diff --git a/buzz/events/doctype/buzz_event/buzz_event.js b/buzz/events/doctype/buzz_event/buzz_event.js index cc4310b3..1dd1f137 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.js +++ b/buzz/events/doctype/buzz_event/buzz_event.js @@ -1,5 +1,8 @@ -// Copyright (c) 2025, BWH Studios and contributors -// For license information, please see license.txt +// Copyright (c) 2025, BWH Studios + +/* ───────────────────────────────────────────────────────────── + FORM CONTROLLER + ───────────────────────────────────────────────────────────── */ const FIELD_LABELS = { category: __("Category"), @@ -311,21 +314,23 @@ frappe.ui.form.on("Buzz Event", { frm.save(); }); - frm.set_query("track", "schedule", (doc, cdt, cdn) => { - return { - filters: { - event: doc.name, + // Clone Event button – only shown on saved documents + if (!frm.is_new()) { + frm.add_custom_button( + __("Clone Event"), + () => { + show_clone_event_dialog(frm); }, - }; + __("Actions") + ); + } + + frm.set_query("track", "schedule", (doc, cdt, cdn) => { + return { filters: { event: doc.name } }; }); frm.set_query("default_ticket_type", (doc) => { - return { - filters: { - event: doc.name, - is_published: 1, - }, - }; + return { filters: { event: doc.name, is_published: 1 } }; }); // Save as Template button @@ -361,7 +366,7 @@ frappe.ui.form.on("Buzz Event", { method: "create_webinar_on_zoom", btn, freeze: true, - }).then(({ message }) => { + }).then(() => { frm.layout.tabs.find((t) => t.label == "Zoom Integration").set_active(); }); }); @@ -377,6 +382,625 @@ frappe.ui.form.on("Buzz Event", { }, }); +/* ───────────────────────────────────────────────────────────────────────────── + SHARED HELPERS + ───────────────────────────────────────────────────────────────────────────── */ + +/** Format "HH:MM:SS" → "HH : MM AM/PM" */ +function buzz_fmt_time(t) { + if (!t) return "—"; + const [hh, mm] = t.split(":"); + let h = parseInt(hh); + const ampm = h >= 12 ? "PM" : "AM"; + h = h % 12 || 12; + return `${String(h).padStart(2, "0")} : ${mm || "00"} ${ampm}`; +} + +/** Format "YYYY-MM-DD" → "Mon, Mar 3" */ +function buzz_fmt_date(d) { + if (!d) return "—"; + return frappe.datetime + .str_to_obj(d) + .toLocaleDateString("en-US", { weekday: "short", month: "short", day: "numeric" }); +} + +/* ───────────────────────────────────────────────────────────────────────────── + COMPUTE OCCURRENCES (pure JS, no library) + ───────────────────────────────────────────────────────────────────────────── */ + +/** + * @param {string} start_date "YYYY-MM-DD" + * @param {string} start_time "HH:MM:SS" + * @param {string} repeat_type "Daily" | "Weekly" | "Monthly" + * @param {number[]} weekdays ISO indices 0=Mon … 6=Sun (only used for Weekly) + * @param {string} end_type "Until" | "For" + * @param {string} until_date "YYYY-MM-DD" (used when end_type === "Until") + * @param {number} for_count integer (used when end_type === "For") + */ +function buzz_compute_occurrences( + start_date, + start_time, + repeat_type, + weekdays, + end_type, + until_date, + for_count +) { + const SAFETY = 500; + const results = []; + const cur = frappe.datetime.str_to_obj(start_date); + + // Compute the end fence date + let limit_date = null; + if (end_type === "Until" && until_date) { + limit_date = frappe.datetime.str_to_obj(until_date); + } else if (end_type === "For") { + const n = Math.max(1, parseInt(for_count) || 1); + limit_date = new Date(cur); + if (repeat_type === "Daily") { + limit_date.setDate(limit_date.getDate() + n - 1); + } else if (repeat_type === "Weekly") { + limit_date.setDate(limit_date.getDate() + n * 7 - 1); + } else if (repeat_type === "Monthly") { + limit_date.setMonth(limit_date.getMonth() + n); + limit_date.setDate(limit_date.getDate() - 1); + } + } + + // Hard fence: two years out + const fence = new Date(cur); + fence.setFullYear(fence.getFullYear() + 2); + + let iters = 0; + + while (iters < SAFETY) { + iters++; + if (cur > fence) break; + if (limit_date && cur > limit_date) break; + + const date_str = frappe.datetime.obj_to_str(cur); + + if (repeat_type === "Daily") { + results.push({ start_date: date_str, start_time }); + cur.setDate(cur.getDate() + 1); + } else if (repeat_type === "Weekly") { + // JS getDay(): 0=Sun … 6=Sat → convert to ISO 0=Mon … 6=Sun + const js_day = cur.getDay(); + const iso_day = js_day === 0 ? 6 : js_day - 1; + if (weekdays.includes(iso_day)) { + results.push({ start_date: date_str, start_time }); + } + cur.setDate(cur.getDate() + 1); + } else if (repeat_type === "Monthly") { + results.push({ start_date: date_str, start_time }); + cur.setMonth(cur.getMonth() + 1); + } + } + + return results; +} + +/* ───────────────────────────────────────────────────────────────────────────── + CLONE EVENT DIALOG + ───────────────────────────────────────────────────────────────────────────── */ + +function show_clone_event_dialog(frm) { + frappe.call({ + method: "buzz.events.doctype.buzz_event.buzz_event.get_clone_event_dialog_html", + args: { context: JSON.stringify({ title: frm.doc.title }) }, + callback(r) { + _build_clone_dialog(frm, r.message); + }, + }); +} + +function _build_clone_dialog(frm, body_html) { + let selected_dates = []; + + // Render the date-rows list inside #clone-dates-list + function render_dates() { + const $list = clone_dialog.$body.find("#clone-dates-list"); + $list.empty(); + + if (!selected_dates.length) { + $list.html( + `

