From 4f5f1b1dd61802233944b354775b16590a0afa6a Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sat, 1 Nov 2025 11:41:20 +0530 Subject: [PATCH 1/4] feat: doctype modelling for custom fields --- buzz/api.py | 19 +++- buzz/buzz/doctype/__init__.py | 0 .../doctype/buzz_custom_field/__init__.py | 0 .../buzz_custom_field/buzz_custom_field.js | 8 ++ .../buzz_custom_field/buzz_custom_field.json | 94 +++++++++++++++++++ .../buzz_custom_field/buzz_custom_field.py | 26 +++++ .../test_buzz_custom_field.py | 20 ++++ .../doctype/additional_field/__init__.py | 0 .../additional_field/additional_field.json | 61 ++++++++++++ .../additional_field/additional_field.py | 26 +++++ .../doctype/event_booking/event_booking.json | 14 ++- .../doctype/event_booking/event_booking.py | 2 + .../event_booking_attendee.json | 8 +- .../event_booking_attendee.py | 1 + .../doctype/event_ticket/event_ticket.json | 14 ++- .../doctype/event_ticket/event_ticket.py | 2 + dashboard/src/components/BookingEventInfo.vue | 8 +- .../components/BookingFinancialSummary.vue | 7 +- dashboard/src/components/TicketCard.vue | 11 ++- dashboard/src/pages/TicketDetails.vue | 12 ++- 20 files changed, 316 insertions(+), 17 deletions(-) create mode 100644 buzz/buzz/doctype/__init__.py create mode 100644 buzz/buzz/doctype/buzz_custom_field/__init__.py create mode 100644 buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.js create mode 100644 buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json create mode 100644 buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py create mode 100644 buzz/buzz/doctype/buzz_custom_field/test_buzz_custom_field.py create mode 100644 buzz/ticketing/doctype/additional_field/__init__.py create mode 100644 buzz/ticketing/doctype/additional_field/additional_field.json create mode 100644 buzz/ticketing/doctype/additional_field/additional_field.py diff --git a/buzz/api.py b/buzz/api.py index 444aa70e..25433de4 100644 --- a/buzz/api.py +++ b/buzz/api.py @@ -315,7 +315,14 @@ def get_booking_details(booking_id: str) -> dict: add_ons = frappe.db.get_all( "Ticket Add-on Value", filters={"parent": ("in", (ticket.name for ticket in tickets))}, - fields=["parent", "name", "add_on", "value", "add_on.title as add_on_title", "add_on.user_selects_option as user_selects_option"], + fields=[ + "parent", + "name", + "add_on", + "value", + "add_on.title as add_on_title", + "add_on.user_selects_option as user_selects_option", + ], ) # Get available options for add-ons @@ -569,7 +576,15 @@ def get_ticket_details(ticket_id: str) -> dict: add_ons = frappe.db.get_all( "Ticket Add-on Value", filters={"parent": ticket_id}, - fields=["name", "add_on", "add_on.title as add_on_title", "value", "price", "currency", "add_on.user_selects_option as user_selects_option"], + fields=[ + "name", + "add_on", + "add_on.title as add_on_title", + "value", + "price", + "currency", + "add_on.user_selects_option as user_selects_option", + ], ) # Get available options for add-ons (for preference management) diff --git a/buzz/buzz/doctype/__init__.py b/buzz/buzz/doctype/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz/doctype/buzz_custom_field/__init__.py b/buzz/buzz/doctype/buzz_custom_field/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.js b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.js new file mode 100644 index 00000000..d3d527a8 --- /dev/null +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.js @@ -0,0 +1,8 @@ +// Copyright (c) 2025, BWH Studios and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Buzz Custom Field", { +// refresh(frm) { + +// }, +// }); diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json new file mode 100644 index 00000000..f417112f --- /dev/null +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json @@ -0,0 +1,94 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-11-01 11:29:38.327158", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "enabled", + "event", + "label", + "fieldname", + "column_break_fpgn", + "applied_to", + "fieldtype", + "options" + ], + "fields": [ + { + "fieldname": "event", + "fieldtype": "Link", + "in_list_view": 1, + "label": "Event", + "options": "Buzz Event", + "reqd": 1 + }, + { + "fieldname": "column_break_fpgn", + "fieldtype": "Column Break" + }, + { + "default": "Booking", + "fieldname": "applied_to", + "fieldtype": "Select", + "label": "Applied To", + "options": "Booking\nTicket" + }, + { + "fieldname": "label", + "fieldtype": "Data", + "label": "Label", + "reqd": 1 + }, + { + "fieldname": "fieldname", + "fieldtype": "Data", + "label": "Name" + }, + { + "fieldname": "fieldtype", + "fieldtype": "Select", + "label": "Type", + "options": "Phone\nEmail\nSelect", + "reqd": 1 + }, + { + "fieldname": "options", + "fieldtype": "Small Text", + "label": "Options" + }, + { + "default": "1", + "fieldname": "enabled", + "fieldtype": "Check", + "label": "Enabled?" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "links": [], + "modified": "2025-11-01 11:32:19.415686", + "modified_by": "Administrator", + "module": "Buzz", + "name": "Buzz Custom Field", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py new file mode 100644 index 00000000..96bb7f4a --- /dev/null +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025, BWH Studios and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class BuzzCustomField(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + applied_to: DF.Literal["Booking", "Ticket"] + enabled: DF.Check + event: DF.Link + fieldname: DF.Data | None + fieldtype: DF.Literal["Phone", "Email", "Select"] + label: DF.Data + options: DF.SmallText | None + # end: auto-generated types + + pass diff --git a/buzz/buzz/doctype/buzz_custom_field/test_buzz_custom_field.py b/buzz/buzz/doctype/buzz_custom_field/test_buzz_custom_field.py new file mode 100644 index 00000000..c6ba3fe2 --- /dev/null +++ b/buzz/buzz/doctype/buzz_custom_field/test_buzz_custom_field.py @@ -0,0 +1,20 @@ +# Copyright (c) 2025, BWH Studios and Contributors +# See license.txt + +# import frappe +from frappe.tests import IntegrationTestCase + +# On IntegrationTestCase, the doctype test records and all +# link-field test record dependencies are recursively loaded +# Use these module variables to add/remove to/from that list +EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] +IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"] + + +class IntegrationTestBuzzCustomField(IntegrationTestCase): + """ + Integration tests for BuzzCustomField. + Use this class for testing interactions between multiple components. + """ + + pass diff --git a/buzz/ticketing/doctype/additional_field/__init__.py b/buzz/ticketing/doctype/additional_field/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/buzz/ticketing/doctype/additional_field/additional_field.json b/buzz/ticketing/doctype/additional_field/additional_field.json new file mode 100644 index 00000000..b8cdba0d --- /dev/null +++ b/buzz/ticketing/doctype/additional_field/additional_field.json @@ -0,0 +1,61 @@ +{ + "actions": [], + "allow_rename": 1, + "creation": "2025-11-01 11:36:52.321319", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "label", + "fieldname", + "column_break_nptw", + "value", + "fieldtype" + ], + "fields": [ + { + "fieldname": "label", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Label" + }, + { + "fieldname": "fieldname", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Fieldname", + "reqd": 1 + }, + { + "fieldname": "value", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Value", + "reqd": 1 + }, + { + "fieldname": "column_break_nptw", + "fieldtype": "Column Break" + }, + { + "fieldname": "fieldtype", + "fieldtype": "Data", + "label": "Fieldtype" + } + ], + "grid_page_length": 50, + "index_web_pages_for_search": 1, + "istable": 1, + "links": [], + "modified": "2025-11-01 11:38:22.581796", + "modified_by": "Administrator", + "module": "Ticketing", + "name": "Additional Field", + "owner": "Administrator", + "permissions": [], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [] +} diff --git a/buzz/ticketing/doctype/additional_field/additional_field.py b/buzz/ticketing/doctype/additional_field/additional_field.py new file mode 100644 index 00000000..87587f28 --- /dev/null +++ b/buzz/ticketing/doctype/additional_field/additional_field.py @@ -0,0 +1,26 @@ +# Copyright (c) 2025, BWH Studios and contributors +# For license information, please see license.txt + +# import frappe +from frappe.model.document import Document + + +class AdditionalField(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.types import DF + + fieldname: DF.Data + fieldtype: DF.Data | None + label: DF.Data | None + parent: DF.Data + parentfield: DF.Data + parenttype: DF.Data + value: DF.Data + # end: auto-generated types + + pass diff --git a/buzz/ticketing/doctype/event_booking/event_booking.json b/buzz/ticketing/doctype/event_booking/event_booking.json index 8226b0db..9112d7df 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.json +++ b/buzz/ticketing/doctype/event_booking/event_booking.json @@ -10,6 +10,8 @@ "user", "section_break_xvkp", "attendees", + "section_break_suav", + "additional_fields", "section_break_tsys", "net_amount", "tax_percentage", @@ -111,6 +113,16 @@ "label": "Tax Amount", "options": "currency", "read_only": 1 + }, + { + "fieldname": "section_break_suav", + "fieldtype": "Section Break" + }, + { + "fieldname": "additional_fields", + "fieldtype": "Table", + "label": "Additional Fields", + "options": "Additional Field" } ], "grid_page_length": 50, @@ -126,7 +138,7 @@ "link_fieldname": "reference_docname" } ], - "modified": "2025-10-28 16:18:05.762804", + "modified": "2025-11-01 11:38:55.175532", "modified_by": "Administrator", "module": "Ticketing", "name": "Event Booking", diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py index 76801715..2fd50b4f 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.py +++ b/buzz/ticketing/doctype/event_booking/event_booking.py @@ -18,8 +18,10 @@ class EventBooking(Document): if TYPE_CHECKING: from frappe.types import DF + from buzz.ticketing.doctype.additional_field.additional_field import AdditionalField from buzz.ticketing.doctype.event_booking_attendee.event_booking_attendee import EventBookingAttendee + additional_fields: DF.Table[AdditionalField] amended_from: DF.Link | None attendees: DF.Table[EventBookingAttendee] currency: DF.Link diff --git a/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.json b/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.json index 6e020f9d..a2c0c9e8 100644 --- a/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.json +++ b/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.json @@ -8,6 +8,7 @@ "field_order": [ "full_name", "ticket_type", + "custom_fields", "column_break_xmfr", "email", "amount", @@ -82,13 +83,18 @@ "in_list_view": 1, "label": "Number of Add Ons", "read_only": 1 + }, + { + "fieldname": "custom_fields", + "fieldtype": "JSON", + "label": "Custom Fields" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "istable": 1, "links": [], - "modified": "2025-07-30 19:30:24.828429", + "modified": "2025-11-01 11:33:49.166876", "modified_by": "Administrator", "module": "Ticketing", "name": "Event Booking Attendee", diff --git a/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.py b/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.py index f908f24d..19626e53 100644 --- a/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.py +++ b/buzz/ticketing/doctype/event_booking_attendee/event_booking_attendee.py @@ -18,6 +18,7 @@ class EventBookingAttendee(Document): add_ons: DF.Link | None amount: DF.Currency currency: DF.Link + custom_fields: DF.JSON | None email: DF.Data full_name: DF.Data number_of_add_ons: DF.Int diff --git a/buzz/ticketing/doctype/event_ticket/event_ticket.json b/buzz/ticketing/doctype/event_ticket/event_ticket.json index 74957df9..ed0cb578 100644 --- a/buzz/ticketing/doctype/event_ticket/event_ticket.json +++ b/buzz/ticketing/doctype/event_ticket/event_ticket.json @@ -13,6 +13,8 @@ "attendee_email", "ticket_type", "qr_code", + "section_break_ijdn", + "additional_fields", "section_break_cgvb", "add_ons", "section_break_yzvi", @@ -101,6 +103,16 @@ "fieldtype": "Link", "label": "Coupon Used ", "options": "Bulk Ticket Coupon" + }, + { + "fieldname": "section_break_ijdn", + "fieldtype": "Section Break" + }, + { + "fieldname": "additional_fields", + "fieldtype": "Table", + "label": "Additional Fields", + "options": "Additional Field" } ], "grid_page_length": 50, @@ -113,7 +125,7 @@ "link_fieldname": "ticket" } ], - "modified": "2025-10-28 16:17:49.890585", + "modified": "2025-11-01 11:39:46.084897", "modified_by": "Administrator", "module": "Ticketing", "name": "Event Ticket", diff --git a/buzz/ticketing/doctype/event_ticket/event_ticket.py b/buzz/ticketing/doctype/event_ticket/event_ticket.py index 5f67f288..86e0b5bc 100644 --- a/buzz/ticketing/doctype/event_ticket/event_ticket.py +++ b/buzz/ticketing/doctype/event_ticket/event_ticket.py @@ -15,9 +15,11 @@ class EventTicket(Document): if TYPE_CHECKING: from frappe.types import DF + from buzz.ticketing.doctype.additional_field.additional_field import AdditionalField from buzz.ticketing.doctype.ticket_add_on_value.ticket_add_on_value import TicketAddonValue add_ons: DF.Table[TicketAddonValue] + additional_fields: DF.Table[AdditionalField] amended_from: DF.Link | None attendee_email: DF.Data attendee_name: DF.Data diff --git a/dashboard/src/components/BookingEventInfo.vue b/dashboard/src/components/BookingEventInfo.vue index 9603367e..9b87c91d 100644 --- a/dashboard/src/components/BookingEventInfo.vue +++ b/dashboard/src/components/BookingEventInfo.vue @@ -19,7 +19,9 @@ Start Date -

{{ formatEventDateTime(event.start_date, event.start_time) }}

+

+ {{ formatEventDateTime(event.start_date, event.start_time) }} +

@@ -28,7 +30,9 @@ End Date -

{{ formatEventDateTime(event.end_date, event.end_time) }}

+

+ {{ formatEventDateTime(event.end_date, event.end_time) }} +

diff --git a/dashboard/src/components/BookingFinancialSummary.vue b/dashboard/src/components/BookingFinancialSummary.vue index 2f32ed12..86a79ba3 100644 --- a/dashboard/src/components/BookingFinancialSummary.vue +++ b/dashboard/src/components/BookingFinancialSummary.vue @@ -2,12 +2,7 @@

Payment Summary

- + diff --git a/dashboard/src/components/TicketCard.vue b/dashboard/src/components/TicketCard.vue index 125e7039..6ffa01aa 100644 --- a/dashboard/src/components/TicketCard.vue +++ b/dashboard/src/components/TicketCard.vue @@ -21,7 +21,12 @@ {{ ticket.attendee_name }}

Email: {{ ticket.attendee_email }}

-

Ticket Type: {{ ticket.ticket_type }}

+

+ Ticket Type: {{ ticket.ticket_type }} +

@@ -33,7 +38,9 @@ class="bg-surface-gray-1 px-3 py-2 rounded text-xs" >
{{ addon.title }}
-
{{ addon.value }}
+
+ {{ addon.value }} +
diff --git a/dashboard/src/pages/TicketDetails.vue b/dashboard/src/pages/TicketDetails.vue index a853e9bd..4da7bf03 100644 --- a/dashboard/src/pages/TicketDetails.vue +++ b/dashboard/src/pages/TicketDetails.vue @@ -83,7 +83,13 @@