+ ${__("No times added yet.")} +

` + ); + return; + } + + selected_dates.forEach((entry, idx) => { + const $row = $(` +
+
+
+ +
+ `); + $list.append($row); + + // Date control + const date_ctrl = frappe.ui.form.make_control({ + df: { fieldtype: "Date", fieldname: `row_date_${idx}`, label: "" }, + parent: $row.find(".row-date-wrap")[0], + render_input: true, + }); + date_ctrl.refresh(); + $row.find(".row-date-wrap .frappe-control").css({ margin: 0, padding: 0 }); + $row.find(".row-date-wrap .form-group").css({ margin: 0 }); + $row.find(".row-date-wrap label").hide(); + date_ctrl.set_value(entry.start_date || ""); + date_ctrl.$input.on("change blur", () => { + const v = date_ctrl.get_value(); + if (v) selected_dates[idx].start_date = v; + sync_primary_label(); + }); + + // Time control + const time_ctrl = frappe.ui.form.make_control({ + df: { fieldtype: "Time", fieldname: `row_time_${idx}`, label: "" }, + parent: $row.find(".row-time-wrap")[0], + render_input: true, + }); + time_ctrl.refresh(); + $row.find(".row-time-wrap .frappe-control").css({ margin: 0, padding: 0 }); + $row.find(".row-time-wrap .form-group").css({ margin: 0 }); + $row.find(".row-time-wrap label").hide(); + time_ctrl.set_value(entry.start_time || ""); + time_ctrl.$input.on("change blur", () => { + const v = time_ctrl.get_value(); + if (v) selected_dates[idx].start_time = v; + }); + + // Remove + $row.find(".row-remove-btn").on("click", () => { + selected_dates.splice(idx, 1); + render_dates(); + sync_primary_label(); + }); + }); + } + + function sync_primary_label() { + const n = selected_dates.length; + clone_dialog.set_primary_action( + n > 0 ? __("Clone {0} Event(s)", [n]) : __("Clone Event"), + null + ); + } + + const clone_dialog = new frappe.ui.Dialog({ + title: __("Clone Event"), + fields: [ + { + fieldname: "body_html", + fieldtype: "HTML", + options: body_html, + }, + { + label: __("Host"), + fieldname: "host", + fieldtype: "Link", + options: "Event Host", + default: frm.doc.host, + reqd: 1, + }, + ], + primary_action_label: __("Clone Event"), + primary_action(values) { + if (!selected_dates.length) { + frappe.msgprint({ + message: __("Please add at least one date."), + indicator: "orange", + }); + return; + } + clone_dialog.disable_primary_action(); + frappe.call({ + method: "buzz.events.doctype.buzz_event.buzz_event.clone_buzz_event", + args: { name: frm.doc.name, dates: selected_dates, host: values.host }, + callback(r) { + clone_dialog.hide(); + if (r.message && r.message.length) { + const links = r.message + .map((n) => `#${n}`) + .join(", "); + frappe.msgprint({ + title: __("Events Created"), + message: __("{0} Buzz Event(s) created: {1}", [ + r.message.length, + links, + ]), + indicator: "green", + }); + } + }, + error() { + clone_dialog.enable_primary_action(); + }, + }); + }, + }); + + clone_dialog.show(); + render_dates(); + + const $host_field = clone_dialog.fields_dict.host.$wrapper; + const $body_html = clone_dialog.$body.find('[data-fieldname="body_html"]'); + $body_html.before($host_field.detach()); + + clone_dialog.$body.on("click", "#clone-add-time-btn", () => { + show_add_time_dialog( + frm.doc.start_date || frappe.datetime.get_today(), + frm.doc.start_time || "15:00:00", + (entry) => { + selected_dates.push(entry); + render_dates(); + sync_primary_label(); + } + ); + }); + + clone_dialog.$body.on("click", "#clone-recurrence-btn", () => { + show_recurrence_dialog( + frm.doc.start_date || frappe.datetime.get_today(), + frm.doc.start_time || "15:00:00", + (entries) => { + selected_dates = selected_dates.concat(entries); + render_dates(); + sync_primary_label(); + } + ); + }); +} + +/* ───────────────────────────────────────────────────────────────────────────── + ADD SINGLE TIME DIALOG + ───────────────────────────────────────────────────────────────────────────── */ + +function show_add_time_dialog(default_date, default_time, on_add) { + const d = new frappe.ui.Dialog({ + title: __("Add Time"), + fields: [ + { + label: __("Date"), + fieldname: "start_date", + fieldtype: "Date", + reqd: 1, + default: default_date, + }, + { + label: __("Time"), + fieldname: "start_time", + fieldtype: "Time", + default: default_time, + }, + ], + primary_action_label: __("Add"), + primary_action(values) { + on_add({ start_date: values.start_date, start_time: values.start_time }); + d.hide(); + }, + }); + d.show(); +} + +/* ───────────────────────────────────────────────────────────────────────────── + CHOOSE TIMES (RECURRENCE DIALOG) + ───────────────────────────────────────────────────────────────────────────── */ + +function show_recurrence_dialog(default_date, default_time, on_add) { + frappe.call({ + method: "buzz.events.doctype.buzz_event.buzz_event.get_recurrence_dialog_html", + callback(r) { + _build_recurrence_dialog(default_date, default_time, on_add, r.message); + }, + }); +} + +function _build_recurrence_dialog(default_date, default_time, on_add, body_html) { + // ISO weekday index: 0=Mon … 6=Sun + const DAY_META = [ + { idx: 0, label: "M", full: "Monday" }, + { idx: 1, label: "T", full: "Tuesday" }, + { idx: 2, label: "W", full: "Wednesday" }, + { idx: 3, label: "T", full: "Thursday" }, + { idx: 4, label: "F", full: "Friday" }, + { idx: 5, label: "S", full: "Saturday" }, + { idx: 6, label: "S", full: "Sunday" }, + ]; + + // Pre-select the ISO weekday of the starting date + const init_obj = frappe.datetime.str_to_obj(default_date); + const js_day = init_obj.getDay(); // 0=Sun + let selected_weekdays = [js_day === 0 ? 6 : js_day - 1]; + + // JS-owned state for the end conditions (not Frappe fields) + let current_end_type = "For"; // "Until" | "For" + let until_date_state = ""; // set by ControlDate + let for_count_state = 5; // set by number input + // Keep a reference to the live ControlDate so we can read it in refresh_preview + let until_date_ctrl = null; + + const recurrence_dialog = new frappe.ui.Dialog({ + title: __("Choose Times"), + fields: [ + { + label: __("Starting on"), + fieldname: "start_date", + fieldtype: "Date", + reqd: 1, + default: default_date, + }, + { fieldtype: "Column Break" }, + { + label: __("Time"), + fieldname: "start_time", + fieldtype: "Time", + default: default_time, + }, + { fieldtype: "Section Break" }, + { + label: __("Repeats"), + fieldname: "repeat_type", + fieldtype: "Select", + options: ["Daily", "Weekly", "Monthly"].join("\n"), + default: "Weekly", + reqd: 1, + }, + { + fieldname: "recurrence_body", + fieldtype: "HTML", + options: body_html, + }, + ], + primary_action_label: __("Add Times"), + primary_action(values) { + // Read until_date from our ctrl if it exists + if (current_end_type === "Until" && until_date_ctrl) { + until_date_state = until_date_ctrl.get_value() || until_date_state; + } + const occurrences = buzz_compute_occurrences( + values.start_date, + values.start_time || default_time, + values.repeat_type, + selected_weekdays, + current_end_type, + until_date_state, + for_count_state + ); + if (!occurrences.length) { + frappe.msgprint({ + message: __("No dates generated. Check your recurrence settings."), + indicator: "orange", + }); + return; + } + on_add(occurrences); + recurrence_dialog.hide(); + }, + }); + + recurrence_dialog.show(); + + /* ── helpers ── */ + + function get_repeat_type() { + return recurrence_dialog.get_field("repeat_type").get_value() || "Weekly"; + } + + function get_start_date() { + return recurrence_dialog.get_field("start_date").get_value() || default_date; + } + + function get_start_time() { + return recurrence_dialog.get_field("start_time").get_value() || default_time; + } + + /* ── Weekday button styles ── */ + function render_weekday_buttons() { + recurrence_dialog.$body.find(".recurrence-day-btn").each(function () { + const day = parseInt($(this).data("day")); + const active = selected_weekdays.includes(day); + $(this) + .toggleClass("btn-primary", active) + .toggleClass("btn-default", !active) + .css("border", ""); + }); + } + + /* ── Show/hide weekday section ── */ + function toggle_weekday_section() { + const $s = recurrence_dialog.$body.find("#recurrence-weekday-section"); + get_repeat_type() === "Weekly" ? $s.show() : $s.hide(); + } + + /* ── End condition: "Until" date or "For N units" ── */ + function render_end_condition() { + const $wrapper = recurrence_dialog.$body.find("#end-condition-wrapper"); + $wrapper.empty(); + until_date_ctrl = null; + + // Update toggle button active state using Frappe classes + recurrence_dialog.$body.find(".end-type-btn").each(function () { + const active = $(this).data("type") === current_end_type; + $(this).toggleClass("btn-primary", active).toggleClass("btn-default", !active); + }); + + if (current_end_type === "Until") { + // Use Frappe's ControlDate rendered directly into the wrapper + until_date_ctrl = frappe.ui.form.make_control({ + df: { + fieldtype: "Date", + fieldname: "recurrence_until_date", + label: "", + placeholder: __("End date"), + }, + parent: $wrapper[0], + render_input: true, + }); + until_date_ctrl.refresh(); + // Strip extra spacing that make_control adds so it sits flush inline + $wrapper.find(".frappe-control").css({ margin: "0", padding: "0" }); + $wrapper.find(".form-group").css({ margin: "0" }); + $wrapper.find(".frappe-control label").hide(); + // Restore previously chosen date + if (until_date_state) { + until_date_ctrl.set_value(until_date_state); + } + // Wire change + until_date_ctrl.$input.on("change blur", function () { + until_date_state = until_date_ctrl.get_value() || ""; + refresh_preview(); + }); + } else { + // "For N weeks/days/months" + const unit_map = { + Daily: __("days"), + Weekly: __("weeks"), + Monthly: __("months"), + }; + const unit = unit_map[get_repeat_type()] || __("occurrences"); + + $wrapper.html(` + + ${unit} + `); + + $wrapper.find("#end-for-count").on("input change", function () { + for_count_state = Math.max(1, parseInt($(this).val()) || 1); + // Keep the input aligned with state + $(this).val(for_count_state); + refresh_preview(); + }); + } + } + + /* ── Occurrence preview chips ── */ + function refresh_preview() { + const start_date = get_start_date(); + const start_time = get_start_time(); + const repeat_type = get_repeat_type(); + + if (!start_date) return; + + // If Until mode, try to read the latest value from the live ctrl + let effective_until = until_date_state; + if (current_end_type === "Until" && until_date_ctrl) { + effective_until = until_date_ctrl.get_value() || until_date_state; + } + + const occurrences = buzz_compute_occurrences( + start_date, + start_time, + repeat_type, + selected_weekdays, + current_end_type, + effective_until, + for_count_state + ); + + const $preview = recurrence_dialog.$body.find("#recurrence-preview"); + $preview.empty(); + + if (!occurrences.length) { + $preview.html( + ` + ${__("No dates match these settings.")} + ` + ); + recurrence_dialog.set_primary_action(__("Add Times"), null); + return; + } + + const MAX_CHIPS = 4; + const visible = occurrences.slice(0, MAX_CHIPS); + const overflow = occurrences.length - MAX_CHIPS; + + visible.forEach((entry) => { + const d_obj = frappe.datetime.str_to_obj(entry.start_date); + $preview.append(` +
+ + ${d_obj.toLocaleDateString("en-US", { month: "short" })} + + + ${d_obj.getDate()} + + + ${d_obj.toLocaleDateString("en-US", { weekday: "short" }).toUpperCase()} + +
+ `); + }); + + if (overflow > 0) { + $preview.append(` +
+ +${overflow} +
+ `); + } + + recurrence_dialog.set_primary_action(__("Add {0} Time(s)", [occurrences.length]), null); + } + + /* ── Initial render ── */ + render_weekday_buttons(); + toggle_weekday_section(); + render_end_condition(); + refresh_preview(); + + /* ── Event bindings ── */ + + // Weekday toggle + recurrence_dialog.$body.on("click", ".recurrence-day-btn", function () { + const day = parseInt($(this).data("day")); + const idx = selected_weekdays.indexOf(day); + if (idx === -1) { + selected_weekdays.push(day); + } else if (selected_weekdays.length > 1) { + selected_weekdays.splice(idx, 1); // keep at least one day selected + } + render_weekday_buttons(); + refresh_preview(); + }); + + // Until / For toggle + recurrence_dialog.$body.on("click", ".end-type-btn", function () { + const type = $(this).data("type"); + if (current_end_type === type) return; + current_end_type = type; + render_end_condition(); + refresh_preview(); + }); + + // Repeat type change + recurrence_dialog.get_field("repeat_type").$input.on("change", function () { + toggle_weekday_section(); + render_end_condition(); // update "weeks/days/months" unit label in For mode + refresh_preview(); + }); + + // Start date / time changes + recurrence_dialog.get_field("start_date").$input.on("change", () => refresh_preview()); + recurrence_dialog.get_field("start_time").$input.on("change", () => refresh_preview()); +} function getZoomSupportedTimezones() { return [ "Pacific/Midway", diff --git a/buzz/events/doctype/buzz_event/buzz_event.py b/buzz/events/doctype/buzz_event/buzz_event.py index f276693a..e7785dcd 100644 --- a/buzz/events/doctype/buzz_event/buzz_event.py +++ b/buzz/events/doctype/buzz_event/buzz_event.py @@ -213,6 +213,90 @@ def update_zoom_webinar(self): webinar.save() +@frappe.whitelist() +def get_clone_event_dialog_html(context: str | dict | None = None) -> str: + """Render the clone-event dialog template and return the HTML string.""" + import json + + ctx = {} + if context: + ctx = json.loads(context) if isinstance(context, str) else context + + return frappe.render_template("buzz/templates/clone_event_dialog/clone_event_dialog.html", ctx) + + +@frappe.whitelist() +def get_recurrence_dialog_html() -> str: + """Render the recurrence dialog template and return the HTML string.""" + return frappe.render_template("buzz/templates/clone_event_dialog/recurrence_dialog.html", {}) + + +@frappe.whitelist() +def clone_buzz_event(name: str, dates: str | list, host: str | None = None) -> list[str]: + """ + Clone a Buzz Event for each entry in `dates`. + + Args: + name : name of the source Buzz Event + dates : JSON list of {"start_date": "YYYY-MM-DD", "start_time": "HH:MM:SS"} + host : optional Event Host to override on clones + + Returns: + list of newly created Buzz Event names + """ + import json + + from frappe.utils import add_days, date_diff + from frappe.utils.data import get_time_str + + if isinstance(dates, str): + dates = json.loads(dates) + + if not dates: + frappe.throw(frappe._("Please provide at least one date.")) + + source = frappe.get_doc("Buzz Event", name) + + # Preserve the original duration (end_date - start_date offset in days) + duration_days = 0 + if source.end_date and source.start_date: + duration_days = date_diff(source.end_date, source.start_date) + + created = [] + + for entry in dates: + new_doc = frappe.copy_doc(source) + new_doc.start_date = entry.get("start_date") + new_doc.start_time = entry.get("start_time") or source.start_time + + new_doc.start_time = get_time_str(new_doc.start_time) + new_doc.end_time = get_time_str(new_doc.end_time) + + if duration_days: + new_doc.end_date = add_days(new_doc.start_date, duration_days) + else: + new_doc.end_date = new_doc.start_date + + # New clone starts as a draft + new_doc.is_published = 0 + new_doc.route = "" + + if host: + new_doc.host = host + + # Adjust schedule item dates by the same offset + if source.start_date and new_doc.schedule: + for row in new_doc.schedule: + if row.date: + row_offset = date_diff(row.date, source.start_date) + row.date = add_days(new_doc.start_date, row_offset) + + new_doc.insert() + created.append(new_doc.name) + + return created + + @frappe.whitelist() def create_from_template(template_name: str, options: str, additional_fields: str = "{}") -> str: """ diff --git a/buzz/templates/clone_event_dialog/clone_event_dialog.html b/buzz/templates/clone_event_dialog/clone_event_dialog.html new file mode 100644 index 00000000..2b87d753 --- /dev/null +++ b/buzz/templates/clone_event_dialog/clone_event_dialog.html @@ -0,0 +1,47 @@ +
+ + + + + + +
+
{{ title }}
+
+ {{ _("Everything except bookings, tickets, and check-ins will be copied over.") }} +
+
+
+ +
+ {{ _("New Times") }} +
+ +
+ +
+ + +
diff --git a/buzz/templates/clone_event_dialog/recurrence_dialog.html b/buzz/templates/clone_event_dialog/recurrence_dialog.html new file mode 100644 index 00000000..7e7f020c --- /dev/null +++ b/buzz/templates/clone_event_dialog/recurrence_dialog.html @@ -0,0 +1,47 @@ + +
+
+ {{ _("Days of the week") }} +
+
+ {% set day_labels = [("M","Monday"),("T","Tuesday"),("W","Wednesday"),("T","Thursday"),("F","Friday"),("S","Saturday"),("S","Sunday")] %} + {% for label, full in day_labels %} + + {% endfor %} +
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + {{ _("Set options above to preview dates…") }} + +
+