{{ ticketDetails.data.doc.event_title }}

-
+
@@ -153,7 +159,9 @@

{{ addon.title || addon.name }}

-

{{ addon.value }}

+

+ {{ addon.value }} +

From 8deb1ef8cd1bfe71fcd996fb07e3a9a119cea5db Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sat, 1 Nov 2025 12:50:28 +0530 Subject: [PATCH 2/4] feat: collect and store custom fields --- buzz/api.py | 51 ++++++++-- .../buzz_custom_field/buzz_custom_field.json | 11 ++- .../buzz_custom_field/buzz_custom_field.py | 2 +- .../events/doctype/buzz_event/buzz_event.json | 7 +- .../doctype/event_booking/event_booking.py | 31 ++++++ dashboard/components.d.ts | 1 + .../src/components/AttendeeFormControl.vue | 94 ++++++++++++++---- dashboard/src/components/BookingForm.vue | 68 ++++++++++++- .../src/components/CustomFieldsSection.vue | 96 +++++++++++++++++++ dashboard/src/pages/BookTickets.vue | 3 + 10 files changed, 329 insertions(+), 35 deletions(-) create mode 100644 dashboard/src/components/CustomFieldsSection.vue diff --git a/buzz/api.py b/buzz/api.py index 25433de4..5f96474a 100644 --- a/buzz/api.py +++ b/buzz/api.py @@ -145,14 +145,43 @@ def get_event_booking_data(event_route: str) -> dict: data.event_details = event_doc + # Custom Fields + custom_fields = frappe.db.get_all( + "Buzz Custom Field", filters={"event": event_doc.name, "enabled": 1}, fields=["*"] + ) + data.custom_fields = custom_fields + return data @frappe.whitelist() -def process_booking(attendees: list[dict], event: str) -> dict: +def process_booking(attendees: list[dict], event: str, booking_custom_fields: dict | None = None) -> dict: booking = frappe.new_doc("Event Booking") booking.event = event booking.user = frappe.session.user + + # Add booking-level custom fields + if booking_custom_fields: + # Get custom field definitions for this event to get proper labels and types + booking_custom_field_defs = frappe.db.get_all( + "Buzz Custom Field", + filters={"event": event, "enabled": 1, "applied_to": "Booking"}, + fields=["fieldname", "label", "fieldtype"], + ) + custom_field_map = {cf["fieldname"]: cf for cf in booking_custom_field_defs} + + for field_name, field_value in booking_custom_fields.items(): + if field_value and field_name in custom_field_map: # Only add non-empty values and valid fields + field_def = custom_field_map[field_name] + booking.append( + "additional_fields", + { + "fieldname": field_name, + "value": str(field_value), + "label": field_def["label"], + "fieldtype": field_def["fieldtype"], + }, + ) for attendee in attendees: add_ons = attendee.get("add_ons", None) if add_ons: @@ -161,15 +190,17 @@ def process_booking(attendees: list[dict], event: str) -> dict: add_ons=add_ons, ) - booking.append( - "attendees", - { - "full_name": attendee.get("full_name"), - "email": attendee.get("email"), - "ticket_type": attendee.get("ticket_type"), - "add_ons": add_ons.name if add_ons else None, - }, - ) + # Process custom fields for this attendee + custom_fields = attendee.get("custom_fields", {}) + attendee_row = { + "full_name": attendee.get("full_name"), + "email": attendee.get("email"), + "ticket_type": attendee.get("ticket_type"), + "add_ons": add_ons.name if add_ons else None, + "custom_fields": custom_fields if custom_fields else None, + } + + booking.append("attendees", attendee_row) booking.insert(ignore_permissions=True) frappe.db.commit() diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json index f417112f..939bc862 100644 --- a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json @@ -19,6 +19,7 @@ "fieldname": "event", "fieldtype": "Link", "in_list_view": 1, + "in_standard_filter": 1, "label": "Event", "options": "Buzz Event", "reqd": 1 @@ -37,6 +38,7 @@ { "fieldname": "label", "fieldtype": "Data", + "in_list_view": 1, "label": "Label", "reqd": 1 }, @@ -46,10 +48,12 @@ "label": "Name" }, { + "default": "Data", "fieldname": "fieldtype", "fieldtype": "Select", + "in_list_view": 1, "label": "Type", - "options": "Phone\nEmail\nSelect", + "options": "Data\nPhone\nEmail\nSelect", "reqd": 1 }, { @@ -67,7 +71,7 @@ "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-11-01 11:32:19.415686", + "modified": "2025-11-01 12:46:05.333010", "modified_by": "Administrator", "module": "Buzz", "name": "Buzz Custom Field", @@ -90,5 +94,6 @@ "rows_threshold_for_grid_search": 20, "sort_field": "creation", "sort_order": "DESC", - "states": [] + "states": [], + "title_field": "label" } diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py index 96bb7f4a..47b2cb59 100644 --- a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py @@ -18,7 +18,7 @@ class BuzzCustomField(Document): enabled: DF.Check event: DF.Link fieldname: DF.Data | None - fieldtype: DF.Literal["Phone", "Email", "Select"] + fieldtype: DF.Literal["Data", "Phone", "Email", "Select"] label: DF.Data options: DF.SmallText | None # end: auto-generated types diff --git a/buzz/events/doctype/buzz_event/buzz_event.json b/buzz/events/doctype/buzz_event/buzz_event.json index 25a6fd4e..e7b92103 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.json +++ b/buzz/events/doctype/buzz_event/buzz_event.json @@ -359,9 +359,14 @@ "group": "General", "link_doctype": "Additional Event Page", "link_fieldname": "event" + }, + { + "group": "Customisations", + "link_doctype": "Buzz Custom Field", + "link_fieldname": "event" } ], - "modified": "2025-10-29 17:22:01.221301", + "modified": "2025-11-01 12:04:09.030132", "modified_by": "Administrator", "module": "Events", "name": "Buzz Event", diff --git a/buzz/ticketing/doctype/event_booking/event_booking.py b/buzz/ticketing/doctype/event_booking/event_booking.py index 2fd50b4f..2d839aed 100644 --- a/buzz/ticketing/doctype/event_booking/event_booking.py +++ b/buzz/ticketing/doctype/event_booking/event_booking.py @@ -108,6 +108,37 @@ def generate_tickets(self): if attendee.add_ons: add_ons_list = frappe.get_cached_doc("Attendee Ticket Add-on", attendee.add_ons).add_ons ticket.add_ons = add_ons_list + + # Add custom fields from attendee to ticket + if attendee.custom_fields: + custom_fields_data = attendee.custom_fields + if isinstance(custom_fields_data, str): + try: + custom_fields_data = json.loads(custom_fields_data) + except (json.JSONDecodeError, TypeError): + custom_fields_data = {} + + # Get custom field definitions for this event to get proper labels and types + custom_field_defs = frappe.db.get_all( + "Buzz Custom Field", + filters={"event": self.event, "enabled": 1, "applied_to": "Ticket"}, + fields=["fieldname", "label", "fieldtype"], + ) + custom_field_map = {cf["fieldname"]: cf for cf in custom_field_defs} + + for field_name, field_value in custom_fields_data.items(): + if field_value and field_name in custom_field_map: + field_def = custom_field_map[field_name] + ticket.append( + "additional_fields", + { + "fieldname": field_name, + "value": str(field_value), + "label": field_def["label"], + "fieldtype": field_def["fieldtype"], + }, + ) + ticket.flags.ignore_permissions = 1 ticket.insert().submit() diff --git a/dashboard/components.d.ts b/dashboard/components.d.ts index cb78265f..f47b1cb8 100644 --- a/dashboard/components.d.ts +++ b/dashboard/components.d.ts @@ -19,6 +19,7 @@ declare module 'vue' { BuzzLogo: typeof import('./src/components/common/BuzzLogo.vue')['default'] CancellationRequestDialog: typeof import('./src/components/CancellationRequestDialog.vue')['default'] CancellationRequestNotice: typeof import('./src/components/CancellationRequestNotice.vue')['default'] + CustomFieldsSection: typeof import('./src/components/CustomFieldsSection.vue')['default'] EventDetailsHeader: typeof import('./src/components/EventDetailsHeader.vue')['default'] EventSelector: typeof import('./src/components/EventSelector.vue')['default'] EventSponsorForm: typeof import('./src/components/EventSponsorForm.vue')['default'] diff --git a/dashboard/src/components/AttendeeFormControl.vue b/dashboard/src/components/AttendeeFormControl.vue index 4298d280..b29a306d 100644 --- a/dashboard/src/components/AttendeeFormControl.vue +++ b/dashboard/src/components/AttendeeFormControl.vue @@ -20,7 +20,7 @@ Attendee #{{ index + 1 }} - +
-
- -
+ + - -
- -
- {{ availableTicketTypes[0].title }} - ({{ - formatPriceOrFree( - availableTicketTypes[0].price, - availableTicketTypes[0].currency - ) - }}) -
-
+ + +
@@ -119,6 +118,7 @@ const props = defineProps({ index: { type: Number, required: true }, availableTicketTypes: { type: Array, required: true }, availableAddOns: { type: Array, required: true }, + customFields: { type: Array, default: () => [] }, showRemove: { type: Boolean, default: false }, }); @@ -169,4 +169,60 @@ const updateAddOnOption = (addOnName, option) => { ensureAddOnExists(addOnName); props.attendee.add_ons[addOnName].option = option; }; + +// Custom fields helper methods +const ensureCustomFieldsExists = () => { + if (!props.attendee.custom_fields) { + props.attendee.custom_fields = {}; + } +}; + +const getCustomFieldValue = (fieldname) => { + ensureCustomFieldsExists(); + return props.attendee.custom_fields[fieldname] || ""; +}; + +const updateCustomFieldValue = (fieldname, value) => { + ensureCustomFieldsExists(); + props.attendee.custom_fields[fieldname] = value; +}; + +// Convert Frappe field types to form control types +const getFormControlType = (fieldtype) => { + switch (fieldtype) { + case "Phone": + return "text"; + case "Email": + return "email"; + case "Select": + return "select"; + default: + return "text"; + } +}; + +// Get field options for select fields +const getFieldOptions = (field) => { + if (field.fieldtype === "Select" && field.options) { + return field.options.split("\n").map((option) => ({ + label: option.trim(), + value: option.trim(), + })); + } + return []; +}; + +// Get placeholder text based on field type +const getFieldPlaceholder = (field) => { + switch (field.fieldtype) { + case "Phone": + return "Enter phone number"; + case "Email": + return "Enter email address"; + case "Select": + return `Select ${field.label.toLowerCase()}`; + default: + return `Enter ${field.label.toLowerCase()}`; + } +}; diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 05b040cd..2a04ec08 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -7,6 +7,18 @@
+ +
+ +
+ @@ -68,10 +81,11 @@ diff --git a/dashboard/src/pages/BookTickets.vue b/dashboard/src/pages/BookTickets.vue index 9a72a558..04592860 100644 --- a/dashboard/src/pages/BookTickets.vue +++ b/dashboard/src/pages/BookTickets.vue @@ -10,6 +10,7 @@ :availableTicketTypes="eventBookingData.availableTicketTypes" :gstSettings="eventBookingData.gstSettings" :eventDetails="eventBookingData.eventDetails" + :customFields="eventBookingData.customFields" />
@@ -25,6 +26,7 @@ const eventBookingData = reactive({ availableTicketTypes: null, gstSettings: null, eventDetails: null, + customFields: null, }); const props = defineProps({ @@ -48,6 +50,7 @@ const eventBookingResource = createResource({ gst_percentage: 18, }; eventBookingData.eventDetails = data.event_details || {}; + eventBookingData.customFields = data.custom_fields || []; }, onError: (error) => { if (error.message.includes("DoesNotExistError")) { From 151d4fc1be349d812eff10de7fcd08744ecd7005 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sat, 1 Nov 2025 12:58:12 +0530 Subject: [PATCH 3/4] feat: support for mandatory and placeholder config --- .../buzz_custom_field/buzz_custom_field.json | 15 +++- .../buzz_custom_field/buzz_custom_field.py | 2 + .../src/components/AttendeeFormControl.vue | 20 +++-- dashboard/src/components/BookingForm.vue | 75 +++++++++++++++---- .../src/components/CustomFieldsSection.vue | 20 +++-- 5 files changed, 93 insertions(+), 39 deletions(-) diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json index 939bc862..1f51212b 100644 --- a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.json @@ -9,6 +9,8 @@ "event", "label", "fieldname", + "mandatory", + "placeholder", "column_break_fpgn", "applied_to", "fieldtype", @@ -66,12 +68,23 @@ "fieldname": "enabled", "fieldtype": "Check", "label": "Enabled?" + }, + { + "default": "0", + "fieldname": "mandatory", + "fieldtype": "Check", + "label": "Mandatory?" + }, + { + "fieldname": "placeholder", + "fieldtype": "Data", + "label": "Placeholder" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "links": [], - "modified": "2025-11-01 12:46:05.333010", + "modified": "2025-11-01 12:56:21.483252", "modified_by": "Administrator", "module": "Buzz", "name": "Buzz Custom Field", diff --git a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py index 47b2cb59..7892f099 100644 --- a/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py +++ b/buzz/buzz/doctype/buzz_custom_field/buzz_custom_field.py @@ -20,7 +20,9 @@ class BuzzCustomField(Document): fieldname: DF.Data | None fieldtype: DF.Literal["Data", "Phone", "Email", "Select"] label: DF.Data + mandatory: DF.Check options: DF.SmallText | None + placeholder: DF.Data | None # end: auto-generated types pass diff --git a/dashboard/src/components/AttendeeFormControl.vue b/dashboard/src/components/AttendeeFormControl.vue index b29a306d..ff1ce6aa 100644 --- a/dashboard/src/components/AttendeeFormControl.vue +++ b/dashboard/src/components/AttendeeFormControl.vue @@ -63,7 +63,7 @@ :label="field.label" :type="getFormControlType(field.fieldtype)" :options="getFieldOptions(field)" - :required="true" + :required="field.mandatory" :placeholder="getFieldPlaceholder(field)" /> @@ -212,17 +212,15 @@ const getFieldOptions = (field) => { return []; }; -// Get placeholder text based on field type +// Get placeholder text - use custom placeholder if available, otherwise no placeholder const getFieldPlaceholder = (field) => { - switch (field.fieldtype) { - case "Phone": - return "Enter phone number"; - case "Email": - return "Enter email address"; - case "Select": - return `Select ${field.label.toLowerCase()}`; - default: - return `Enter ${field.label.toLowerCase()}`; + // If custom placeholder is provided, use it + if (field.placeholder?.trim()) { + const placeholder = field.placeholder.trim(); + return field.mandatory ? `${placeholder} (required)` : placeholder; } + + // If no custom placeholder is provided, return empty string (no placeholder) + return ""; }; diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 2a04ec08..28e44404 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -86,7 +86,7 @@ import AttendeeFormControl from "./AttendeeFormControl.vue"; import BookingSummary from "./BookingSummary.vue"; import EventDetailsHeader from "./EventDetailsHeader.vue"; import CustomFieldsSection from "./CustomFieldsSection.vue"; -import { createResource } from "frappe-ui"; +import { createResource, toast } from "frappe-ui"; import { useBookingFormStorage } from "../composables/useBookingFormStorage.js"; import { useRouter } from "vue-router"; import { userResource } from "../data/user.js"; @@ -323,10 +323,53 @@ const processBooking = createResource({ url: "buzz.api.process_booking", }); +// --- FORM VALIDATION --- +const validateForm = () => { + const errors = []; + + // Validate booking-level mandatory fields + for (const field of bookingCustomFields.value) { + if (field.mandatory) { + const value = bookingCustomFieldsData.value[field.fieldname]; + if (!value || !String(value).trim()) { + errors.push(`${field.label} is required`); + } + } + } + + // Validate ticket-level mandatory fields for each attendee + attendees.value.forEach((attendee, index) => { + for (const field of ticketCustomFields.value) { + if (field.mandatory) { + const value = attendee.custom_fields?.[field.fieldname]; + if (!value || !String(value).trim()) { + errors.push(`${field.label} is required for Attendee #${index + 1}`); + } + } + } + }); + + return errors; +}; + // --- FORM SUBMISSION --- async function submit() { if (processBooking.loading) return; + // Validate mandatory fields + const validationErrors = validateForm(); + if (validationErrors.length > 0) { + // Show the first error as toast, or all errors if only a few + if (validationErrors.length === 1) { + toast.error(validationErrors[0]); + } else if (validationErrors.length <= 3) { + toast.error(`Please fill in the required fields:\n${validationErrors.join("\n")}`); + } else { + toast.error(`Please fill in ${validationErrors.length} required fields.`); + } + return; + } + const attendees_payload = attendees.value.map((attendee) => { const cleanAttendee = JSON.parse(JSON.stringify(attendee)); const selected_add_ons = []; @@ -341,17 +384,17 @@ async function submit() { } cleanAttendee.add_ons = selected_add_ons; - // Clean custom fields - only include fields that have values and match our custom field definitions + // Clean custom fields - include all valid fields (mandatory fields are validated separately) if (cleanAttendee.custom_fields) { const cleanedCustomFields = {}; for (const [fieldName, value] of Object.entries(cleanAttendee.custom_fields)) { - if (value != null && String(value).trim()) { - // Check if this is a valid custom field for tickets - const isValidField = ticketCustomFields.value.some( - (cf) => cf.fieldname === fieldName - ); - if (isValidField) { - cleanedCustomFields[fieldName] = value; + // Check if this is a valid custom field for tickets + const fieldDef = ticketCustomFields.value.find((cf) => cf.fieldname === fieldName); + if (fieldDef) { + // Include mandatory fields even if empty (validation already passed) + // For non-mandatory fields, only include if they have values + if (fieldDef.mandatory || (value != null && String(value).trim())) { + cleanedCustomFields[fieldName] = value || ""; } } } @@ -365,13 +408,13 @@ async function submit() { // Clean booking custom fields const cleanedBookingCustomFields = {}; for (const [fieldName, value] of Object.entries(bookingCustomFieldsData.value)) { - if (value != null && String(value).trim()) { - // Check if this is a valid custom field for bookings - const isValidField = bookingCustomFields.value.some( - (cf) => cf.fieldname === fieldName - ); - if (isValidField) { - cleanedBookingCustomFields[fieldName] = value; + // Check if this is a valid custom field for bookings + const fieldDef = bookingCustomFields.value.find((cf) => cf.fieldname === fieldName); + if (fieldDef) { + // Include mandatory fields even if empty (validation already passed) + // For non-mandatory fields, only include if they have values + if (fieldDef.mandatory || (value != null && String(value).trim())) { + cleanedBookingCustomFields[fieldName] = value || ""; } } } diff --git a/dashboard/src/components/CustomFieldsSection.vue b/dashboard/src/components/CustomFieldsSection.vue index 5cae21b1..9406c74b 100644 --- a/dashboard/src/components/CustomFieldsSection.vue +++ b/dashboard/src/components/CustomFieldsSection.vue @@ -13,7 +13,7 @@ :label="field.label" :type="getFormControlType(field.fieldtype)" :options="getFieldOptions(field)" - :required="true" + :required="field.mandatory" :placeholder="getFieldPlaceholder(field)" /> @@ -80,17 +80,15 @@ const getFieldOptions = (field) => { return []; }; -// Get placeholder text based on field type +// Get placeholder text - use custom placeholder if available, otherwise no placeholder const getFieldPlaceholder = (field) => { - switch (field.fieldtype) { - case "Phone": - return "Enter phone number"; - case "Email": - return "Enter email address"; - case "Select": - return `Select ${field.label.toLowerCase()}`; - default: - return `Enter ${field.label.toLowerCase()}`; + // If custom placeholder is provided, use it + if (field.placeholder?.trim()) { + const placeholder = field.placeholder.trim(); + return field.mandatory ? `${placeholder} (required)` : placeholder; } + + // If no custom placeholder is provided, return empty string (no placeholder) + return ""; }; From c53a970f469ba0a865507eec25cf3861e6144d76 Mon Sep 17 00:00:00 2001 From: Hussain Nagaria Date: Sat, 1 Nov 2025 13:16:23 +0530 Subject: [PATCH 4/4] fix: store custom booking fields in local storage --- .../src/components/AttendeeFormControl.vue | 63 +++++++++++++++++-- dashboard/src/components/BookingForm.vue | 12 ++-- .../src/components/CustomFieldsSection.vue | 63 +++++++++++++++++-- .../src/composables/useBookingFormStorage.js | 5 +- 4 files changed, 128 insertions(+), 15 deletions(-) diff --git a/dashboard/src/components/AttendeeFormControl.vue b/dashboard/src/components/AttendeeFormControl.vue index ff1ce6aa..3f099ff9 100644 --- a/dashboard/src/components/AttendeeFormControl.vue +++ b/dashboard/src/components/AttendeeFormControl.vue @@ -179,7 +179,23 @@ const ensureCustomFieldsExists = () => { const getCustomFieldValue = (fieldname) => { ensureCustomFieldsExists(); - return props.attendee.custom_fields[fieldname] || ""; + const currentValue = props.attendee.custom_fields[fieldname]; + + // Apply default for select fields that don't have values yet + if (!currentValue && currentValue !== "") { + const field = props.customFields.find((f) => f.fieldname === fieldname); + if (field && field.fieldtype === "Select") { + const options = getFieldOptions(field); + if (options.length > 0) { + // Set the first option as default + const firstOptionValue = options[0].value; + updateCustomFieldValue(fieldname, firstOptionValue); + return firstOptionValue; + } + } + } + + return currentValue || ""; }; const updateCustomFieldValue = (fieldname, value) => { @@ -204,10 +220,47 @@ const getFormControlType = (fieldtype) => { // Get field options for select fields const getFieldOptions = (field) => { if (field.fieldtype === "Select" && field.options) { - return field.options.split("\n").map((option) => ({ - label: option.trim(), - value: option.trim(), - })); + // Handle different formats of options + let options = []; + + if (typeof field.options === "string") { + // Split by newlines and filter out empty options + options = field.options + .split("\n") + .map((option) => option.trim()) + .filter((option) => option.length > 0); + } else if (Array.isArray(field.options)) { + // If options is already an array + options = field.options.filter((option) => { + try { + return option != null && String(option).trim().length > 0; + } catch { + return false; + } + }); + } + + const formattedOptions = options.map((option) => { + const optionStr = String(option).trim(); + return { + label: optionStr, + value: optionStr, + }; + }); + + // Debug log for development + if ( + process.env.NODE_ENV === "development" && + formattedOptions.length === 0 && + field.options + ) { + console.warn( + `CustomField "${field.fieldname}" has Select type but no valid options:`, + field.options + ); + } + + return formattedOptions; } return []; }; diff --git a/dashboard/src/components/BookingForm.vue b/dashboard/src/components/BookingForm.vue index 28e44404..075a87e5 100644 --- a/dashboard/src/components/BookingForm.vue +++ b/dashboard/src/components/BookingForm.vue @@ -121,10 +121,14 @@ const props = defineProps({ // --- STATE --- // Use the booking form storage composable -const { attendees, attendeeIdCounter } = useBookingFormStorage(); - -// Custom fields data -const bookingCustomFieldsData = ref({}); +const { + attendees, + attendeeIdCounter, + bookingCustomFields: storedBookingCustomFields, +} = useBookingFormStorage(); + +// Use stored booking custom fields data +const bookingCustomFieldsData = storedBookingCustomFields; // Ensure user data is loaded if (!userResource.data) { diff --git a/dashboard/src/components/CustomFieldsSection.vue b/dashboard/src/components/CustomFieldsSection.vue index 9406c74b..7553857f 100644 --- a/dashboard/src/components/CustomFieldsSection.vue +++ b/dashboard/src/components/CustomFieldsSection.vue @@ -46,7 +46,23 @@ const emit = defineEmits(["update:modelValue"]); // Get field value from model const getFieldValue = (fieldname) => { - return props.modelValue[fieldname] || ""; + const currentValue = props.modelValue[fieldname]; + + // Apply default for select fields that don't have values yet + if (!currentValue && currentValue !== "") { + const field = props.customFields.find((f) => f.fieldname === fieldname); + if (field && field.fieldtype === "Select") { + const options = getFieldOptions(field); + if (options.length > 0) { + // Set the first option as default + const firstOptionValue = options[0].value; + updateFieldValue(fieldname, firstOptionValue); + return firstOptionValue; + } + } + } + + return currentValue || ""; }; // Update field value in model @@ -72,10 +88,47 @@ const getFormControlType = (fieldtype) => { // Get field options for select fields const getFieldOptions = (field) => { if (field.fieldtype === "Select" && field.options) { - return field.options.split("\n").map((option) => ({ - label: option.trim(), - value: option.trim(), - })); + // Handle different formats of options + let options = []; + + if (typeof field.options === "string") { + // Split by newlines and filter out empty options + options = field.options + .split("\n") + .map((option) => option.trim()) + .filter((option) => option.length > 0); + } else if (Array.isArray(field.options)) { + // If options is already an array + options = field.options.filter((option) => { + try { + return option != null && String(option).trim().length > 0; + } catch { + return false; + } + }); + } + + const formattedOptions = options.map((option) => { + const optionStr = String(option).trim(); + return { + label: optionStr, + value: optionStr, + }; + }); + + // Debug log for development + if ( + process.env.NODE_ENV === "development" && + formattedOptions.length === 0 && + field.options + ) { + console.warn( + `CustomField "${field.fieldname}" has Select type but no valid options:`, + field.options + ); + } + + return formattedOptions; } return []; }; diff --git a/dashboard/src/composables/useBookingFormStorage.js b/dashboard/src/composables/useBookingFormStorage.js index 6e665e2e..7b7aea38 100644 --- a/dashboard/src/composables/useBookingFormStorage.js +++ b/dashboard/src/composables/useBookingFormStorage.js @@ -8,6 +8,7 @@ export function useBookingFormStorage() { // Use the same storage keys as in BookingForm.vue const attendees = useStorage("event-booking-attendees", []); const attendeeIdCounter = useStorage("event-booking-counter", 0); + const bookingCustomFields = useStorage("event-booking-custom-fields", {}); /** * Clear all stored booking form data @@ -16,18 +17,20 @@ export function useBookingFormStorage() { const clearStoredData = () => { attendees.value = []; attendeeIdCounter.value = 0; + bookingCustomFields.value = {}; }; /** * Check if there's any stored booking data */ const hasStoredData = () => { - return attendees.value.length > 0; + return attendees.value.length > 0 || Object.keys(bookingCustomFields.value).length > 0; }; return { attendees, attendeeIdCounter, + bookingCustomFields, clearStoredData, hasStoredData, };