diff --git a/lukenya_marathon/build.json b/lukenya_marathon/build.json new file mode 100644 index 0000000..8b6e2b4 --- /dev/null +++ b/lukenya_marathon/build.json @@ -0,0 +1,8 @@ +{ + "lukenya_marathon.bundle.js": [ + "public/js/lukenya_navigation.js" + ], + "lukenya_marathon.bundle.css": [ + "public/css/lukenya_navigation.css" + ] +} diff --git a/lukenya_marathon/hooks.py b/lukenya_marathon/hooks.py index 87a849a..bb3ac7f 100644 --- a/lukenya_marathon/hooks.py +++ b/lukenya_marathon/hooks.py @@ -247,3 +247,42 @@ app_license = "mit" # List of apps whose translatable strings should be excluded from this app's translations. # ignore_translatable_strings_from = [] +# --- Race management foundations (added by apply_foundations.py) --- + +doc_events = { + "Bib Allocation": { + "on_update": "lukenya_marathon.lukenya_marathon.race_sync.on_bib_change", + "on_trash": "lukenya_marathon.lukenya_marathon.race_sync.on_trash", + }, + "Race Pack Issue": { + "on_update": "lukenya_marathon.lukenya_marathon.race_sync.on_pack_change", + "on_trash": "lukenya_marathon.lukenya_marathon.race_sync.on_trash", + }, + "Check-in": { + "on_update": "lukenya_marathon.lukenya_marathon.race_sync.on_checkin_change", + "on_trash": "lukenya_marathon.lukenya_marathon.race_sync.on_trash", + }, + "Timing Result": { + "on_update": "lukenya_marathon.lukenya_marathon.race_sync.on_result_change", + "on_trash": "lukenya_marathon.lukenya_marathon.race_sync.on_trash", + }, +} + +scheduler_events = { + "hourly": [ + "lukenya_marathon.lukenya_marathon.holds.hourly", + ], +} + + + +app_include_js = [ + "/assets/lukenya_marathon/js/lukenya_navigation.js" +] + +# Replaces the default Frappe navbar logo (top-left) for this app. +app_logo_url = "/assets/lukenya_marathon/images/lukenya-logo.png" + +app_include_css = [ + "/assets/lukenya_marathon/css/lukenya_navigation.css" +] diff --git a/lukenya_marathon/lukenya_marathon/analytics.py b/lukenya_marathon/lukenya_marathon/analytics.py new file mode 100644 index 0000000..f31ee8a --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/analytics.py @@ -0,0 +1,1052 @@ +"""Analytics behind the Reports & Analytics page and the standard script reports. + +One place for the queries so the page, the charts and the downloadable reports always agree. + +Definitions used throughout +- An "entry" is a Registration that is not Cancelled, Transferred or Refunded. +- Revenue is the sum of Payment Transactions with status Paid or Reconciled. +- Outstanding is what active entries still owe: amount due minus revenue received, never below zero. +- Date range filters entries by registration date. On the Payments tab it filters payments by + payment date, and on the Volunteers tab it filters shifts by shift date. +""" + +import frappe +from frappe import _ +from frappe.utils import add_days, cint, flt, getdate, nowdate + +CURRENCY = "KES" + +PAID = ("Paid", "Reconciled") +INACTIVE = ("Cancelled", "Transferred", "Refunded") +CONFIRMED = ("Confirmed", "Completed") +UNSETTLED = ("Unpaid", "Pending", "Partially Paid", "Failed") +BIB_LIVE = ("Allocated", "Printed", "Issued", "Replaced") +PACK_OUT = ("Issued", "Collected") +ACTIVE_VOLUNTEER = ("Approved", "Briefed", "Active") + +AGE_BANDS = [ + (0, 17, "Under 18"), + (18, 24, "18-24"), + (25, 34, "25-34"), + (35, 44, "35-44"), + (45, 54, "45-54"), + (55, 120, "55+"), +] + +UNPAID_ALERT_DAYS = 3 +NEAR_CAPACITY_PERCENT = 90 + +# (paid amount per registration) used by several queries +PAID_BY_REG = ( + "(select registration, sum(amount) as paid from `tabPayment Transaction` " + "where status in %(paid)s group by registration)" +) + + +# ---- small helpers ----------------------------------------------------------------------- + + +def _f(filters): + return frappe._dict(filters or {}) + + +def _rows(sql, params=None): + return frappe.db.sql(sql, params or {}, as_dict=True) + + +def _one(sql, params=None): + rows = _rows(sql, params) + return rows[0] if rows else frappe._dict() + + +def _has(doctype): + return frappe.db.table_exists(doctype) + + +def _pct(part, whole): + return round(flt(part) / flt(whole) * 100, 1) if flt(whole) else None + + +def _reg_scope(filters, alias="r", active=True, dates=True): + """WHERE clause and params for queries over Registration rows.""" + f = _f(filters) + where = ["1 = 1"] + params = {"paid": PAID, "inactive": INACTIVE} + if active: + where.append(f"{alias}.registration_status not in %(inactive)s") + if f.event_edition: + where.append(f"{alias}.event_edition = %(event_edition)s") + params["event_edition"] = f.event_edition + if f.race_category: + where.append(f"{alias}.race_category = %(race_category)s") + params["race_category"] = f.race_category + if dates and f.from_date: + where.append(f"date({alias}.registration_date) >= %(from_date)s") + params["from_date"] = getdate(f.from_date) + if dates and f.to_date: + where.append(f"date({alias}.registration_date) <= %(to_date)s") + params["to_date"] = getdate(f.to_date) + return " and ".join(where), params + + +def _fmt_duration(seconds): + if seconds is None: + return "" + total = int(round(flt(seconds))) + return f"{total // 3600}:{(total % 3600) // 60:02d}:{total % 60:02d}" + + +def _age(dob, ref): + dob, ref = getdate(dob), getdate(ref) + age = ref.year - dob.year - ((ref.month, ref.day) < (dob.month, dob.day)) + return age if 0 <= age <= 120 else None + + +def _band(age): + if age is None: + return _("Not given") + for low, high, label in AGE_BANDS: + if low <= age <= high: + return label + return _("Not given") + + +# ---- payload builders -------------------------------------------------------------------- + + +def _kpi(label, value, fmt="int", hint=None): + return {"label": label, "value": value, "format": fmt, "hint": hint} + + +def _chart(cid, title, ctype, labels, datasets, fmt=None, wide=False, report=None): + return { + "id": cid, + "title": title, + "type": ctype, + "labels": labels, + "datasets": datasets, + "format": fmt, + "wide": wide, + "report": report, + } + + +def _series(rows, name, label="label", value="value", blank=None): + blank = blank or _("Not given") + return ( + [str(r[label]) if r[label] not in (None, "") else blank for r in rows], + [{"name": name, "values": [flt(r[value]) for r in rows]}], + ) + + +def _table(tid, title, columns, rows, report=None, empty=None): + return { + "id": tid, + "title": title, + "columns": columns, + "rows": rows, + "report": report, + "empty": empty or _("Nothing to show yet."), + } + + +def _attention(text, count, report=None, doctype=None, filters=None): + return {"text": text, "count": count, "report": report, "doctype": doctype, "filters": filters or {}} + + +def _payload(kpis=None, charts=None, tables=None, attention=None): + return { + "kpis": kpis or [], + "charts": charts or [], + "tables": tables or [], + "attention": [a for a in (attention or []) if a["count"]], + } + + +# ---- shared datasets --------------------------------------------------------------------- + + +def registration_summary(filters=None): + """One row per active race category with registrations, payments and capacity use.""" + f = _f(filters) + params = {"paid": PAID, "inactive": INACTIVE} + cat_where = ["c.active = 1"] + join_extra = "" + rev_extra = "" + if f.event_edition: + cat_where.append("c.event_edition = %(event_edition)s") + params["event_edition"] = f.event_edition + if f.race_category: + cat_where.append("c.name = %(race_category)s") + params["race_category"] = f.race_category + if f.from_date: + join_extra += " and date(r.registration_date) >= %(from_date)s" + rev_extra += " and date(r2.registration_date) >= %(from_date)s" + params["from_date"] = getdate(f.from_date) + if f.to_date: + join_extra += " and date(r.registration_date) <= %(to_date)s" + rev_extra += " and date(r2.registration_date) <= %(to_date)s" + params["to_date"] = getdate(f.to_date) + + rows = _rows( + f""" + select c.name as race_category, c.category_name, c.event_edition, + coalesce(c.capacity, 0) as capacity, + count(r.name) as registered, + coalesce(sum(r.registration_status in ('Confirmed', 'Completed')), 0) as confirmed, + coalesce(sum(r.payment_status = 'Paid'), 0) as paid_entries, + coalesce(sum(r.amount_due), 0) as billed, + coalesce(max(rev.revenue), 0) as revenue + from `tabRace Category` c + left join `tabRegistration` r + on r.race_category = c.name and r.registration_status not in %(inactive)s {join_extra} + left join ( + select r2.race_category, sum(p2.paid) as revenue + from `tabRegistration` r2 + join {PAID_BY_REG} p2 on p2.registration = r2.name + where 1 = 1 {rev_extra} + group by r2.race_category + ) rev on rev.race_category = c.name + where {" and ".join(cat_where)} + group by c.name, c.category_name, c.event_edition, c.capacity, c.distance_km + order by c.event_edition desc, c.distance_km desc + """, + params, + ) + for row in rows: + row.capacity = cint(row.capacity) + row.registered = cint(row.registered) + row.confirmed = cint(row.confirmed) + row.paid_entries = cint(row.paid_entries) + row.billed = flt(row.billed) + row.revenue = flt(row.revenue) + row.fill_percent = _pct(row.registered, row.capacity) + return rows + + +def unpaid_registrations(filters=None, limit=None): + """Active entries that still owe money, oldest first.""" + where, params = _reg_scope(filters) + params["unsettled"] = UNSETTLED + limit_sql = f"limit {int(limit)}" if limit else "" + rows = _rows( + f""" + select r.name as registration, + concat_ws(' ', pa.first_name, pa.last_name) as participant_name, + r.race_category, c.category_name, r.registration_date, r.payment_status, + r.amount_due, coalesce(p.paid, 0) as paid, + greatest(r.amount_due - coalesce(p.paid, 0), 0) as balance, + datediff(curdate(), date(r.registration_date)) as days_open + from `tabRegistration` r + join `tabParticipant` pa on pa.name = r.participant + left join `tabRace Category` c on c.name = r.race_category + left join {PAID_BY_REG} p on p.registration = r.name + where {where} + and r.payment_status in %(unsettled)s + and coalesce(r.price_tier, '') != 'Complimentary' + and greatest(r.amount_due - coalesce(p.paid, 0), 0) > 0 + order by days_open desc, r.registration_date + {limit_sql} + """, + params, + ) + for row in rows: + row.amount_due = flt(row.amount_due) + row.paid = flt(row.paid) + row.balance = flt(row.balance) + row.days_open = cint(row.days_open) + return rows + + +def payment_reconciliation(filters=None, limit=None): + """Entries whose payment status and recorded payments disagree.""" + where, params = _reg_scope(filters, active=False) + limit_sql = f"limit {int(limit)}" if limit else "" + rows = _rows( + f""" + select * from ( + select r.name as registration, + concat_ws(' ', pa.first_name, pa.last_name) as participant_name, + r.race_category, r.registration_date, r.payment_status, + r.amount_due, coalesce(p.paid, 0) as paid, + coalesce(p.paid, 0) - r.amount_due as difference, + case + when r.registration_status in ('Cancelled', 'Refunded') and coalesce(p.paid, 0) > 0 + then 'Cancelled or refunded, payment still on record' + when r.registration_status in %(inactive)s then null + when r.payment_status = 'Paid' and r.amount_due > 0 and coalesce(p.paid, 0) = 0 + then 'Marked paid, no payment recorded' + when r.payment_status = 'Paid' and abs(coalesce(p.paid, 0) - r.amount_due) > 0.5 + then 'Amount paid differs from amount due' + when r.payment_status in ('Unpaid', 'Pending', 'Failed') and coalesce(p.paid, 0) > 0 + then 'Payment recorded, entry not marked paid' + end as issue + from `tabRegistration` r + join `tabParticipant` pa on pa.name = r.participant + left join {PAID_BY_REG} p on p.registration = r.name + where {where} + ) x + where x.issue is not null + order by x.registration_date + {limit_sql} + """, + params, + ) + for row in rows: + row.amount_due = flt(row.amount_due) + row.paid = flt(row.paid) + row.difference = flt(row.difference) + return rows + + +# ---- tab: overview ----------------------------------------------------------------------- + + +def _totals(filters): + where, params = _reg_scope(filters) + row = _one( + f""" + select count(r.name) as total, + coalesce(sum(r.registration_status in ('Confirmed', 'Completed')), 0) as confirmed, + coalesce(sum(r.payment_status = 'Paid'), 0) as paid_entries, + coalesce(sum(greatest(r.amount_due - coalesce(p.paid, 0), 0)), 0) as outstanding + from `tabRegistration` r + left join {PAID_BY_REG} p on p.registration = r.name + where {where} + """, + params, + ) + # Revenue counts money received on any entry, including ones since cancelled or transferred. + any_where, any_params = _reg_scope(filters, active=False) + row["revenue"] = _one( + f""" + select coalesce(sum(p.paid), 0) as revenue + from `tabRegistration` r + join {PAID_BY_REG} p on p.registration = r.name + where {any_where} + """, + any_params, + ).revenue + return row + + +def _per_day(filters, days=60): + where, params = _reg_scope(filters) + rows = _rows( + f""" + select date(r.registration_date) as day, count(*) as value + from `tabRegistration` r + where {where} and r.registration_date is not null + group by date(r.registration_date) + order by day desc + limit {int(days)} + """, + params, + ) + rows.reverse() + for row in rows: + row["label"] = getdate(row["day"]).strftime("%d %b") + return rows + + +def _editions_compare(): + return _rows( + f""" + select e.edition_name as label, count(r.name) as registrations, + coalesce(sum(p.paid), 0) as revenue + from `tabEvent Edition` e + left join `tabRegistration` r + on r.event_edition = e.name and r.registration_status not in %(inactive)s + left join {PAID_BY_REG} p on p.registration = r.name + group by e.name, e.edition_name, e.event_date + order by e.event_date + """, + {"paid": PAID, "inactive": INACTIVE}, + ) + + +def _returning_runners(): + row = _one( + """ + select count(*) as n from ( + select participant from `tabRegistration` + where registration_status not in %(inactive)s + group by participant + having count(distinct event_edition) > 1 + ) x + """, + {"inactive": INACTIVE}, + ) + return cint(row.n) + + +def _category_columns(): + return [ + {"label": _("Category"), "field": "category_name", "type": "text"}, + {"label": _("Capacity"), "field": "capacity", "type": "int"}, + {"label": _("Registered"), "field": "registered", "type": "int"}, + {"label": _("Confirmed"), "field": "confirmed", "type": "int"}, + {"label": _("Paid"), "field": "paid_entries", "type": "int"}, + {"label": _("Filled"), "field": "fill_percent", "type": "percent"}, + {"label": _("Revenue"), "field": "revenue", "type": "currency"}, + ] + + +def _overview(filters): + f = _f(filters) + totals = _totals(f) + summary = registration_summary(f) + unpaid = unpaid_registrations(f) + recon = payment_reconciliation(f) + + limited = [r for r in summary if r.capacity] + capacity_total = sum(r.capacity for r in limited) + fill = _pct(sum(r.registered for r in limited), capacity_total) + multi_edition = len({r.event_edition for r in summary}) > 1 + + def cat_label(row): + return f"{row.category_name} ({row.event_edition})" if multi_edition else row.category_name + + kpis = [ + _kpi(_("Entries"), cint(totals.total)), + _kpi(_("Confirmed"), cint(totals.confirmed), hint=_("{0}% of entries").format(_pct(totals.confirmed, totals.total) or 0)), + _kpi(_("Revenue"), flt(totals.revenue), "currency", hint=_("From paid and reconciled payments")), + _kpi(_("Outstanding"), flt(totals.outstanding), "currency", hint=_("Amount due minus payments received")), + _kpi(_("Paid entries"), _pct(totals.paid_entries, totals.total), "percent", hint=_("{0} of {1} entries").format(cint(totals.paid_entries), cint(totals.total))), + _kpi(_("Capacity filled"), fill, "percent", hint=_("Categories with a capacity set")), + ] + + editions = _editions_compare() + show_trend = len(editions) > 1 and not f.event_edition + if show_trend: + kpis.append(_kpi(_("Returning runners"), _returning_runners(), hint=_("Entered more than one edition"))) + + per_day = _per_day(f) + charts = [ + _chart( + "per_day", + _("Entries per day"), + "line", + [r["label"] for r in per_day], + [{"name": _("Entries"), "values": [flt(r["value"]) for r in per_day]}], + wide=True, + ), + _chart( + "capacity", + _("Entries against capacity"), + "bar", + [cat_label(r) for r in summary], + [ + {"name": _("Registered"), "values": [r.registered for r in summary]}, + {"name": _("Capacity"), "values": [r.capacity for r in summary]}, + ], + report={"name": "Registration Summary"}, + ), + ] + if show_trend: + charts.append( + _chart( + "edition_entries", + _("Entries by edition"), + "bar", + [e["label"] for e in editions], + [{"name": _("Entries"), "values": [cint(e["registrations"]) for e in editions]}], + ) + ) + charts.append( + _chart( + "edition_revenue", + _("Revenue by edition"), + "bar", + [e["label"] for e in editions], + [{"name": _("Revenue"), "values": [flt(e["revenue"]) for e in editions]}], + fmt="currency", + ) + ) + + near_full = [r for r in limited if r.fill_percent is not None and r.fill_percent >= NEAR_CAPACITY_PERCENT] + attention = [ + _attention( + _("entries unpaid for more than {0} days").format(UNPAID_ALERT_DAYS), + len([u for u in unpaid if u.days_open > UNPAID_ALERT_DAYS]), + report="Unpaid Registrations", + ), + _attention(_("payments where the records disagree"), len(recon), report="Payment Reconciliation"), + _attention(_("categories at {0}% capacity or more").format(NEAR_CAPACITY_PERCENT), len(near_full), report="Registration Summary"), + ] + + return _payload( + kpis, + charts, + [_table("categories", _("Entries by category"), _category_columns(), summary, report={"name": "Registration Summary"})], + attention, + ) + + +# ---- tab: registrations ------------------------------------------------------------------ + + +def _group(sql_field, joins, filters, active=True, alias="r", order="value desc", limit=None): + where, params = _reg_scope(filters, alias=alias, active=active) + limit_sql = f"limit {int(limit)}" if limit else "" + return _rows( + f""" + select {sql_field} as label, count(*) as value + from `tabRegistration` {alias} + {joins} + where {where} + group by {sql_field} + order by {order} + {limit_sql} + """, + params, + ) + + +def _registrations(filters): + f = _f(filters) + totals = _totals(f) + all_where, all_params = _reg_scope(f, active=False) + status_counts = _rows( + f""" + select r.registration_status as label, count(*) as value + from `tabRegistration` r + where {all_where} + group by r.registration_status + """, + all_params, + ) + order = ["Pending", "Confirmed", "Completed", "Cancelled", "Transferred", "Refunded"] + status_counts.sort(key=lambda r: order.index(r.label) if r.label in order else 99) + dropped = sum(cint(r.value) for r in status_counts if r.label in INACTIVE) + + join_p = "join `tabParticipant` p on p.name = r.participant" + gender = _group("coalesce(nullif(p.gender, ''), 'Not given')", join_p, f) + nationality = _group("coalesce(nullif(trim(p.nationality), ''), 'Not given')", join_p, f, limit=8) + shirts = _group("coalesce(nullif(p.shirt_size, ''), 'Not given')", join_p, f, order="(label = 'Not given'), field(label, 'XS','S','M','L','XL','XXL'), label") + tiers = _group("coalesce(nullif(r.price_tier, ''), 'Not given')", "", f) + + where, params = _reg_scope(f) + ages = _rows( + f""" + select p.date_of_birth as dob, e.event_date + from `tabRegistration` r + join `tabParticipant` p on p.name = r.participant + left join `tabEvent Edition` e on e.name = r.event_edition + where {where} + """, + params, + ) + counts = {} + for row in ages: + if not row.dob: + label = _("Not given") + else: + label = _band(_age(row.dob, row.event_date or nowdate())) + counts[label] = counts.get(label, 0) + 1 + band_order = [b[2] for b in AGE_BANDS] + [_("Not given")] + age_rows = [{"label": b, "value": counts[b]} for b in band_order if b in counts] + + non_resident = next((cint(r.value) for r in tiers if r.label == "Non-resident"), 0) + + kpis = [ + _kpi(_("Entries"), cint(totals.total)), + _kpi(_("Confirmed"), cint(totals.confirmed)), + _kpi(_("Cancelled or refunded"), dropped, hint=_("Not counted as entries")), + _kpi(_("Non-resident entries"), non_resident), + ] + + def chart(cid, title, ctype, rows, name=None, wide=False): + labels, datasets = _series(rows, name or _("Entries")) + return _chart(cid, title, ctype, labels, datasets, wide=wide) + + charts = [ + chart("status", _("Entry status"), "bar", status_counts), + chart("gender", _("Gender"), "donut", gender), + chart("age", _("Age group on race day"), "bar", age_rows), + chart("tier", _("Price tier"), "bar", tiers), + chart("nationality", _("Top nationalities"), "bar", nationality), + chart("shirts", _("Shirt sizes"), "bar", shirts), + ] + return _payload(kpis, charts, [_table("categories", _("Entries by category"), _category_columns(), registration_summary(f), report={"name": "Registration Summary"})]) + + +# ---- tab: payments ----------------------------------------------------------------------- + + +def _pay_scope(filters): + f = _f(filters) + where = ["1 = 1"] + params = {"paid": PAID} + if f.event_edition: + where.append("r.event_edition = %(event_edition)s") + params["event_edition"] = f.event_edition + if f.race_category: + where.append("r.race_category = %(race_category)s") + params["race_category"] = f.race_category + when = "date(coalesce(pt.payment_date, pt.creation))" + if f.from_date: + where.append(f"{when} >= %(from_date)s") + params["from_date"] = getdate(f.from_date) + if f.to_date: + where.append(f"{when} <= %(to_date)s") + params["to_date"] = getdate(f.to_date) + return " and ".join(where), params + + +def _payments(filters): + f = _f(filters) + where, params = _pay_scope(f) + base = "from `tabPayment Transaction` pt join `tabRegistration` r on r.name = pt.registration" + + total = _one(f"select coalesce(sum(pt.amount), 0) as revenue, count(*) as n {base} where {where} and pt.status in %(paid)s", params) + failed = _one(f"select count(*) as n {base} where {where} and pt.status = 'Failed'", params) + by_gateway = _rows(f"select coalesce(nullif(pt.gateway, ''), 'Other') as label, sum(pt.amount) as value {base} where {where} and pt.status in %(paid)s group by label order by value desc", params) + by_category = _rows(f"select coalesce(c.category_name, r.race_category) as label, sum(pt.amount) as value {base} left join `tabRace Category` c on c.name = r.race_category where {where} and pt.status in %(paid)s group by label order by value desc", params) + daily = _rows(f"select date(coalesce(pt.payment_date, pt.creation)) as day, sum(pt.amount) as value {base} where {where} and pt.status in %(paid)s group by day order by day desc limit 60", params) + daily.reverse() + reasons = _rows(f"select coalesce(nullif(trim(pt.failure_reason), ''), 'No reason given') as label, count(*) as value {base} where {where} and pt.status = 'Failed' group by label order by value desc limit 8", params) + + totals = _totals(f) + unpaid = unpaid_registrations(f) + recon = payment_reconciliation(f) + + kpis = [ + _kpi(_("Revenue"), flt(total.revenue), "currency", hint=_("{0} successful payments").format(cint(total.n))), + _kpi(_("Outstanding"), flt(totals.outstanding), "currency", hint=_("Amount due minus payments received")), + _kpi(_("Failed payments"), cint(failed.n)), + _kpi(_("Records that disagree"), len(recon), hint=_("See reconciliation below")), + ] + + def chart(cid, title, ctype, rows, name, fmt=None, wide=False): + labels, datasets = _series(rows, name) + return _chart(cid, title, ctype, labels, datasets, fmt=fmt, wide=wide) + + daily_rows = [{"label": getdate(r["day"]).strftime("%d %b"), "value": r["value"]} for r in daily] + charts = [ + chart("daily", _("Revenue per day"), "line", daily_rows, _("Revenue"), fmt="currency", wide=True), + chart("gateway", _("Revenue by payment method"), "donut", by_gateway, _("Revenue"), fmt="currency"), + chart("category", _("Revenue by category"), "bar", by_category, _("Revenue"), fmt="currency"), + chart("reasons", _("Why payments failed"), "bar", reasons, _("Payments")), + ] + + unpaid_cols = [ + {"label": _("Entry"), "field": "registration", "type": "link", "doctype": "Registration"}, + {"label": _("Runner"), "field": "participant_name", "type": "text"}, + {"label": _("Category"), "field": "category_name", "type": "text"}, + {"label": _("Status"), "field": "payment_status", "type": "text"}, + {"label": _("Due"), "field": "amount_due", "type": "currency"}, + {"label": _("Paid"), "field": "paid", "type": "currency"}, + {"label": _("Balance"), "field": "balance", "type": "currency"}, + {"label": _("Days open"), "field": "days_open", "type": "int"}, + ] + recon_cols = [ + {"label": _("Entry"), "field": "registration", "type": "link", "doctype": "Registration"}, + {"label": _("Runner"), "field": "participant_name", "type": "text"}, + {"label": _("Issue"), "field": "issue", "type": "text"}, + {"label": _("Due"), "field": "amount_due", "type": "currency"}, + {"label": _("Paid"), "field": "paid", "type": "currency"}, + {"label": _("Difference"), "field": "difference", "type": "currency"}, + ] + tables = [ + _table("unpaid", _("Unpaid entries, oldest first"), unpaid_cols, unpaid[:15], report={"name": "Unpaid Registrations"}, empty=_("No unpaid entries.")), + _table("recon", _("Payment reconciliation"), recon_cols, recon[:15], report={"name": "Payment Reconciliation"}, empty=_("Payments and entries agree.")), + ] + attention = [ + _attention( + _("payments failed"), + cint(failed.n), + doctype="Payment Transaction", + filters={"status": "Failed"}, + ), + _attention(_("entries unpaid for more than {0} days").format(UNPAID_ALERT_DAYS), len([u for u in unpaid if u.days_open > UNPAID_ALERT_DAYS]), report="Unpaid Registrations"), + ] + return _payload(kpis, charts, tables, attention) + + +# ---- tab: race day ----------------------------------------------------------------------- + + +def _race_day(filters): + f = _f(filters) + where, params = _reg_scope(f) + params.update(bib_live=BIB_LIVE, pack_out=PACK_OUT) + has_checkin = _has("Check-in") + + confirmed = cint( + _one(f"select count(*) as n from `tabRegistration` r where {where} and r.registration_status in ('Confirmed', 'Completed')", params).n + ) + bibs = cint( + _one( + f"""select count(distinct b.registration) as n from `tabBib Allocation` b + join `tabRegistration` r on r.name = b.registration where {where} and b.status in %(bib_live)s""", + params, + ).n + ) + packs = cint( + _one( + f"""select count(distinct i.registration) as n from `tabRace Pack Issue` i + join `tabRegistration` r on r.name = i.registration where {where} and i.issue_status in %(pack_out)s""", + params, + ).n + ) + checked_in = did_not_start = 0 + if has_checkin: + checked_in = cint( + _one( + f"""select count(distinct ci.registration) as n from `tabCheck-in` ci + join `tabRegistration` r on r.name = ci.registration where {where} and ci.status = 'Checked In'""", + params, + ).n + ) + did_not_start = cint( + _one( + f"""select count(distinct ci.registration) as n from `tabCheck-in` ci + join `tabRegistration` r on r.name = ci.registration where {where} and ci.status = 'Did Not Start'""", + params, + ).n + ) + + bib_status = _rows(f"select b.status as label, count(*) as value from `tabBib Allocation` b join `tabRegistration` r on r.name = b.registration where {where} group by b.status order by value desc", params) + pack_status = _rows(f"select i.issue_status as label, count(*) as value from `tabRace Pack Issue` i join `tabRegistration` r on r.name = i.registration where {where} group by i.issue_status order by value desc", params) + methods = [] + if has_checkin: + methods = _rows(f"select ci.check_in_method as label, count(*) as value from `tabCheck-in` ci join `tabRegistration` r on r.name = ci.registration where {where} and ci.status = 'Checked In' group by ci.check_in_method order by value desc", params) + + checkin_join = ( + "left join (select distinct registration from `tabCheck-in` where status = 'Checked In') ci on ci.registration = r.name" + if has_checkin + else "left join (select null as registration limit 0) ci on ci.registration = r.name" + ) + progress = _rows( + f""" + select c.category_name as label, + coalesce(sum(r.registration_status in ('Confirmed', 'Completed')), 0) as confirmed, + count(ci.registration) as checked_in + from `tabRace Category` c + join `tabRegistration` r on r.race_category = c.name + {checkin_join} + where {where} + group by c.name, c.category_name, c.distance_km + order by c.distance_km desc + """, + params, + ) + + no_bib = cint( + _one( + f"""select count(*) as n from `tabRegistration` r + where {where} and r.registration_status in ('Confirmed', 'Completed') and r.payment_status = 'Paid' + and not exists (select 1 from `tabBib Allocation` b where b.registration = r.name and b.status in %(bib_live)s)""", + params, + ).n + ) + uncollected = cint( + _one( + f"""select count(distinct i.registration) as n from `tabRace Pack Issue` i + join `tabRegistration` r on r.name = i.registration where {where} and i.issue_status = 'Uncollected'""", + params, + ).n + ) + + def chart(cid, title, ctype, rows, name): + labels, datasets = _series(rows, name) + return _chart(cid, title, ctype, labels, datasets) + + kpis = [ + _kpi(_("Confirmed entries"), confirmed), + _kpi(_("Bibs allocated"), bibs, hint=_("{0}% of confirmed").format(_pct(bibs, confirmed) or 0)), + _kpi(_("Race packs out"), packs, hint=_("Issued or collected")), + _kpi(_("Checked in"), checked_in, hint=_("{0}% of confirmed").format(_pct(checked_in, confirmed) or 0)), + _kpi(_("Did not start"), did_not_start), + ] + charts = [ + _chart( + "progress", + _("Check-in progress by category"), + "bar", + [r.label for r in progress], + [ + {"name": _("Confirmed"), "values": [cint(r.confirmed) for r in progress]}, + {"name": _("Checked in"), "values": [cint(r.checked_in) for r in progress]}, + ], + wide=True, + ), + chart("bibs", _("Bib allocations"), "bar", bib_status, _("Bibs")), + chart("packs", _("Race packs"), "donut", pack_status, _("Packs")), + chart("methods", _("How runners checked in"), "bar", methods, _("Check-ins")), + ] + attention = [ + _attention(_("paid entries have no bib yet"), no_bib), + _attention( + _("race packs marked uncollected"), + uncollected, + doctype="Race Pack Issue", + filters={"issue_status": "Uncollected"}, + ), + ] + return _payload(kpis, charts, [], attention) + + +# ---- tab: results and prizes ------------------------------------------------------------- + +_TIME = "coalesce(nullif(t.chip_time, 0), nullif(t.gun_time, 0))" # a zero time means no time + + +def _result_scope(filters): + f = _f(filters) + where, params = ["1 = 1"], {} + if f.event_edition: + where.append("t.event_edition = %(event_edition)s") + params["event_edition"] = f.event_edition + if f.race_category: + where.append("t.race_category = %(race_category)s") + params["race_category"] = f.race_category + return " and ".join(where), params + + +def _top_ten(gender, where, params): + rows = _rows( + f""" + select t.name as result, t.overall_rank, t.bib_number, + concat_ws(' ', p.first_name, p.last_name) as runner, + c.category_name, {_TIME} as seconds, t.approved + from `tabTiming Result` t + join `tabRegistration` r on r.name = t.registration + join `tabParticipant` p on p.name = r.participant + left join `tabRace Category` c on c.name = t.race_category + where {where} and t.finish_status = 'Finished' and p.gender = %(gender)s and {_TIME} is not null + order by seconds asc + limit 10 + """, + dict(params, gender=gender), + ) + for i, row in enumerate(rows, start=1): + row["place"] = i + row["time"] = _fmt_duration(row.seconds) + row["approved"] = _("Yes") if cint(row.approved) else _("No") + return rows + + +def _results(filters): + f = _f(filters) + where, params = _result_scope(f) + status = _rows(f"select t.finish_status as label, count(*) as value from `tabTiming Result` t where {where} group by t.finish_status order by value desc", params) + counts = {r.label: cint(r.value) for r in status} + finished, dnf = counts.get("Finished", 0), counts.get("Did Not Finish", 0) + awaiting = cint( + _one(f"select count(*) as n from `tabTiming Result` t where {where} and t.approved = 0 and t.finish_status in ('Finished', 'Pending Review')", params).n + ) + by_category = _rows( + f""" + select c.category_name, count(*) as finishers, + avg({_TIME}) as average_seconds, min({_TIME}) as best_seconds + from `tabTiming Result` t + join `tabRace Category` c on c.name = t.race_category + where {where} and t.finish_status = 'Finished' and {_TIME} is not null + group by c.name, c.category_name, c.distance_km + order by c.distance_km desc + """, + params, + ) + for row in by_category: + row["average"] = _fmt_duration(row.average_seconds) + row["best"] = _fmt_duration(row.best_seconds) + + prizes = _rows( + f""" + select a.approval_status as label, count(*) as value, coalesce(sum(pr.amount), 0) as amount + from `tabPrize Award` a + join `tabTiming Result` t on t.name = a.timing_result + left join `tabPrize Rule` pr on pr.name = a.prize_rule + where {where} + group by a.approval_status + """, + params, + ) + prize_count = {r.label: cint(r.value) for r in prizes} + prize_amount = {r.label: flt(r.amount) for r in prizes} + + kpis = [ + _kpi(_("Finishers"), finished), + _kpi(_("Finish rate"), _pct(finished, finished + dnf), "percent", hint=_("Finished out of started")), + _kpi(_("Results awaiting approval"), awaiting), + _kpi(_("Prizes approved"), prize_count.get("Approved", 0) + prize_count.get("Paid", 0)), + _kpi(_("Prize money paid"), prize_amount.get("Paid", 0), "currency", hint=_("{0} approved, not yet paid").format(format_amount(prize_amount.get("Approved", 0)))), + ] + + def chart(cid, title, ctype, rows, name, wide=False): + labels, datasets = _series(rows, name) + return _chart(cid, title, ctype, labels, datasets, wide=wide) + + finishers_by_cat = [{"label": r.category_name, "value": r.finishers} for r in by_category] + charts = [ + chart("status", _("Result status"), "donut", status, _("Results")), + chart("finishers", _("Finishers by category"), "bar", finishers_by_cat, _("Finishers")), + chart("prizes", _("Prize awards by status"), "bar", prizes, _("Awards")), + ] + + time_cols = [ + {"label": _("Category"), "field": "category_name", "type": "text"}, + {"label": _("Finishers"), "field": "finishers", "type": "int"}, + {"label": _("Fastest"), "field": "best", "type": "text"}, + {"label": _("Average"), "field": "average", "type": "text"}, + ] + top_cols = [ + {"label": _("#"), "field": "place", "type": "int"}, + {"label": _("Runner"), "field": "runner", "type": "text"}, + {"label": _("Bib"), "field": "bib_number", "type": "text"}, + {"label": _("Category"), "field": "category_name", "type": "text"}, + {"label": _("Time"), "field": "time", "type": "text"}, + {"label": _("Approved"), "field": "approved", "type": "text"}, + ] + tables = [ + _table("times", _("Times by category"), time_cols, by_category, empty=_("No finish times recorded yet.")), + _table("men", _("Fastest 10 men"), top_cols, _top_ten("Male", where, params), empty=_("No finish times recorded yet.")), + _table("women", _("Fastest 10 women"), top_cols, _top_ten("Female", where, params), empty=_("No finish times recorded yet.")), + ] + attention = [ + _attention( + _("results need approval"), + awaiting, + doctype="Timing Result", + filters=dict({"approved": 0}, **({"event_edition": f.event_edition} if f.event_edition else {}), **({"race_category": f.race_category} if f.race_category else {})), + ), + _attention( + _("prize awards waiting for approval"), + prize_count.get("Pending", 0), + doctype="Prize Award", + filters={"approval_status": "Pending"}, + ), + ] + return _payload(kpis, charts, tables, attention) + + +def format_amount(value): + return f"{CURRENCY} {flt(value):,.0f}" + + +# ---- tab: volunteers and impact ---------------------------------------------------------- + + +def _volunteers(filters): + f = _f(filters) + where, params = ["1 = 1"], {} + if f.event_edition: + where.append("s.event_edition = %(event_edition)s") + params["event_edition"] = f.event_edition + if f.from_date: + where.append("s.shift_date >= %(from_date)s") + params["from_date"] = getdate(f.from_date) + if f.to_date: + where.append("s.shift_date <= %(to_date)s") + params["to_date"] = getdate(f.to_date) + where = " and ".join(where) + + shifts = _one( + f""" + select count(*) as total, + coalesce(sum(s.attendance_status = 'Present'), 0) as present, + coalesce(sum(s.attendance_status = 'Absent'), 0) as absent, + coalesce(sum(case when s.attendance_status = 'Present' then + (case when s.end_time >= s.start_time + then time_to_sec(s.end_time) - time_to_sec(s.start_time) + else time_to_sec(s.end_time) - time_to_sec(s.start_time) + 86400 end) + else 0 end), 0) / 3600 as hours + from `tabVolunteer Shift` s where {where} + """, + params, + ) + by_area = _rows(f"select s.work_area as label, count(*) as value from `tabVolunteer Shift` s where {where} group by s.work_area order by value desc", params) + attendance = _rows(f"select s.attendance_status as label, count(*) as value from `tabVolunteer Shift` s where {where} group by s.attendance_status order by value desc", params) + active = cint( + _one("select count(*) as n from `tabVolunteer` where status in %(active)s", {"active": ACTIVE_VOLUNTEER}).n + ) + + tree_where, tree_params = ["1 = 1"], {} + if f.event_edition: + tree_where.append("tr.event_edition = %(event_edition)s") + tree_params["event_edition"] = f.event_edition + trees = _one( + f""" + select coalesce(sum(tr.trees_committed), 0) as committed, + coalesce(sum(tr.trees_planted), 0) as planted, + coalesce(sum(case when tr.verification_status = 'Verified' then tr.trees_planted else 0 end), 0) as verified + from `tabTree Impact Record` tr where {" and ".join(tree_where)} + """, + tree_params, + ) + exp_where, exp_params = _reg_scope({"event_edition": f.event_edition, "race_category": f.race_category}, dates=False) + expected = cint( + _one( + f""" + select coalesce(sum(e.trees_commitment_per_entry), 0) as n + from `tabRegistration` r join `tabEvent Edition` e on e.name = r.event_edition + where {exp_where} and r.registration_status in ('Confirmed', 'Completed') + """, + exp_params, + ).n + ) + + kpis = [ + _kpi(_("Active volunteers"), active, hint=_("Approved, briefed or active")), + _kpi(_("Shifts"), cint(shifts.total)), + _kpi(_("Attendance"), _pct(shifts.present, cint(shifts.present) + cint(shifts.absent)), "percent", hint=_("Present out of present and absent")), + _kpi(_("Volunteer hours"), round(flt(shifts.hours), 1), hint=_("From shifts marked present")), + _kpi(_("Trees planted"), cint(trees.planted), hint=_("{0} verified").format(cint(trees.verified))), + ] + + def chart(cid, title, ctype, rows, name): + labels, datasets = _series(rows, name) + return _chart(cid, title, ctype, labels, datasets) + + charts = [ + chart("areas", _("Shifts by work area"), "bar", by_area, _("Shifts")), + chart("attendance", _("Shift attendance"), "donut", attendance, _("Shifts")), + _chart( + "trees", + _("Tree planting progress"), + "bar", + [_("Expected from entries"), _("Committed"), _("Planted"), _("Verified")], + [{"name": _("Trees"), "values": [expected, cint(trees.committed), cint(trees.planted), cint(trees.verified)]}], + wide=True, + ), + ] + return _payload(kpis, charts, [], []) + + +# ---- endpoints --------------------------------------------------------------------------- + +TABS = { + "overview": _overview, + "registrations": _registrations, + "payments": _payments, + "race_day": _race_day, + "results": _results, + "volunteers": _volunteers, +} + + +@frappe.whitelist() +def get_filter_options(): + frappe.only_for("System Manager") + editions = _rows("select name, edition_name, event_date, status from `tabEvent Edition` order by event_date desc") + categories = _rows("select name, category_name, event_edition from `tabRace Category` where active = 1 order by distance_km desc") + default = next((e.name for e in editions if e.status == "Open"), editions[0].name if editions else None) + return {"editions": editions, "categories": categories, "default_edition": default} + + +@frappe.whitelist() +def get_analytics(tab="overview", event_edition=None, race_category=None, from_date=None, to_date=None): + frappe.only_for("System Manager") + if tab not in TABS: + frappe.throw(_("Unknown report tab: {0}").format(tab)) + filters = frappe._dict( + event_edition=event_edition or None, + race_category=race_category or None, + from_date=from_date or None, + to_date=to_date or None, + ) + data = TABS[tab](filters) + data["currency"] = CURRENCY + return data diff --git a/lukenya_marathon/lukenya_marathon/api.py b/lukenya_marathon/lukenya_marathon/api.py new file mode 100644 index 0000000..cca58a2 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/api.py @@ -0,0 +1,1186 @@ +import frappe +from frappe import _ +from frappe.utils import now_datetime, getdate, flt + +from lukenya_marathon.lukenya_marathon import registration_rules + + +def _clean(value): + return (value or "").strip() + + +def _get_price(category, price_tier, nationality): + nationality = _clean(nationality).lower() + + if price_tier == "Early Bird" and flt(category.early_bird_price) > 0: + return flt(category.early_bird_price) + + if price_tier == "Non-resident" or nationality not in ("kenya", "kenyan"): + if flt(category.non_resident_price) > 0: + return flt(category.non_resident_price) + + return flt(category.kenyan_price) + + +def _find_existing_participant(id_number, email): + if id_number: + existing = frappe.db.get_value( + "Participant", + {"id_number": id_number}, + "name", + ) + if existing: + return existing + + if email: + existing = frappe.db.get_value( + "Participant", + {"email": email}, + "name", + ) + if existing: + return existing + + return None + + +@frappe.whitelist(allow_guest=True) +def get_event_categories(event_edition=None): + filters = {"active": 1} + + if event_edition: + filters["event_edition"] = event_edition + + return frappe.get_all( + "Race Category", + filters=filters, + fields=[ + "name", + "category_code", + "category_name", + "event_edition", + "discipline", + "distance_km", + "start_time", + "cutoff_hours", + "eligibility", + "kenyan_price", + "non_resident_price", + "early_bird_price", + "capacity", + "prize_eligible", + "inclusions", + ], + order_by="category_name asc", + ) + + +@frappe.whitelist(allow_guest=True) +def create_registration(payload=None): + if isinstance(payload, str): + payload = frappe.parse_json(payload) + + if not isinstance(payload, dict): + frappe.throw(_("Registration payload must be a JSON object.")) + + required = [ + "event_edition", + "race_category", + "first_name", + "last_name", + "phone", + "indemnity_accepted", + ] + + missing = [ + field + for field in required + if payload.get(field) in (None, "", False) + ] + + if missing: + frappe.throw( + _("Missing required fields: {0}").format(", ".join(missing)) + ) + + edition = frappe.get_doc("Event Edition", payload["event_edition"]) + category = frappe.get_doc("Race Category", payload["race_category"]) + + if category.event_edition != edition.name: + frappe.throw( + _("The selected race category does not belong to this event edition.") + ) + + if not category.active: + frappe.throw(_("The selected race category is not active.")) + + if edition.status != "Open": + frappe.throw( + _("Registration is not open for this event edition.") + ) + + registration_rules.check_window(edition) + + capacity = int(category.capacity or 0) + + if capacity > 0: + active_count = frappe.db.count( + "Registration", + { + "race_category": category.name, + "registration_status": ["in", ["Pending", "Confirmed"]], + }, + ) + + if active_count >= capacity: + frappe.throw(_("This race category is full.")) + + first_name = _clean(payload.get("first_name")) + last_name = _clean(payload.get("last_name")) + id_number = _clean(payload.get("id_number")) + email = _clean(payload.get("email")).lower() + phone = _clean(payload.get("phone")) + nationality = _clean(payload.get("nationality")) or "Kenya" + price_tier = registration_rules.resolve_price_tier( + edition, category, payload.get("price_tier"), nationality + ) + + existing_participant = _find_existing_participant(id_number, email) + + if existing_participant: + participant_name = existing_participant + participant = frappe.get_doc("Participant", participant_name) + + participant.first_name = first_name + participant.last_name = last_name + participant.phone = phone + participant.whatsapp = _clean(payload.get("whatsapp")) + participant.shirt_size = _clean(payload.get("shirt_size")) + participant.emergency_contact_name = _clean( + payload.get("emergency_contact_name") + ) + participant.emergency_contact_phone = _clean( + payload.get("emergency_contact_phone") + ) + participant.communication_consent = int( + bool(payload.get("communication_consent")) + ) + participant.save(ignore_permissions=True) + else: + participant = frappe.get_doc( + { + "doctype": "Participant", + "participant_code": frappe.generate_hash(length=10).upper(), + "first_name": first_name, + "last_name": last_name, + "id_number": id_number, + "date_of_birth": payload.get("date_of_birth"), + "gender": payload.get("gender"), + "nationality": nationality, + "email": email, + "phone": phone, + "whatsapp": _clean(payload.get("whatsapp")), + "shirt_size": _clean(payload.get("shirt_size")), + "emergency_contact_name": _clean( + payload.get("emergency_contact_name") + ), + "emergency_contact_phone": _clean( + payload.get("emergency_contact_phone") + ), + "communication_consent": int( + bool(payload.get("communication_consent")) + ), + } + ) + participant.insert(ignore_permissions=True) + + duplicate = frappe.db.exists( + "Registration", + { + "participant": participant.name, + "event_edition": edition.name, + "registration_status": ["not in", ["Cancelled", "Refunded"]], + }, + ) + + if duplicate: + frappe.throw( + _("This participant already has an active registration for this edition.") + ) + + amount_due = _get_price(category, price_tier, nationality) + + registration = frappe.get_doc( + { + "doctype": "Registration", + "participant": participant.name, + "event_edition": edition.name, + "race_category": category.name, + "registration_status": "Pending", + "registration_date": now_datetime(), + "price_tier": price_tier, + "amount_due": amount_due, + "payment_status": "Unpaid", + "indemnity_accepted": 1, + "consent_timestamp": now_datetime(), + } + ) + + registration.insert(ignore_permissions=True) + + return { + "success": True, + "registration": registration.name, + "participant": participant.name, + "event_edition": edition.name, + "race_category": category.name, + "amount_due": amount_due, + "currency": "KES", + "payment_status": registration.payment_status, + "registration_status": registration.registration_status, + } + + +@frappe.whitelist() +def seed_demo_data(): + """Create development event data for the Lukenya Marathon site.""" + edition_code = "LUKENYA-2026" + + if frappe.db.exists("Event Edition", edition_code): + edition = frappe.get_doc("Event Edition", edition_code) + else: + edition = frappe.get_doc( + { + "doctype": "Event Edition", + "edition_code": edition_code, + "edition_name": "Lukenya University Marathon 2026", + "event_date": "2026-03-28", + "status": "Open", + "venue": "Lukenya University Main Campus", + "county": "Machakos County", + "description": ( + "Annual Lukenya University Marathon at Lukenya Hills." + ), + "registration_capacity": 1000, + "trees_commitment_per_entry": 1, + } + ) + edition.insert(ignore_permissions=True) + + categories = [ + { + "code": "42KM", + "name": "Full Marathon", + "discipline": "Running", + "distance": 42.2, + "start": "06:00:00", + "cutoff": 6, + "kenyan": 2500, + "non_resident": 50, + "early": 2000, + "capacity": 300, + "prize": 1, + }, + { + "code": "21KM", + "name": "Half Marathon", + "discipline": "Running", + "distance": 21.1, + "start": "06:00:00", + "cutoff": 3.5, + "kenyan": 1800, + "non_resident": 40, + "early": 1500, + "capacity": 300, + "prize": 0, + }, + { + "code": "15KM", + "name": "Community Run", + "discipline": "Running", + "distance": 15, + "start": "06:30:00", + "cutoff": 2.5, + "kenyan": 1200, + "non_resident": 30, + "early": 1000, + "capacity": 150, + "prize": 0, + }, + { + "code": "10KM", + "name": "Local Race", + "discipline": "Running", + "distance": 10, + "start": "06:30:00", + "cutoff": 2, + "kenyan": 1000, + "non_resident": 25, + "early": 800, + "capacity": 150, + "prize": 0, + }, + { + "code": "5KM", + "name": "Fun Run", + "discipline": "Running", + "distance": 5, + "start": "07:00:00", + "cutoff": 1.5, + "kenyan": 500, + "non_resident": 15, + "early": 400, + "capacity": 100, + "prize": 0, + }, + { + "code": "50KM-CYCLE", + "name": "Black Mamba Cycle", + "discipline": "Cycling", + "distance": 50, + "start": "06:00:00", + "cutoff": 6, + "kenyan": 2500, + "non_resident": 50, + "early": 2000, + "capacity": 100, + "prize": 0, + }, + { + "code": "VIRTUAL", + "name": "Virtual Run", + "discipline": "Virtual", + "distance": 0, + "start": "00:00:00", + "cutoff": 0, + "kenyan": 800, + "non_resident": 20, + "early": 600, + "capacity": 1000, + "prize": 0, + }, + ] + + created = [] + + for item in categories: + if frappe.db.exists("Race Category", item["code"]): + category = frappe.get_doc("Race Category", item["code"]) + category.category_name = item["name"] + category.event_edition = edition.name + category.discipline = item["discipline"] + category.distance_km = item["distance"] + category.start_time = item["start"] + category.cutoff_hours = item["cutoff"] + category.kenyan_price = item["kenyan"] + category.non_resident_price = item["non_resident"] + category.early_bird_price = item["early"] + category.capacity = item["capacity"] + category.active = 1 + category.prize_eligible = item["prize"] + category.inclusions = ( + "Race bib, chip timing, finisher medal, event T-shirt" + ) + category.save(ignore_permissions=True) + else: + category = frappe.get_doc( + { + "doctype": "Race Category", + "category_code": item["code"], + "category_name": item["name"], + "event_edition": edition.name, + "discipline": item["discipline"], + "distance_km": item["distance"], + "start_time": item["start"], + "cutoff_hours": item["cutoff"], + "kenyan_price": item["kenyan"], + "non_resident_price": item["non_resident"], + "early_bird_price": item["early"], + "capacity": item["capacity"], + "active": 1, + "prize_eligible": item["prize"], + "inclusions": ( + "Race bib, chip timing, finisher medal, event T-shirt" + ), + } + ) + category.insert(ignore_permissions=True) + + created.append(category.name) + + frappe.db.commit() + + return { + "success": True, + "event_edition": edition.name, + "categories": created, + } + + +@frappe.whitelist() +def setup_lukenya_workspace(): + workspace_name = "Lukenya Marathon" + + shortcut_definitions = [ + ("Event Edition", "Event Editions", "Calendar"), + ("Race Category", "Race Categories", "List"), + ("Participant", "Participants", "List"), + ("Registration", "Registrations", "List"), + ("Payment Transaction", "Payment Transactions", "List"), + ("Bib Allocation", "Bib Allocations", "List"), + ("Race Pack Issue", "Race Pack Issues", "List"), + ("Timing Result", "Timing Results", "List"), + ("Prize Rule", "Prize Rules", "List"), + ("Prize Award", "Prize Awards", "List"), + ("Tree Impact Record", "Tree Impact Records", "List"), + ("Volunteer", "Volunteers", "List"), + ("Volunteer Shift", "Volunteer Shifts", "List"), + ] + + existing = frappe.db.exists("Workspace", workspace_name) + + if existing: + workspace = frappe.get_doc("Workspace", workspace_name) + workspace.shortcuts = [] + else: + workspace = frappe.get_doc( + { + "doctype": "Workspace", + "name": workspace_name, + "title": workspace_name, + "label": workspace_name, + "module": "Lukenya Marathon", + "public": 1, + "is_hidden": 0, + } + ) + + added = [] + + for doctype, label, doc_view in shortcut_definitions: + if not frappe.db.exists("DocType", doctype): + continue + + workspace.append( + "shortcuts", + { + "type": "DocType", + "link_to": doctype, + "label": label, + "doc_view": doc_view, + "icon": "list", + }, + ) + added.append(doctype) + + if existing: + workspace.save(ignore_permissions=True) + else: + workspace.insert(ignore_permissions=True) + frappe.db.commit() + + return { + "success": True, + "workspace": workspace.name, + "shortcuts_added": added, + "shortcut_count": len(added), + } + + +@frappe.whitelist() +def repair_lukenya_workspace_content(): + import json + + workspace_name = "Lukenya Marathon" + + shortcuts = [ + ("Event Edition", "Event Editions"), + ("Race Category", "Race Categories"), + ("Participant", "Participants"), + ("Registration", "Registrations"), + ("Payment Transaction", "Payment Transactions"), + ("Bib Allocation", "Bib Allocations"), + ("Race Pack Issue", "Race Pack Issues"), + ("Timing Result", "Timing Results"), + ("Prize Rule", "Prize Rules"), + ("Prize Award", "Prize Awards"), + ("Tree Impact Record", "Tree Impact Records"), + ("Volunteer", "Volunteers"), + ("Volunteer Shift", "Volunteer Shifts"), + ] + + content = [ + { + "id": "lukenya-marathon-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, (doctype, label) in enumerate(shortcuts, start=1): + if frappe.db.exists("DocType", doctype): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": doctype, + "label": label, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "content_blocks": len(content), + "shortcuts_rendered": len(content) - 1, + } + + +@frappe.whitelist() +def repair_lukenya_workspace_shortcuts(): + import json + + workspace_name = "Lukenya Marathon" + + shortcut_rows = frappe.get_all( + "Workspace Shortcut", + filters={ + "parent": workspace_name, + "parenttype": "Workspace", + "parentfield": "shortcuts", + }, + fields=["name", "label"], + order_by="idx asc", + ) + + content = [ + { + "id": "lukenya-marathon-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, shortcut in enumerate(shortcut_rows, start=1): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": shortcut.name, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "shortcuts_found": len(shortcut_rows), + "content_blocks": len(content), + "shortcut_record_names": [row.name for row in shortcut_rows], + } + + +@frappe.whitelist() +def finalize_lukenya_workspace(): + import json + + workspace_name = "Lukenya Marathon" + + doctype_shortcuts = [ + ("Event Edition", "Event Editions"), + ("Race Category", "Race Categories"), + ("Participant", "Participants"), + ("Registration", "Registrations"), + ("Payment Transaction", "Payment Transactions"), + ("Bib Allocation", "Bib Allocations"), + ("Race Pack Issue", "Race Pack Issues"), + ("Timing Result", "Timing Results"), + ("Prize Rule", "Prize Rules"), + ("Prize Award", "Prize Awards"), + ("Tree Impact Record", "Tree Impact Records"), + ("Volunteer", "Volunteers"), + ("Volunteer Shift", "Volunteer Shifts"), + ] + + content = [ + { + "id": "lukenya-marathon-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, (doctype, label) in enumerate(doctype_shortcuts, start=1): + if frappe.db.exists("DocType", doctype): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": doctype, + "label": label, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "shortcuts_rendered": len(content) - 1, + "shortcuts": [item[0] for item in doctype_shortcuts], + } + + +@frappe.whitelist() +def set_minimal_lukenya_workspace_content(): + import json + + workspace_name = "Lukenya Marathon" + + doctypes = [ + "Event Edition", + "Race Category", + "Participant", + "Registration", + "Payment Transaction", + "Bib Allocation", + "Race Pack Issue", + "Timing Result", + "Prize Rule", + "Prize Award", + "Tree Impact Record", + "Volunteer", + "Volunteer Shift", + ] + + content = [ + { + "id": "lukenya-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, doctype in enumerate(doctypes, start=1): + if frappe.db.exists("DocType", doctype): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": doctype, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content, separators=(",", ":")), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "shortcuts": len(content) - 1, + } + + +@frappe.whitelist() +def diagnose_lukenya_workspace(): + import json + + workspace_name = "Lukenya Marathon" + report = { + "workspace": workspace_name, + "current_user": frappe.session.user, + "workspace_exists": False, + "workspace_fields": {}, + "content_parse_ok": False, + "content_blocks": [], + "shortcut_diagnostics": [], + "child_shortcuts": [], + "errors": [], + "warnings": [], + } + + if not frappe.db.exists("Workspace", workspace_name): + report["errors"].append("Workspace does not exist.") + return report + + report["workspace_exists"] = True + + workspace = frappe.get_doc("Workspace", workspace_name) + + report["workspace_fields"] = { + "name": workspace.name, + "title": workspace.title, + "label": workspace.label, + "module": workspace.module, + "public": workspace.public, + "is_hidden": workspace.is_hidden, + "hide_custom": workspace.hide_custom, + "for_user": workspace.for_user, + "restrict_to_domain": workspace.restrict_to_domain, + "content_length": len(workspace.content or ""), + } + + if workspace.is_hidden: + report["errors"].append("Workspace is marked as hidden.") + + if workspace.for_user and workspace.for_user != frappe.session.user: + report["errors"].append( + "Workspace is restricted to another user: " + + str(workspace.for_user) + ) + + try: + content = json.loads(workspace.content or "[]") + report["content_parse_ok"] = True + except Exception as exc: + report["errors"].append( + "Workspace content is invalid JSON: " + repr(exc) + ) + return report + + if not isinstance(content, list): + report["errors"].append("Workspace content is not a JSON list.") + return report + + report["content_blocks"] = content + + child_shortcuts = frappe.get_all( + "Workspace Shortcut", + filters={ + "parent": workspace_name, + "parenttype": "Workspace", + "parentfield": "shortcuts", + }, + fields=[ + "name", + "idx", + "type", + "link_to", + "label", + "doc_view", + ], + order_by="idx asc", + ) + + report["child_shortcuts"] = child_shortcuts + + child_by_link = { + row.link_to: row + for row in child_shortcuts + if row.link_to + } + + for index, block in enumerate(content, start=1): + if not isinstance(block, dict): + report["errors"].append( + f"Content block {index} is not an object." + ) + continue + + block_type = block.get("type") + data = block.get("data") or {} + + if block_type != "shortcut": + continue + + shortcut_name = data.get("shortcut_name") + + diagnostic = { + "content_index": index, + "shortcut_name": shortcut_name, + "doctype_exists": False, + "child_shortcut_exists": False, + "read_permission": False, + "doctype_meta": {}, + "errors": [], + "warnings": [], + } + + if not shortcut_name: + diagnostic["errors"].append( + "Shortcut has no data.shortcut_name value." + ) + report["shortcut_diagnostics"].append(diagnostic) + continue + + doctype_exists = frappe.db.exists("DocType", shortcut_name) + diagnostic["doctype_exists"] = bool(doctype_exists) + + if not doctype_exists: + diagnostic["errors"].append( + "No DocType exists with this exact name." + ) + else: + try: + meta = frappe.get_meta(shortcut_name) + + diagnostic["doctype_meta"] = { + "name": meta.name, + "module": meta.module, + "istable": meta.istable, + "issingle": meta.issingle, + "is_virtual": getattr(meta, "is_virtual", False), + "read_only": getattr(meta, "read_only", False), + } + + diagnostic["read_permission"] = bool( + frappe.has_permission( + shortcut_name, + ptype="read", + user=frappe.session.user, + ) + ) + + if not diagnostic["read_permission"]: + diagnostic["errors"].append( + "Current user has no read permission." + ) + + if meta.istable: + diagnostic["errors"].append( + "Target DocType is a child table, not a desk list DocType." + ) + + if meta.issingle: + diagnostic["warnings"].append( + "Target DocType is single; it may not render as a list shortcut." + ) + + except Exception as exc: + diagnostic["errors"].append( + "Could not inspect DocType metadata: " + repr(exc) + ) + + if shortcut_name not in child_by_link: + diagnostic["warnings"].append( + "No Workspace Shortcut child row has link_to equal to this target." + ) + else: + diagnostic["child_shortcut_exists"] = True + + report["shortcut_diagnostics"].append(diagnostic) + + if not report["shortcut_diagnostics"]: + report["errors"].append( + "Workspace content contains no shortcut blocks." + ) + + if report["content_parse_ok"] and not report["errors"]: + report["warnings"].append( + "Backend validation found no shortcut data errors. " + "If shortcuts remain invisible, inspect the browser Network response " + "for get_desktop_page and the loaded frontend asset version." + ) + + return report + + +@frappe.whitelist() +def setup_lukenya_card_dashboard(): + import json + + workspace_name = "Lukenya Marathon" + + cards = [ + { + "label": "Event Setup", + "links": [ + ("Event Editions", "Event Edition"), + ("Race Categories", "Race Category"), + ], + }, + { + "label": "Registration Management", + "links": [ + ("Participants", "Participant"), + ("Registrations", "Registration"), + ("Payment Transactions", "Payment Transaction"), + ], + }, + { + "label": "Race-Day Operations", + "links": [ + ("Bib Allocations", "Bib Allocation"), + ("Race Pack Issues", "Race Pack Issue"), + ("Volunteer Shifts", "Volunteer Shift"), + ], + }, + { + "label": "Results and Awards", + "links": [ + ("Timing Results", "Timing Result"), + ("Prize Rules", "Prize Rule"), + ("Prize Awards", "Prize Award"), + ], + }, + { + "label": "People and Impact", + "links": [ + ("Volunteers", "Volunteer"), + ("Tree Impact Records", "Tree Impact Record"), + ], + }, + ] + + workspace = frappe.get_doc("Workspace", workspace_name) + + # Remove old link rows and rebuild only this workspace's links. + workspace.set("links", []) + + workspace_content = [ + { + "id": "lukenya-dashboard-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + created_links = [] + + for card_index, card in enumerate(cards, start=1): + workspace.append( + "links", + { + "type": "Card Break", + "label": card["label"], + "hidden": 0, + }, + ) + + workspace_content.append( + { + "id": f"lukenya-card-{card_index}", + "type": "card", + "data": { + "card_name": card["label"], + "col": 4, + }, + } + ) + + for label, doctype in card["links"]: + if not frappe.db.exists("DocType", doctype): + continue + + workspace.append( + "links", + { + "type": "Link", + "label": label, + "link_type": "DocType", + "link_to": doctype, + "hidden": 0, + "dependencies": "", + }, + ) + + created_links.append(doctype) + + # Remove shortcut blocks. Cards and links will be the dashboard interface. + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(workspace_content, separators=(",", ":")), + update_modified=False, + ) + + workspace.save(ignore_permissions=True) + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "cards_created": len(cards), + "links_created": len(created_links), + "links": created_links, + } + + +@frappe.whitelist() +def finalize_lukenya_card_dashboard(): + import json + + workspace_name = "Lukenya Marathon" + + cards = [ + { + "label": "Event Setup", + "links": [ + ("Event Editions", "Event Edition"), + ("Race Categories", "Race Category"), + ], + }, + { + "label": "Registration Management", + "links": [ + ("Participants", "Participant"), + ("Registrations", "Registration"), + ("Payment Transactions", "Payment Transaction"), + ], + }, + { + "label": "Race-Day Operations", + "links": [ + ("Bib Allocations", "Bib Allocation"), + ("Race Pack Issues", "Race Pack Issue"), + ("Volunteer Shifts", "Volunteer Shift"), + ], + }, + { + "label": "Results and Awards", + "links": [ + ("Timing Results", "Timing Result"), + ("Prize Rules", "Prize Rule"), + ("Prize Awards", "Prize Award"), + ], + }, + { + "label": "People and Impact", + "links": [ + ("Volunteers", "Volunteer"), + ("Tree Impact Records", "Tree Impact Record"), + ], + }, + ] + + workspace = frappe.get_doc("Workspace", workspace_name) + workspace.set("shortcuts", []) + workspace.set("links", []) + + created_links = [] + + for card in cards: + workspace.append( + "links", + { + "type": "Card Break", + "label": card["label"], + "hidden": 0, + }, + ) + + for label, doctype in card["links"]: + if not frappe.db.exists("DocType", doctype): + continue + + workspace.append( + "links", + { + "type": "Link", + "label": label, + "link_type": "DocType", + "link_to": doctype, + "hidden": 0, + "dependencies": "", + }, + ) + created_links.append(doctype) + + # Save the Workspace Links first. + workspace.save(ignore_permissions=True) + + # Frappe 15 renders Card Break groups from content. + content = [ + { + "id": "lukenya-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, card in enumerate(cards, start=1): + content.append( + { + "id": f"lukenya-card-{index}", + "type": "card", + "data": { + "card_name": card["label"], + "col": 4, + }, + } + ) + + # Write content after save so workspace.save cannot overwrite it. + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content, separators=(",", ":")), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "cards_created": len(cards), + "links_created": len(created_links), + "links": created_links, + "content": content, + } diff --git a/lukenya_marathon/lukenya_marathon/api.py.bak b/lukenya_marathon/lukenya_marathon/api.py.bak new file mode 100644 index 0000000..abb9f52 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/api.py.bak @@ -0,0 +1,1180 @@ +import frappe +from frappe import _ +from frappe.utils import now_datetime, getdate, flt + + +def _clean(value): + return (value or "").strip() + + +def _get_price(category, price_tier, nationality): + nationality = _clean(nationality).lower() + + if price_tier == "Early Bird" and flt(category.early_bird_price) > 0: + return flt(category.early_bird_price) + + if price_tier == "Non-resident" or nationality not in ("kenya", "kenyan"): + if flt(category.non_resident_price) > 0: + return flt(category.non_resident_price) + + return flt(category.kenyan_price) + + +def _find_existing_participant(id_number, email): + if id_number: + existing = frappe.db.get_value( + "Participant", + {"id_number": id_number}, + "name", + ) + if existing: + return existing + + if email: + existing = frappe.db.get_value( + "Participant", + {"email": email}, + "name", + ) + if existing: + return existing + + return None + + +@frappe.whitelist(allow_guest=True) +def get_event_categories(event_edition=None): + filters = {"active": 1} + + if event_edition: + filters["event_edition"] = event_edition + + return frappe.get_all( + "Race Category", + filters=filters, + fields=[ + "name", + "category_code", + "category_name", + "event_edition", + "discipline", + "distance_km", + "start_time", + "cutoff_hours", + "eligibility", + "kenyan_price", + "non_resident_price", + "early_bird_price", + "capacity", + "prize_eligible", + "inclusions", + ], + order_by="category_name asc", + ) + + +@frappe.whitelist(allow_guest=True) +def create_registration(payload=None): + if isinstance(payload, str): + payload = frappe.parse_json(payload) + + if not isinstance(payload, dict): + frappe.throw(_("Registration payload must be a JSON object.")) + + required = [ + "event_edition", + "race_category", + "first_name", + "last_name", + "phone", + "indemnity_accepted", + ] + + missing = [ + field + for field in required + if payload.get(field) in (None, "", False) + ] + + if missing: + frappe.throw( + _("Missing required fields: {0}").format(", ".join(missing)) + ) + + edition = frappe.get_doc("Event Edition", payload["event_edition"]) + category = frappe.get_doc("Race Category", payload["race_category"]) + + if category.event_edition != edition.name: + frappe.throw( + _("The selected race category does not belong to this event edition.") + ) + + if not category.active: + frappe.throw(_("The selected race category is not active.")) + + if edition.status != "Open": + frappe.throw( + _("Registration is not open for this event edition.") + ) + + capacity = int(category.capacity or 0) + + if capacity > 0: + active_count = frappe.db.count( + "Registration", + { + "race_category": category.name, + "registration_status": ["in", ["Pending", "Confirmed"]], + }, + ) + + if active_count >= capacity: + frappe.throw(_("This race category is full.")) + + first_name = _clean(payload.get("first_name")) + last_name = _clean(payload.get("last_name")) + id_number = _clean(payload.get("id_number")) + email = _clean(payload.get("email")).lower() + phone = _clean(payload.get("phone")) + nationality = _clean(payload.get("nationality")) or "Kenya" + price_tier = _clean(payload.get("price_tier")) or "Standard" + + existing_participant = _find_existing_participant(id_number, email) + + if existing_participant: + participant_name = existing_participant + participant = frappe.get_doc("Participant", participant_name) + + participant.first_name = first_name + participant.last_name = last_name + participant.phone = phone + participant.whatsapp = _clean(payload.get("whatsapp")) + participant.shirt_size = _clean(payload.get("shirt_size")) + participant.emergency_contact_name = _clean( + payload.get("emergency_contact_name") + ) + participant.emergency_contact_phone = _clean( + payload.get("emergency_contact_phone") + ) + participant.communication_consent = int( + bool(payload.get("communication_consent")) + ) + participant.save(ignore_permissions=True) + else: + participant = frappe.get_doc( + { + "doctype": "Participant", + "participant_code": frappe.generate_hash(length=10).upper(), + "first_name": first_name, + "last_name": last_name, + "id_number": id_number, + "date_of_birth": payload.get("date_of_birth"), + "gender": payload.get("gender"), + "nationality": nationality, + "email": email, + "phone": phone, + "whatsapp": _clean(payload.get("whatsapp")), + "shirt_size": _clean(payload.get("shirt_size")), + "emergency_contact_name": _clean( + payload.get("emergency_contact_name") + ), + "emergency_contact_phone": _clean( + payload.get("emergency_contact_phone") + ), + "communication_consent": int( + bool(payload.get("communication_consent")) + ), + } + ) + participant.insert(ignore_permissions=True) + + duplicate = frappe.db.exists( + "Registration", + { + "participant": participant.name, + "event_edition": edition.name, + "registration_status": ["not in", ["Cancelled", "Refunded"]], + }, + ) + + if duplicate: + frappe.throw( + _("This participant already has an active registration for this edition.") + ) + + amount_due = _get_price(category, price_tier, nationality) + + registration = frappe.get_doc( + { + "doctype": "Registration", + "participant": participant.name, + "event_edition": edition.name, + "race_category": category.name, + "registration_status": "Pending", + "registration_date": now_datetime(), + "price_tier": price_tier, + "amount_due": amount_due, + "payment_status": "Unpaid", + "indemnity_accepted": 1, + "consent_timestamp": now_datetime(), + } + ) + + registration.insert(ignore_permissions=True) + + return { + "success": True, + "registration": registration.name, + "participant": participant.name, + "event_edition": edition.name, + "race_category": category.name, + "amount_due": amount_due, + "currency": "KES", + "payment_status": registration.payment_status, + "registration_status": registration.registration_status, + } + + +@frappe.whitelist() +def seed_demo_data(): + """Create development event data for the Lukenya Marathon site.""" + edition_code = "LUKENYA-2026" + + if frappe.db.exists("Event Edition", edition_code): + edition = frappe.get_doc("Event Edition", edition_code) + else: + edition = frappe.get_doc( + { + "doctype": "Event Edition", + "edition_code": edition_code, + "edition_name": "Lukenya University Marathon 2026", + "event_date": "2026-03-28", + "status": "Open", + "venue": "Lukenya University Main Campus", + "county": "Machakos County", + "description": ( + "Annual Lukenya University Marathon at Lukenya Hills." + ), + "registration_capacity": 1000, + "trees_commitment_per_entry": 1, + } + ) + edition.insert(ignore_permissions=True) + + categories = [ + { + "code": "42KM", + "name": "Full Marathon", + "discipline": "Running", + "distance": 42.2, + "start": "06:00:00", + "cutoff": 6, + "kenyan": 2500, + "non_resident": 50, + "early": 2000, + "capacity": 300, + "prize": 1, + }, + { + "code": "21KM", + "name": "Half Marathon", + "discipline": "Running", + "distance": 21.1, + "start": "06:00:00", + "cutoff": 3.5, + "kenyan": 1800, + "non_resident": 40, + "early": 1500, + "capacity": 300, + "prize": 0, + }, + { + "code": "15KM", + "name": "Community Run", + "discipline": "Running", + "distance": 15, + "start": "06:30:00", + "cutoff": 2.5, + "kenyan": 1200, + "non_resident": 30, + "early": 1000, + "capacity": 150, + "prize": 0, + }, + { + "code": "10KM", + "name": "Local Race", + "discipline": "Running", + "distance": 10, + "start": "06:30:00", + "cutoff": 2, + "kenyan": 1000, + "non_resident": 25, + "early": 800, + "capacity": 150, + "prize": 0, + }, + { + "code": "5KM", + "name": "Fun Run", + "discipline": "Running", + "distance": 5, + "start": "07:00:00", + "cutoff": 1.5, + "kenyan": 500, + "non_resident": 15, + "early": 400, + "capacity": 100, + "prize": 0, + }, + { + "code": "50KM-CYCLE", + "name": "Black Mamba Cycle", + "discipline": "Cycling", + "distance": 50, + "start": "06:00:00", + "cutoff": 6, + "kenyan": 2500, + "non_resident": 50, + "early": 2000, + "capacity": 100, + "prize": 0, + }, + { + "code": "VIRTUAL", + "name": "Virtual Run", + "discipline": "Virtual", + "distance": 0, + "start": "00:00:00", + "cutoff": 0, + "kenyan": 800, + "non_resident": 20, + "early": 600, + "capacity": 1000, + "prize": 0, + }, + ] + + created = [] + + for item in categories: + if frappe.db.exists("Race Category", item["code"]): + category = frappe.get_doc("Race Category", item["code"]) + category.category_name = item["name"] + category.event_edition = edition.name + category.discipline = item["discipline"] + category.distance_km = item["distance"] + category.start_time = item["start"] + category.cutoff_hours = item["cutoff"] + category.kenyan_price = item["kenyan"] + category.non_resident_price = item["non_resident"] + category.early_bird_price = item["early"] + category.capacity = item["capacity"] + category.active = 1 + category.prize_eligible = item["prize"] + category.inclusions = ( + "Race bib, chip timing, finisher medal, event T-shirt" + ) + category.save(ignore_permissions=True) + else: + category = frappe.get_doc( + { + "doctype": "Race Category", + "category_code": item["code"], + "category_name": item["name"], + "event_edition": edition.name, + "discipline": item["discipline"], + "distance_km": item["distance"], + "start_time": item["start"], + "cutoff_hours": item["cutoff"], + "kenyan_price": item["kenyan"], + "non_resident_price": item["non_resident"], + "early_bird_price": item["early"], + "capacity": item["capacity"], + "active": 1, + "prize_eligible": item["prize"], + "inclusions": ( + "Race bib, chip timing, finisher medal, event T-shirt" + ), + } + ) + category.insert(ignore_permissions=True) + + created.append(category.name) + + frappe.db.commit() + + return { + "success": True, + "event_edition": edition.name, + "categories": created, + } + + +@frappe.whitelist() +def setup_lukenya_workspace(): + workspace_name = "Lukenya Marathon" + + shortcut_definitions = [ + ("Event Edition", "Event Editions", "Calendar"), + ("Race Category", "Race Categories", "List"), + ("Participant", "Participants", "List"), + ("Registration", "Registrations", "List"), + ("Payment Transaction", "Payment Transactions", "List"), + ("Bib Allocation", "Bib Allocations", "List"), + ("Race Pack Issue", "Race Pack Issues", "List"), + ("Timing Result", "Timing Results", "List"), + ("Prize Rule", "Prize Rules", "List"), + ("Prize Award", "Prize Awards", "List"), + ("Tree Impact Record", "Tree Impact Records", "List"), + ("Volunteer", "Volunteers", "List"), + ("Volunteer Shift", "Volunteer Shifts", "List"), + ] + + existing = frappe.db.exists("Workspace", workspace_name) + + if existing: + workspace = frappe.get_doc("Workspace", workspace_name) + workspace.shortcuts = [] + else: + workspace = frappe.get_doc( + { + "doctype": "Workspace", + "name": workspace_name, + "title": workspace_name, + "label": workspace_name, + "module": "Lukenya Marathon", + "public": 1, + "is_hidden": 0, + } + ) + + added = [] + + for doctype, label, doc_view in shortcut_definitions: + if not frappe.db.exists("DocType", doctype): + continue + + workspace.append( + "shortcuts", + { + "type": "DocType", + "link_to": doctype, + "label": label, + "doc_view": doc_view, + "icon": "list", + }, + ) + added.append(doctype) + + if existing: + workspace.save(ignore_permissions=True) + else: + workspace.insert(ignore_permissions=True) + frappe.db.commit() + + return { + "success": True, + "workspace": workspace.name, + "shortcuts_added": added, + "shortcut_count": len(added), + } + + +@frappe.whitelist() +def repair_lukenya_workspace_content(): + import json + + workspace_name = "Lukenya Marathon" + + shortcuts = [ + ("Event Edition", "Event Editions"), + ("Race Category", "Race Categories"), + ("Participant", "Participants"), + ("Registration", "Registrations"), + ("Payment Transaction", "Payment Transactions"), + ("Bib Allocation", "Bib Allocations"), + ("Race Pack Issue", "Race Pack Issues"), + ("Timing Result", "Timing Results"), + ("Prize Rule", "Prize Rules"), + ("Prize Award", "Prize Awards"), + ("Tree Impact Record", "Tree Impact Records"), + ("Volunteer", "Volunteers"), + ("Volunteer Shift", "Volunteer Shifts"), + ] + + content = [ + { + "id": "lukenya-marathon-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, (doctype, label) in enumerate(shortcuts, start=1): + if frappe.db.exists("DocType", doctype): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": doctype, + "label": label, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "content_blocks": len(content), + "shortcuts_rendered": len(content) - 1, + } + + +@frappe.whitelist() +def repair_lukenya_workspace_shortcuts(): + import json + + workspace_name = "Lukenya Marathon" + + shortcut_rows = frappe.get_all( + "Workspace Shortcut", + filters={ + "parent": workspace_name, + "parenttype": "Workspace", + "parentfield": "shortcuts", + }, + fields=["name", "label"], + order_by="idx asc", + ) + + content = [ + { + "id": "lukenya-marathon-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, shortcut in enumerate(shortcut_rows, start=1): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": shortcut.name, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "shortcuts_found": len(shortcut_rows), + "content_blocks": len(content), + "shortcut_record_names": [row.name for row in shortcut_rows], + } + + +@frappe.whitelist() +def finalize_lukenya_workspace(): + import json + + workspace_name = "Lukenya Marathon" + + doctype_shortcuts = [ + ("Event Edition", "Event Editions"), + ("Race Category", "Race Categories"), + ("Participant", "Participants"), + ("Registration", "Registrations"), + ("Payment Transaction", "Payment Transactions"), + ("Bib Allocation", "Bib Allocations"), + ("Race Pack Issue", "Race Pack Issues"), + ("Timing Result", "Timing Results"), + ("Prize Rule", "Prize Rules"), + ("Prize Award", "Prize Awards"), + ("Tree Impact Record", "Tree Impact Records"), + ("Volunteer", "Volunteers"), + ("Volunteer Shift", "Volunteer Shifts"), + ] + + content = [ + { + "id": "lukenya-marathon-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, (doctype, label) in enumerate(doctype_shortcuts, start=1): + if frappe.db.exists("DocType", doctype): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": doctype, + "label": label, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "shortcuts_rendered": len(content) - 1, + "shortcuts": [item[0] for item in doctype_shortcuts], + } + + +@frappe.whitelist() +def set_minimal_lukenya_workspace_content(): + import json + + workspace_name = "Lukenya Marathon" + + doctypes = [ + "Event Edition", + "Race Category", + "Participant", + "Registration", + "Payment Transaction", + "Bib Allocation", + "Race Pack Issue", + "Timing Result", + "Prize Rule", + "Prize Award", + "Tree Impact Record", + "Volunteer", + "Volunteer Shift", + ] + + content = [ + { + "id": "lukenya-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, doctype in enumerate(doctypes, start=1): + if frappe.db.exists("DocType", doctype): + content.append( + { + "id": f"lukenya-shortcut-{index}", + "type": "shortcut", + "data": { + "shortcut_name": doctype, + "col": 3, + }, + } + ) + + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content, separators=(",", ":")), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "shortcuts": len(content) - 1, + } + + +@frappe.whitelist() +def diagnose_lukenya_workspace(): + import json + + workspace_name = "Lukenya Marathon" + report = { + "workspace": workspace_name, + "current_user": frappe.session.user, + "workspace_exists": False, + "workspace_fields": {}, + "content_parse_ok": False, + "content_blocks": [], + "shortcut_diagnostics": [], + "child_shortcuts": [], + "errors": [], + "warnings": [], + } + + if not frappe.db.exists("Workspace", workspace_name): + report["errors"].append("Workspace does not exist.") + return report + + report["workspace_exists"] = True + + workspace = frappe.get_doc("Workspace", workspace_name) + + report["workspace_fields"] = { + "name": workspace.name, + "title": workspace.title, + "label": workspace.label, + "module": workspace.module, + "public": workspace.public, + "is_hidden": workspace.is_hidden, + "hide_custom": workspace.hide_custom, + "for_user": workspace.for_user, + "restrict_to_domain": workspace.restrict_to_domain, + "content_length": len(workspace.content or ""), + } + + if workspace.is_hidden: + report["errors"].append("Workspace is marked as hidden.") + + if workspace.for_user and workspace.for_user != frappe.session.user: + report["errors"].append( + "Workspace is restricted to another user: " + + str(workspace.for_user) + ) + + try: + content = json.loads(workspace.content or "[]") + report["content_parse_ok"] = True + except Exception as exc: + report["errors"].append( + "Workspace content is invalid JSON: " + repr(exc) + ) + return report + + if not isinstance(content, list): + report["errors"].append("Workspace content is not a JSON list.") + return report + + report["content_blocks"] = content + + child_shortcuts = frappe.get_all( + "Workspace Shortcut", + filters={ + "parent": workspace_name, + "parenttype": "Workspace", + "parentfield": "shortcuts", + }, + fields=[ + "name", + "idx", + "type", + "link_to", + "label", + "doc_view", + ], + order_by="idx asc", + ) + + report["child_shortcuts"] = child_shortcuts + + child_by_link = { + row.link_to: row + for row in child_shortcuts + if row.link_to + } + + for index, block in enumerate(content, start=1): + if not isinstance(block, dict): + report["errors"].append( + f"Content block {index} is not an object." + ) + continue + + block_type = block.get("type") + data = block.get("data") or {} + + if block_type != "shortcut": + continue + + shortcut_name = data.get("shortcut_name") + + diagnostic = { + "content_index": index, + "shortcut_name": shortcut_name, + "doctype_exists": False, + "child_shortcut_exists": False, + "read_permission": False, + "doctype_meta": {}, + "errors": [], + "warnings": [], + } + + if not shortcut_name: + diagnostic["errors"].append( + "Shortcut has no data.shortcut_name value." + ) + report["shortcut_diagnostics"].append(diagnostic) + continue + + doctype_exists = frappe.db.exists("DocType", shortcut_name) + diagnostic["doctype_exists"] = bool(doctype_exists) + + if not doctype_exists: + diagnostic["errors"].append( + "No DocType exists with this exact name." + ) + else: + try: + meta = frappe.get_meta(shortcut_name) + + diagnostic["doctype_meta"] = { + "name": meta.name, + "module": meta.module, + "istable": meta.istable, + "issingle": meta.issingle, + "is_virtual": getattr(meta, "is_virtual", False), + "read_only": getattr(meta, "read_only", False), + } + + diagnostic["read_permission"] = bool( + frappe.has_permission( + shortcut_name, + ptype="read", + user=frappe.session.user, + ) + ) + + if not diagnostic["read_permission"]: + diagnostic["errors"].append( + "Current user has no read permission." + ) + + if meta.istable: + diagnostic["errors"].append( + "Target DocType is a child table, not a desk list DocType." + ) + + if meta.issingle: + diagnostic["warnings"].append( + "Target DocType is single; it may not render as a list shortcut." + ) + + except Exception as exc: + diagnostic["errors"].append( + "Could not inspect DocType metadata: " + repr(exc) + ) + + if shortcut_name not in child_by_link: + diagnostic["warnings"].append( + "No Workspace Shortcut child row has link_to equal to this target." + ) + else: + diagnostic["child_shortcut_exists"] = True + + report["shortcut_diagnostics"].append(diagnostic) + + if not report["shortcut_diagnostics"]: + report["errors"].append( + "Workspace content contains no shortcut blocks." + ) + + if report["content_parse_ok"] and not report["errors"]: + report["warnings"].append( + "Backend validation found no shortcut data errors. " + "If shortcuts remain invisible, inspect the browser Network response " + "for get_desktop_page and the loaded frontend asset version." + ) + + return report + + +@frappe.whitelist() +def setup_lukenya_card_dashboard(): + import json + + workspace_name = "Lukenya Marathon" + + cards = [ + { + "label": "Event Setup", + "links": [ + ("Event Editions", "Event Edition"), + ("Race Categories", "Race Category"), + ], + }, + { + "label": "Registration Management", + "links": [ + ("Participants", "Participant"), + ("Registrations", "Registration"), + ("Payment Transactions", "Payment Transaction"), + ], + }, + { + "label": "Race-Day Operations", + "links": [ + ("Bib Allocations", "Bib Allocation"), + ("Race Pack Issues", "Race Pack Issue"), + ("Volunteer Shifts", "Volunteer Shift"), + ], + }, + { + "label": "Results and Awards", + "links": [ + ("Timing Results", "Timing Result"), + ("Prize Rules", "Prize Rule"), + ("Prize Awards", "Prize Award"), + ], + }, + { + "label": "People and Impact", + "links": [ + ("Volunteers", "Volunteer"), + ("Tree Impact Records", "Tree Impact Record"), + ], + }, + ] + + workspace = frappe.get_doc("Workspace", workspace_name) + + # Remove old link rows and rebuild only this workspace's links. + workspace.set("links", []) + + workspace_content = [ + { + "id": "lukenya-dashboard-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + created_links = [] + + for card_index, card in enumerate(cards, start=1): + workspace.append( + "links", + { + "type": "Card Break", + "label": card["label"], + "hidden": 0, + }, + ) + + workspace_content.append( + { + "id": f"lukenya-card-{card_index}", + "type": "card", + "data": { + "card_name": card["label"], + "col": 4, + }, + } + ) + + for label, doctype in card["links"]: + if not frappe.db.exists("DocType", doctype): + continue + + workspace.append( + "links", + { + "type": "Link", + "label": label, + "link_type": "DocType", + "link_to": doctype, + "hidden": 0, + "dependencies": "", + }, + ) + + created_links.append(doctype) + + # Remove shortcut blocks. Cards and links will be the dashboard interface. + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(workspace_content, separators=(",", ":")), + update_modified=False, + ) + + workspace.save(ignore_permissions=True) + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "cards_created": len(cards), + "links_created": len(created_links), + "links": created_links, + } + + +@frappe.whitelist() +def finalize_lukenya_card_dashboard(): + import json + + workspace_name = "Lukenya Marathon" + + cards = [ + { + "label": "Event Setup", + "links": [ + ("Event Editions", "Event Edition"), + ("Race Categories", "Race Category"), + ], + }, + { + "label": "Registration Management", + "links": [ + ("Participants", "Participant"), + ("Registrations", "Registration"), + ("Payment Transactions", "Payment Transaction"), + ], + }, + { + "label": "Race-Day Operations", + "links": [ + ("Bib Allocations", "Bib Allocation"), + ("Race Pack Issues", "Race Pack Issue"), + ("Volunteer Shifts", "Volunteer Shift"), + ], + }, + { + "label": "Results and Awards", + "links": [ + ("Timing Results", "Timing Result"), + ("Prize Rules", "Prize Rule"), + ("Prize Awards", "Prize Award"), + ], + }, + { + "label": "People and Impact", + "links": [ + ("Volunteers", "Volunteer"), + ("Tree Impact Records", "Tree Impact Record"), + ], + }, + ] + + workspace = frappe.get_doc("Workspace", workspace_name) + workspace.set("shortcuts", []) + workspace.set("links", []) + + created_links = [] + + for card in cards: + workspace.append( + "links", + { + "type": "Card Break", + "label": card["label"], + "hidden": 0, + }, + ) + + for label, doctype in card["links"]: + if not frappe.db.exists("DocType", doctype): + continue + + workspace.append( + "links", + { + "type": "Link", + "label": label, + "link_type": "DocType", + "link_to": doctype, + "hidden": 0, + "dependencies": "", + }, + ) + created_links.append(doctype) + + # Save the Workspace Links first. + workspace.save(ignore_permissions=True) + + # Frappe 15 renders Card Break groups from content. + content = [ + { + "id": "lukenya-header", + "type": "header", + "data": { + "text": "Marathon Operations", + "col": 12, + }, + } + ] + + for index, card in enumerate(cards, start=1): + content.append( + { + "id": f"lukenya-card-{index}", + "type": "card", + "data": { + "card_name": card["label"], + "col": 4, + }, + } + ) + + # Write content after save so workspace.save cannot overwrite it. + frappe.db.set_value( + "Workspace", + workspace_name, + "content", + json.dumps(content, separators=(",", ":")), + update_modified=True, + ) + + frappe.db.commit() + + return { + "success": True, + "workspace": workspace_name, + "cards_created": len(cards), + "links_created": len(created_links), + "links": created_links, + "content": content, + } diff --git a/lukenya_marathon/lukenya_marathon/bibs.py b/lukenya_marathon/lukenya_marathon/bibs.py new file mode 100644 index 0000000..86440fb --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/bibs.py @@ -0,0 +1,35 @@ +"""Bib operations that need more than one record to change together.""" + +import frappe +from frappe import _ + +from lukenya_marathon.lukenya_marathon.doctype.bib_allocation.bib_allocation import LIVE + +ROLES = ("System Manager", "Race Director", "Registration Desk") + + +@frappe.whitelist() +def replace_bib(registration, new_bib, chip_number=None, reason=None): + """Retire an entry's current bib (kept as Replaced, so the number isn't reused) and issue a new one.""" + frappe.only_for(ROLES) + new_bib = (new_bib or "").strip().upper() + current = frappe.db.get_value( + "Bib Allocation", {"registration": registration, "status": ["in", list(LIVE)]}, ["name", "bib_number"], as_dict=True + ) + if not current: + frappe.throw(_("This entry has no bib to replace.")) + if new_bib == current.bib_number: + frappe.throw(_("The new bib is the same as the current one.")) + + note = _("Replaced by {0}.").format(new_bib) + (" " + reason if reason else "") + frappe.db.set_value("Bib Allocation", current.name, {"status": "Replaced", "notes": note}) + doc = frappe.get_doc({ + "doctype": "Bib Allocation", + "registration": registration, + "bib_number": new_bib, + "chip_number": chip_number or "", + "status": "Allocated", + "notes": _("Replaces {0}.").format(current.bib_number) + (" " + reason if reason else ""), + }) + doc.insert() + return {"bib": new_bib, "replaced": current.bib_number} diff --git a/lukenya_marathon/lukenya_marathon/dashboard.py b/lukenya_marathon/lukenya_marathon/dashboard.py new file mode 100644 index 0000000..41cb796 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/dashboard.py @@ -0,0 +1,106 @@ +"""Server-side data for the Lukenya Marathon dashboard page.""" + +import frappe +from frappe.utils import add_days, flt, getdate, nowdate + +CURRENCY = "KES" +PAID_STATUSES = ("Paid", "Reconciled") + + +def _count(doctype, filters=None): + if not frappe.db.exists("DocType", doctype): + return 0 + return frappe.db.count(doctype, filters or {}) + + +def _created_between(doctype, start, end): + return _count(doctype, {"creation": ["between", [start, end]]}) + + +def _revenue(start=None, end=None): + """Sum of paid/reconciled payments, optionally limited to a date window.""" + if not frappe.db.exists("DocType", "Payment Transaction"): + return 0.0 + + values = {"paid": PAID_STATUSES} + window = "" + if start and end: + window = "and date(coalesce(payment_date, creation)) between %(start)s and %(end)s" + values.update(start=start, end=end) + + result = frappe.db.sql( + f""" + select coalesce(sum(amount), 0) + from `tabPayment Transaction` + where status in %(paid)s {window} + """, + values, + ) + return flt(result[0][0]) + + +def _trend(current, previous): + """Percent change against the previous period, or None if there is nothing to compare.""" + if not previous: + return None + return round((flt(current) - flt(previous)) / flt(previous) * 100) + + +def _current_edition(): + fields = ["name", "edition_name", "event_date", "status"] + edition = frappe.db.get_value( + "Event Edition", {"status": "Open"}, fields, as_dict=True, order_by="event_date desc" + ) + return edition or frappe.db.get_value( + "Event Edition", {}, fields, as_dict=True, order_by="event_date desc" + ) + + +@frappe.whitelist() +def get_dashboard_stats(): + frappe.only_for("System Manager") + + today = getdate(nowdate()) + recent_start, recent_end = add_days(today, -29), today + prior_start, prior_end = add_days(today, -59), add_days(today, -30) + + participants_recent = _created_between("Participant", recent_start, recent_end) + participants_prior = _created_between("Participant", prior_start, prior_end) + + editions_recent = _created_between("Event Edition", recent_start, recent_end) + editions_prior = _created_between("Event Edition", prior_start, prior_end) + + revenue_recent = _revenue(recent_start, recent_end) + revenue_prior = _revenue(prior_start, prior_end) + + return { + "currency": CURRENCY, + "edition": _current_edition(), + "kpis": { + "participants": { + "value": _count("Participant"), + "trend": _trend(participants_recent, participants_prior), + }, + "editions": { + "value": _count("Event Edition"), + "trend": _trend(editions_recent, editions_prior), + }, + "revenue": { + "value": _revenue(), + "trend": _trend(revenue_recent, revenue_prior), + }, + "categories": { + "value": _count("Race Category", {"active": 1}), + "trend": None, + }, + }, + "tiles": { + "event_setup": _count("Event Edition"), + "race_categories": _count("Race Category"), + "registrations": _count("Registration"), + "payments": _count("Payment Transaction"), + "race_day": _count("Bib Allocation"), + "prizes": _count("Prize Award"), + "volunteers": _count("Volunteer"), + }, + } diff --git a/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.json b/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.json new file mode 100644 index 0000000..c7c3e9d --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.json @@ -0,0 +1,109 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:BIB-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "event_edition", + "bib_number", + "chip_number", + "status", + "allocated_on", + "notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "options": "Event Edition", + "label": "Event Edition", + "read_only": 1, + "in_list_view": 1, + "in_standard_filter": 1 + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "reqd": 1 + }, + { + "fieldname": "chip_number", + "fieldtype": "Data", + "label": "Chip Number", + "reqd": 0 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Allocated\nPrinted\nIssued\nReplaced\nCancelled" + }, + { + "fieldname": "allocated_on", + "fieldtype": "Datetime", + "label": "Allocated On", + "reqd": 0 + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Bib Allocation", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Registration Desk", + "read": 1, + "write": 1, + "create": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "title_field": "bib_number", + "show_title_field_in_link": 1, + "search_fields": "bib_number,registration", + "modified": "2026-09-21 23:14:17.826333" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.json.bak b/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.json.bak new file mode 100644 index 0000000..50d70e7 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.json.bak @@ -0,0 +1,79 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:bib_number", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "bib_number", + "chip_number", + "status", + "allocated_on", + "notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "reqd": 1 + }, + { + "fieldname": "chip_number", + "fieldtype": "Data", + "label": "Chip Number", + "reqd": 0 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Allocated\nPrinted\nIssued\nReplaced\nCancelled" + }, + { + "fieldname": "allocated_on", + "fieldtype": "Datetime", + "label": "Allocated On", + "reqd": 0 + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Bib Allocation", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.py b/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.py new file mode 100644 index 0000000..48b746d --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/bib_allocation/bib_allocation.py @@ -0,0 +1,62 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import now_datetime + +from lukenya_marathon.lukenya_marathon import settings + +LIVE = ("Allocated", "Printed", "Issued") + + +class BibAllocation(Document): + def validate(self): + reg = frappe.db.get_value( + "Registration", self.registration, + ["event_edition", "registration_status", "payment_status", "price_tier"], as_dict=True, + ) + if not reg: + frappe.throw(_("Registration {0} was not found.").format(self.registration)) + + self.event_edition = reg.event_edition + self.bib_number = (self.bib_number or "").strip().upper() + self.chip_number = (self.chip_number or "").strip() + if not self.bib_number: + frappe.throw(_("Bib number is required.")) + if not self.allocated_on: + self.allocated_on = now_datetime() + + self.validate_unique_in_edition() + if self.status in LIVE: + self.validate_one_live_bib() + if self.is_new(): + self.validate_eligible(reg) + + def validate_unique_in_edition(self): + """A bib number belongs to one runner per edition. Cancelled bibs free the number.""" + clash = frappe.db.get_value( + "Bib Allocation", + { + "event_edition": self.event_edition, + "bib_number": self.bib_number, + "status": ["!=", "Cancelled"], + "name": ["!=", self.name or ""], + }, + "name", + ) + if clash and self.status != "Cancelled": + frappe.throw(_("Bib {0} is already used in this edition.").format(self.bib_number)) + + def validate_one_live_bib(self): + other = frappe.db.get_value( + "Bib Allocation", + {"registration": self.registration, "status": ["in", list(LIVE)], "name": ["!=", self.name or ""]}, + "bib_number", + ) + if other: + frappe.throw(_("This entry already has bib {0}. Use Replace bib, or cancel that bib first.").format(other)) + + def validate_eligible(self, reg): + if reg.registration_status not in ("Confirmed", "Completed"): + frappe.throw(_("Only confirmed entries can be given a bib.")) + if settings.get("bibs_require_payment") and reg.payment_status != "Paid" and reg.price_tier != "Complimentary": + frappe.throw(_("This entry hasn't been paid for, so it can't be given a bib.")) diff --git a/lukenya_marathon/lukenya_marathon/doctype/check_in/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/check_in/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.json b/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.json new file mode 100644 index 0000000..50fa545 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.json @@ -0,0 +1,105 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:CHK-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "bib_number", + "check_in_time", + "check_in_method", + "status", + "staff_member", + "notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "reqd": 0 + }, + { + "fieldname": "check_in_time", + "fieldtype": "Datetime", + "label": "Check-in Time", + "reqd": 1 + }, + { + "fieldname": "check_in_method", + "fieldtype": "Select", + "label": "Check-in Method", + "reqd": 1, + "options": "Desk\nQR Code\nManual\nMobile" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Checked In\nDid Not Start\nWithdrawn" + }, + { + "fieldname": "staff_member", + "fieldtype": "Link", + "label": "Staff Member", + "reqd": 0, + "options": "User" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Check-in", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Registration Desk", + "read": 1, + "write": 1, + "create": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.845575" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.json.bak b/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.json.bak new file mode 100644 index 0000000..1cd0c67 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.json.bak @@ -0,0 +1,88 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:CHK-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "bib_number", + "check_in_time", + "check_in_method", + "status", + "staff_member", + "notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "reqd": 0 + }, + { + "fieldname": "check_in_time", + "fieldtype": "Datetime", + "label": "Check-in Time", + "reqd": 1 + }, + { + "fieldname": "check_in_method", + "fieldtype": "Select", + "label": "Check-in Method", + "reqd": 1, + "options": "Desk\nQR Code\nManual\nMobile" + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Checked In\nDid Not Start\nWithdrawn" + }, + { + "fieldname": "staff_member", + "fieldtype": "Link", + "label": "Staff Member", + "reqd": 0, + "options": "User" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Check-in", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.py b/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.py new file mode 100644 index 0000000..397108d --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/check_in/check_in.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class CheckIn(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.json b/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.json index 6113220..560a776 100644 --- a/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.json +++ b/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.json @@ -1,108 +1,129 @@ { - "actions": [], - "allow_rename": 1, - "autoname": "field:edition_code", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "edition_code", - "edition_name", - "event_date", - "registration_open", - "registration_close", - "status", - "venue", - "county", - "description", - "registration_capacity", - "trees_commitment_per_entry" - ], - "fields": [ - { - "fieldname": "edition_code", - "fieldtype": "Data", - "label": "Edition Code", - "reqd": 1, - "unique": 1 - }, - { - "fieldname": "edition_name", - "fieldtype": "Data", - "label": "Edition Name", - "reqd": 1 - }, - { - "fieldname": "event_date", - "fieldtype": "Date", - "label": "Event Date", - "reqd": 1 - }, - { - "fieldname": "registration_open", - "fieldtype": "Datetime", - "label": "Registration Open" - }, - { - "fieldname": "registration_close", - "fieldtype": "Datetime", - "label": "Registration Close" - }, - { - "default": "Draft", - "fieldname": "status", - "fieldtype": "Select", - "label": "Status", - "options": "Draft\nOpen\nClosed\nCompleted\nCancelled", - "reqd": 1 - }, - { - "fieldname": "venue", - "fieldtype": "Data", - "label": "Venue", - "reqd": 1 - }, - { - "fieldname": "county", - "fieldtype": "Data", - "label": "County" - }, - { - "fieldname": "description", - "fieldtype": "Text Editor", - "label": "Public Description" - }, - { - "fieldname": "registration_capacity", - "fieldtype": "Int", - "label": "Registration Capacity" - }, - { - "default": "1", - "fieldname": "trees_commitment_per_entry", - "fieldtype": "Int", - "label": "Trees per Eligible Entry", - "reqd": 1 - } - ], - "links": [], - "module": "Lukenya Marathon", - "name": "Event Edition", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "actions": [], + "allow_rename": 1, + "autoname": "field:edition_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "edition_code", + "edition_name", + "event_date", + "registration_open", + "registration_close", + "early_bird_ends", + "status", + "venue", + "county", + "description", + "registration_capacity", + "trees_commitment_per_entry" + ], + "fields": [ + { + "fieldname": "edition_code", + "fieldtype": "Data", + "label": "Edition Code", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "edition_name", + "fieldtype": "Data", + "label": "Edition Name", + "reqd": 1 + }, + { + "fieldname": "event_date", + "fieldtype": "Date", + "label": "Event Date", + "reqd": 1 + }, + { + "fieldname": "registration_open", + "fieldtype": "Datetime", + "label": "Registration Open" + }, + { + "fieldname": "registration_close", + "fieldtype": "Datetime", + "label": "Registration Close" + }, + { + "fieldname": "early_bird_ends", + "fieldtype": "Datetime", + "label": "Early-bird Ends", + "description": "Early-bird prices apply to residents until this time. Leave empty to never offer them." + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Draft\nOpen\nClosed\nCompleted\nCancelled", + "reqd": 1 + }, + { + "fieldname": "venue", + "fieldtype": "Data", + "label": "Venue", + "reqd": 1 + }, + { + "fieldname": "county", + "fieldtype": "Data", + "label": "County" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Public Description" + }, + { + "fieldname": "registration_capacity", + "fieldtype": "Int", + "label": "Registration Capacity" + }, + { + "default": "1", + "fieldname": "trees_commitment_per_entry", + "fieldtype": "Int", + "label": "Trees per Eligible Entry", + "reqd": 1 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Event Edition", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "role": "Registration Desk", + "read": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.828343" } diff --git a/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.json.bak b/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.json.bak new file mode 100644 index 0000000..6113220 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.json.bak @@ -0,0 +1,108 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:edition_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "edition_code", + "edition_name", + "event_date", + "registration_open", + "registration_close", + "status", + "venue", + "county", + "description", + "registration_capacity", + "trees_commitment_per_entry" + ], + "fields": [ + { + "fieldname": "edition_code", + "fieldtype": "Data", + "label": "Edition Code", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "edition_name", + "fieldtype": "Data", + "label": "Edition Name", + "reqd": 1 + }, + { + "fieldname": "event_date", + "fieldtype": "Date", + "label": "Event Date", + "reqd": 1 + }, + { + "fieldname": "registration_open", + "fieldtype": "Datetime", + "label": "Registration Open" + }, + { + "fieldname": "registration_close", + "fieldtype": "Datetime", + "label": "Registration Close" + }, + { + "default": "Draft", + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "options": "Draft\nOpen\nClosed\nCompleted\nCancelled", + "reqd": 1 + }, + { + "fieldname": "venue", + "fieldtype": "Data", + "label": "Venue", + "reqd": 1 + }, + { + "fieldname": "county", + "fieldtype": "Data", + "label": "County" + }, + { + "fieldname": "description", + "fieldtype": "Text Editor", + "label": "Public Description" + }, + { + "fieldname": "registration_capacity", + "fieldtype": "Int", + "label": "Registration Capacity" + }, + { + "default": "1", + "fieldname": "trees_commitment_per_entry", + "fieldtype": "Int", + "label": "Trees per Eligible Entry", + "reqd": 1 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Event Edition", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.py b/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.py new file mode 100644 index 0000000..3a91a28 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/event_edition/event_edition.py @@ -0,0 +1,21 @@ +import frappe +from frappe import _ +from frappe.model.document import Document + +from lukenya_marathon.lukenya_marathon.registration_rules import blockers, check_dates, edition_readiness + + +class EventEdition(Document): + def validate(self): + problems = check_dates(self) + if problems: + frappe.throw("
".join(problems)) + + # Moving a saved edition to Open needs the basics in place. (A brand new edition can't have + # categories yet, so creating one as Open is left alone.) + before = self.get_doc_before_save() + if self.status == "Open" and before and before.status != "Open": + problems = [c for c in blockers(edition_readiness(self)) if c["key"] != "dates"] + if problems: + lines = [c["label"] + (f" ({c['detail']})" if c["detail"] else "") for c in problems] + frappe.throw(_("This edition can't open registration yet:") + "
" + "
".join(lines)) diff --git a/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/lukenya_settings.json b/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/lukenya_settings.json new file mode 100644 index 0000000..ff4d276 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/lukenya_settings.json @@ -0,0 +1,70 @@ +{ + "actions": [], + "creation": "2026-09-21 00:00:00.000000", + "doctype": "DocType", + "engine": "InnoDB", + "field_order": [ + "registration_section", + "hold_expiry_days", + "auto_close_registration", + "race_day_section", + "bibs_require_payment" + ], + "fields": [ + { + "fieldname": "registration_section", + "fieldtype": "Section Break", + "label": "Registration" + }, + { + "default": "0", + "description": "Cancel a pending entry that is still unpaid after this many days, freeing its place. 0 means never. Entries with a payment in progress are left alone.", + "fieldname": "hold_expiry_days", + "fieldtype": "Int", + "label": "Release unpaid entries after (days)" + }, + { + "default": "1", + "description": "Close an edition automatically once its registration close time has passed.", + "fieldname": "auto_close_registration", + "fieldtype": "Check", + "label": "Close registration automatically" + }, + { + "fieldname": "race_day_section", + "fieldtype": "Section Break", + "label": "Race day" + }, + { + "default": "1", + "description": "When on, a bib can only go to a confirmed entry that is paid or complimentary.", + "fieldname": "bibs_require_payment", + "fieldtype": "Check", + "label": "Bibs require payment" + } + ], + "index_web_pages_for_search": 1, + "issingle": 1, + "links": [], + "modified": "2026-09-21 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "Lukenya Settings", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "print": 1, + "read": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/lukenya_settings.py b/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/lukenya_settings.py new file mode 100644 index 0000000..7914294 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/lukenya_settings/lukenya_settings.py @@ -0,0 +1,9 @@ +import frappe +from frappe import _ +from frappe.model.document import Document + + +class LukenyaSettings(Document): + def validate(self): + if (self.hold_expiry_days or 0) < 0: + frappe.throw(_("Release unpaid entries after: days cannot be negative.")) diff --git a/lukenya_marathon/lukenya_marathon/doctype/participant/participant.json b/lukenya_marathon/lukenya_marathon/doctype/participant/participant.json index cc15b8a..d5d615a 100644 --- a/lukenya_marathon/lukenya_marathon/doctype/participant/participant.json +++ b/lukenya_marathon/lukenya_marathon/doctype/participant/participant.json @@ -1,133 +1,181 @@ { - "actions": [], - "allow_rename": 1, - "autoname": "field:participant_code", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "participant_code", - "first_name", - "last_name", - "id_number", - "date_of_birth", - "gender", - "nationality", - "email", - "phone", - "whatsapp", - "shirt_size", - "emergency_contact_name", - "emergency_contact_phone", - "medical_notes", - "communication_consent" - ], - "fields": [ - { - "fieldname": "participant_code", - "fieldtype": "Data", - "label": "Participant Code", - "reqd": 1, - "unique": 1 - }, - { - "fieldname": "first_name", - "fieldtype": "Data", - "label": "First Name", - "reqd": 1 - }, - { - "fieldname": "last_name", - "fieldtype": "Data", - "label": "Last Name", - "reqd": 1 - }, - { - "fieldname": "id_number", - "fieldtype": "Data", - "label": "ID / Passport Number", - "unique": 1 - }, - { - "fieldname": "date_of_birth", - "fieldtype": "Date", - "label": "Date of Birth" - }, - { - "fieldname": "gender", - "fieldtype": "Select", - "label": "Gender", - "options": "Male\nFemale\nOther\nPrefer not to say" - }, - { - "default": "Kenya", - "fieldname": "nationality", - "fieldtype": "Data", - "label": "Nationality" - }, - { - "fieldname": "email", - "fieldtype": "Data", - "label": "Email", - "options": "Email" - }, - { - "fieldname": "phone", - "fieldtype": "Data", - "label": "Phone", - "reqd": 1 - }, - { - "fieldname": "whatsapp", - "fieldtype": "Data", - "label": "WhatsApp" - }, - { - "fieldname": "shirt_size", - "fieldtype": "Select", - "label": "Shirt Size", - "options": "XS\nS\nM\nL\nXL\nXXL" - }, - { - "fieldname": "emergency_contact_name", - "fieldtype": "Data", - "label": "Emergency Contact Name" - }, - { - "fieldname": "emergency_contact_phone", - "fieldtype": "Data", - "label": "Emergency Contact Phone" - }, - { - "fieldname": "medical_notes", - "fieldtype": "Small Text", - "label": "Medical / Accessibility Notes", - "permlevel": 1 - }, - { - "fieldname": "communication_consent", - "fieldtype": "Check", - "label": "Communication Consent" - } - ], - "links": [], - "module": "Lukenya Marathon", - "name": "Participant", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "actions": [], + "allow_rename": 1, + "autoname": "field:participant_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "participant_code", + "first_name", + "last_name", + "full_name", + "id_number", + "date_of_birth", + "gender", + "nationality", + "email", + "phone", + "whatsapp", + "shirt_size", + "emergency_contact_name", + "emergency_contact_phone", + "guardian_name", + "guardian_phone", + "medical_notes", + "communication_consent" + ], + "fields": [ + { + "fieldname": "participant_code", + "fieldtype": "Data", + "label": "Participant Code", + "reqd": 0, + "unique": 1, + "description": "Generated automatically when left empty" + }, + { + "fieldname": "first_name", + "fieldtype": "Data", + "label": "First Name", + "reqd": 1 + }, + { + "fieldname": "last_name", + "fieldtype": "Data", + "label": "Last Name", + "reqd": 1 + }, + { + "fieldname": "full_name", + "fieldtype": "Data", + "label": "Full Name", + "read_only": 1, + "in_list_view": 1, + "bold": 1, + "no_copy": 1 + }, + { + "fieldname": "id_number", + "fieldtype": "Data", + "label": "ID / Passport Number", + "unique": 1, + "permlevel": 1 + }, + { + "fieldname": "date_of_birth", + "fieldtype": "Date", + "label": "Date of Birth" + }, + { + "fieldname": "gender", + "fieldtype": "Select", + "label": "Gender", + "options": "Male\nFemale\nOther\nPrefer not to say" + }, + { + "default": "Kenya", + "fieldname": "nationality", + "fieldtype": "Data", + "label": "Nationality" + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "options": "Email" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "reqd": 1 + }, + { + "fieldname": "whatsapp", + "fieldtype": "Data", + "label": "WhatsApp" + }, + { + "fieldname": "shirt_size", + "fieldtype": "Select", + "label": "Shirt Size", + "options": "XS\nS\nM\nL\nXL\nXXL" + }, + { + "fieldname": "emergency_contact_name", + "fieldtype": "Data", + "label": "Emergency Contact Name" + }, + { + "fieldname": "emergency_contact_phone", + "fieldtype": "Data", + "label": "Emergency Contact Phone" + }, + { + "fieldname": "guardian_name", + "fieldtype": "Data", + "label": "Guardian Name", + "description": "For runners under 18" + }, + { + "fieldname": "guardian_phone", + "fieldtype": "Data", + "label": "Guardian Phone" + }, + { + "fieldname": "medical_notes", + "fieldtype": "Small Text", + "label": "Medical / Accessibility Notes", + "permlevel": 1 + }, + { + "fieldname": "communication_consent", + "fieldtype": "Check", + "label": "Communication Consent" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Participant", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "role": "System Manager", + "permlevel": 1, + "read": 1, + "write": 1 + }, + { + "role": "Registration Desk", + "read": 1, + "write": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "title_field": "full_name", + "search_fields": "participant_code,full_name,id_number,phone,email", + "show_title_field_in_link": 1, + "modified": "2026-09-21 23:14:17.840063" } diff --git a/lukenya_marathon/lukenya_marathon/doctype/participant/participant.json.bak b/lukenya_marathon/lukenya_marathon/doctype/participant/participant.json.bak new file mode 100644 index 0000000..0d4450b --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/participant/participant.json.bak @@ -0,0 +1,168 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:participant_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "participant_code", + "first_name", + "last_name", + "full_name", + "id_number", + "date_of_birth", + "gender", + "nationality", + "email", + "phone", + "whatsapp", + "shirt_size", + "emergency_contact_name", + "emergency_contact_phone", + "guardian_name", + "guardian_phone", + "medical_notes", + "communication_consent" + ], + "fields": [ + { + "fieldname": "participant_code", + "fieldtype": "Data", + "label": "Participant Code", + "reqd": 0, + "unique": 1, + "description": "Generated automatically when left empty" + }, + { + "fieldname": "first_name", + "fieldtype": "Data", + "label": "First Name", + "reqd": 1 + }, + { + "fieldname": "last_name", + "fieldtype": "Data", + "label": "Last Name", + "reqd": 1 + }, + { + "fieldname": "full_name", + "fieldtype": "Data", + "label": "Full Name", + "read_only": 1, + "in_list_view": 1, + "bold": 1, + "no_copy": 1 + }, + { + "fieldname": "id_number", + "fieldtype": "Data", + "label": "ID / Passport Number", + "unique": 1, + "permlevel": 1 + }, + { + "fieldname": "date_of_birth", + "fieldtype": "Date", + "label": "Date of Birth" + }, + { + "fieldname": "gender", + "fieldtype": "Select", + "label": "Gender", + "options": "Male\nFemale\nOther\nPrefer not to say" + }, + { + "default": "Kenya", + "fieldname": "nationality", + "fieldtype": "Data", + "label": "Nationality" + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "options": "Email" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "reqd": 1 + }, + { + "fieldname": "whatsapp", + "fieldtype": "Data", + "label": "WhatsApp" + }, + { + "fieldname": "shirt_size", + "fieldtype": "Select", + "label": "Shirt Size", + "options": "XS\nS\nM\nL\nXL\nXXL" + }, + { + "fieldname": "emergency_contact_name", + "fieldtype": "Data", + "label": "Emergency Contact Name" + }, + { + "fieldname": "emergency_contact_phone", + "fieldtype": "Data", + "label": "Emergency Contact Phone" + }, + { + "fieldname": "guardian_name", + "fieldtype": "Data", + "label": "Guardian Name", + "description": "For runners under 18" + }, + { + "fieldname": "guardian_phone", + "fieldtype": "Data", + "label": "Guardian Phone" + }, + { + "fieldname": "medical_notes", + "fieldtype": "Small Text", + "label": "Medical / Accessibility Notes", + "permlevel": 1 + }, + { + "fieldname": "communication_consent", + "fieldtype": "Check", + "label": "Communication Consent" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Participant", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "role": "System Manager", + "permlevel": 1, + "read": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "title_field": "full_name", + "search_fields": "participant_code,full_name,id_number,phone,email", + "show_title_field_in_link": 1, + "modified": "2026-09-21 12:21:14.234614" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/participant/participant.py b/lukenya_marathon/lukenya_marathon/doctype/participant/participant.py new file mode 100644 index 0000000..9390c12 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/participant/participant.py @@ -0,0 +1,36 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import getdate, nowdate + +from lukenya_marathon.lukenya_marathon.participant_utils import ( + build_full_name, + clean_name, + normalize_nationality, + normalize_phone, + phone_is_valid, +) + + +class Participant(Document): + def before_naming(self): + # Staff creating a runner by hand should not have to invent a code. + if not self.participant_code: + self.participant_code = frappe.generate_hash(length=10).upper() + + def validate(self): + self.first_name = clean_name(self.first_name) + self.last_name = clean_name(self.last_name) + self.full_name = build_full_name(self.first_name, self.last_name) + self.email = (self.email or "").strip().lower() + self.id_number = (self.id_number or "").strip() or None + self.nationality = normalize_nationality(self.nationality) + + for field in ("phone", "whatsapp", "emergency_contact_phone", "guardian_phone"): + setattr(self, field, normalize_phone(self.get(field))) + + if self.phone and not phone_is_valid(self.phone): + frappe.throw(_("The phone number doesn't look valid: {0}").format(self.phone)) + + if self.date_of_birth and getdate(self.date_of_birth) > getdate(nowdate()): + frappe.throw(_("Date of birth cannot be in the future.")) diff --git a/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.json b/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.json new file mode 100644 index 0000000..6b2c7fa --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.json @@ -0,0 +1,124 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:gateway_transaction_id", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "gateway", + "gateway_transaction_id", + "merchant_request_id", + "checkout_request_id", + "amount", + "currency", + "status", + "payment_date", + "failure_reason", + "callback_payload" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "gateway", + "fieldtype": "Select", + "label": "Gateway", + "reqd": 1, + "options": "M-Pesa\nCard\nBank\nCash\nOther" + }, + { + "fieldname": "gateway_transaction_id", + "fieldtype": "Data", + "label": "Gateway Transaction ID", + "reqd": 1 + }, + { + "fieldname": "merchant_request_id", + "fieldtype": "Data", + "label": "Merchant Request ID", + "reqd": 0 + }, + { + "fieldname": "checkout_request_id", + "fieldtype": "Data", + "label": "Checkout Request ID", + "reqd": 0 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "reqd": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Data", + "label": "Currency", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Initiated\nPending\nPaid\nFailed\nRefunded\nReconciled" + }, + { + "fieldname": "payment_date", + "fieldtype": "Datetime", + "label": "Payment Date", + "reqd": 0 + }, + { + "fieldname": "failure_reason", + "fieldtype": "Small Text", + "label": "Failure Reason", + "reqd": 0 + }, + { + "fieldname": "callback_payload", + "fieldtype": "Long Text", + "label": "Callback Payload", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Payment Transaction", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Registration Desk", + "read": 1 + }, + { + "role": "Race Director", + "read": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.841919" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.json.bak b/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.json.bak new file mode 100644 index 0000000..dd708f2 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.json.bak @@ -0,0 +1,115 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:gateway_transaction_id", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "gateway", + "gateway_transaction_id", + "merchant_request_id", + "checkout_request_id", + "amount", + "currency", + "status", + "payment_date", + "failure_reason", + "callback_payload" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "gateway", + "fieldtype": "Select", + "label": "Gateway", + "reqd": 1, + "options": "M-Pesa\nCard\nBank\nCash\nOther" + }, + { + "fieldname": "gateway_transaction_id", + "fieldtype": "Data", + "label": "Gateway Transaction ID", + "reqd": 1 + }, + { + "fieldname": "merchant_request_id", + "fieldtype": "Data", + "label": "Merchant Request ID", + "reqd": 0 + }, + { + "fieldname": "checkout_request_id", + "fieldtype": "Data", + "label": "Checkout Request ID", + "reqd": 0 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "reqd": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Data", + "label": "Currency", + "reqd": 1 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Initiated\nPending\nPaid\nFailed\nRefunded\nReconciled" + }, + { + "fieldname": "payment_date", + "fieldtype": "Datetime", + "label": "Payment Date", + "reqd": 0 + }, + { + "fieldname": "failure_reason", + "fieldtype": "Small Text", + "label": "Failure Reason", + "reqd": 0 + }, + { + "fieldname": "callback_payload", + "fieldtype": "Long Text", + "label": "Callback Payload", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Payment Transaction", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.py b/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.py new file mode 100644 index 0000000..bd7cb7f --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/payment_transaction/payment_transaction.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class PaymentTransaction(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_award/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/prize_award/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.json b/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.json new file mode 100644 index 0000000..c1f49d3 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.json @@ -0,0 +1,125 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:AWD-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "timing_result", + "prize_rule", + "winner_name", + "id_number", + "approval_status", + "payment_reference", + "paid_on", + "notes" + ], + "fields": [ + { + "fieldname": "timing_result", + "fieldtype": "Link", + "label": "Timing Result", + "reqd": 1, + "options": "Timing Result" + }, + { + "fieldname": "prize_rule", + "fieldtype": "Link", + "label": "Prize Rule", + "reqd": 1, + "options": "Prize Rule" + }, + { + "fieldname": "winner_name", + "fieldtype": "Data", + "label": "Winner Name", + "reqd": 1 + }, + { + "fieldname": "id_number", + "fieldtype": "Data", + "label": "ID / Passport Number", + "reqd": 0 + }, + { + "fieldname": "approval_status", + "fieldtype": "Select", + "label": "Approval Status", + "reqd": 1, + "options": "Pending\nApproved\nRejected\nPaid", + "permlevel": 1 + }, + { + "fieldname": "payment_reference", + "fieldtype": "Data", + "label": "Payment Reference", + "reqd": 0, + "permlevel": 1 + }, + { + "fieldname": "paid_on", + "fieldtype": "Date", + "label": "Paid On", + "reqd": 0, + "permlevel": 1 + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Prize Award", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "System Manager", + "permlevel": 1, + "read": 1, + "write": 1 + }, + { + "role": "Race Director", + "permlevel": 1, + "read": 1, + "write": 1 + }, + { + "role": "Timing Officer", + "permlevel": 1, + "read": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.835354" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.json.bak b/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.json.bak new file mode 100644 index 0000000..d3cb6ab --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.json.bak @@ -0,0 +1,94 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:AWD-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "timing_result", + "prize_rule", + "winner_name", + "id_number", + "approval_status", + "payment_reference", + "paid_on", + "notes" + ], + "fields": [ + { + "fieldname": "timing_result", + "fieldtype": "Link", + "label": "Timing Result", + "reqd": 1, + "options": "Timing Result" + }, + { + "fieldname": "prize_rule", + "fieldtype": "Link", + "label": "Prize Rule", + "reqd": 1, + "options": "Prize Rule" + }, + { + "fieldname": "winner_name", + "fieldtype": "Data", + "label": "Winner Name", + "reqd": 1 + }, + { + "fieldname": "id_number", + "fieldtype": "Data", + "label": "ID / Passport Number", + "reqd": 0 + }, + { + "fieldname": "approval_status", + "fieldtype": "Select", + "label": "Approval Status", + "reqd": 1, + "options": "Pending\nApproved\nRejected\nPaid" + }, + { + "fieldname": "payment_reference", + "fieldtype": "Data", + "label": "Payment Reference", + "reqd": 0 + }, + { + "fieldname": "paid_on", + "fieldtype": "Date", + "label": "Paid On", + "reqd": 0 + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Prize Award", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.py b/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.py new file mode 100644 index 0000000..04c37b7 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/prize_award/prize_award.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class PrizeAward(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_rule/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/prize_rule/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.json b/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.json new file mode 100644 index 0000000..956a42d --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.json @@ -0,0 +1,106 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:PR-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "event_edition", + "race_category", + "division", + "position", + "age_group", + "amount", + "currency", + "active" + ], + "fields": [ + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "race_category", + "fieldtype": "Link", + "label": "Race Category", + "reqd": 1, + "options": "Race Category" + }, + { + "fieldname": "division", + "fieldtype": "Select", + "label": "Division", + "reqd": 1, + "options": "Overall\nMen\nWomen\nWheelchair\nCorporate Relay\nAge Group" + }, + { + "fieldname": "position", + "fieldtype": "Int", + "label": "Position", + "reqd": 1 + }, + { + "fieldname": "age_group", + "fieldtype": "Data", + "label": "Age Group", + "reqd": 0 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "reqd": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Data", + "label": "Currency", + "reqd": 1 + }, + { + "fieldname": "active", + "fieldtype": "Check", + "label": "Active", + "reqd": 0, + "default": "0" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Prize Rule", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.847326" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.json.bak b/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.json.bak new file mode 100644 index 0000000..76bbae0 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.json.bak @@ -0,0 +1,95 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:PR-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "event_edition", + "race_category", + "division", + "position", + "age_group", + "amount", + "currency", + "active" + ], + "fields": [ + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "race_category", + "fieldtype": "Link", + "label": "Race Category", + "reqd": 1, + "options": "Race Category" + }, + { + "fieldname": "division", + "fieldtype": "Select", + "label": "Division", + "reqd": 1, + "options": "Overall\nMen\nWomen\nWheelchair\nCorporate Relay\nAge Group" + }, + { + "fieldname": "position", + "fieldtype": "Int", + "label": "Position", + "reqd": 1 + }, + { + "fieldname": "age_group", + "fieldtype": "Data", + "label": "Age Group", + "reqd": 0 + }, + { + "fieldname": "amount", + "fieldtype": "Currency", + "label": "Amount", + "reqd": 1 + }, + { + "fieldname": "currency", + "fieldtype": "Data", + "label": "Currency", + "reqd": 1 + }, + { + "fieldname": "active", + "fieldtype": "Check", + "label": "Active", + "reqd": 0, + "default": "0" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Prize Rule", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.py b/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.py new file mode 100644 index 0000000..8314716 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/prize_rule/prize_rule.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class PrizeRule(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.json b/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.json index 7ef2c9f..8ad0ed7 100644 --- a/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.json +++ b/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.json @@ -1,132 +1,147 @@ { - "actions": [], - "allow_rename": 1, - "autoname": "field:category_code", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "category_code", - "category_name", - "event_edition", - "discipline", - "distance_km", - "start_time", - "cutoff_hours", - "eligibility", - "kenyan_price", - "non_resident_price", - "early_bird_price", - "capacity", - "active", - "prize_eligible", - "inclusions" - ], - "fields": [ - { - "fieldname": "category_code", - "fieldtype": "Data", - "label": "Category Code", - "reqd": 1, - "unique": 1 - }, - { - "fieldname": "category_name", - "fieldtype": "Data", - "label": "Category Name", - "reqd": 1 - }, - { - "fieldname": "event_edition", - "fieldtype": "Link", - "label": "Event Edition", - "options": "Event Edition", - "reqd": 1 - }, - { - "default": "Running", - "fieldname": "discipline", - "fieldtype": "Select", - "label": "Discipline", - "options": "Running\nCycling\nVirtual\nRelay", - "reqd": 1 - }, - { - "fieldname": "distance_km", - "fieldtype": "Float", - "label": "Distance (KM)" - }, - { - "fieldname": "start_time", - "fieldtype": "Time", - "label": "Start Time" - }, - { - "fieldname": "cutoff_hours", - "fieldtype": "Float", - "label": "Cut-off (Hours)" - }, - { - "fieldname": "eligibility", - "fieldtype": "Small Text", - "label": "Eligibility Rules" - }, - { - "fieldname": "kenyan_price", - "fieldtype": "Currency", - "label": "Kenyan Price", - "reqd": 1 - }, - { - "fieldname": "non_resident_price", - "fieldtype": "Currency", - "label": "Non-resident Price" - }, - { - "fieldname": "early_bird_price", - "fieldtype": "Currency", - "label": "Early Bird Price" - }, - { - "fieldname": "capacity", - "fieldtype": "Int", - "label": "Capacity" - }, - { - "default": "1", - "fieldname": "active", - "fieldtype": "Check", - "label": "Active" - }, - { - "fieldname": "prize_eligible", - "fieldtype": "Check", - "label": "Prize Eligible" - }, - { - "fieldname": "inclusions", - "fieldtype": "Small Text", - "label": "Inclusions" - } - ], - "links": [], - "module": "Lukenya Marathon", - "name": "Race Category", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "actions": [], + "allow_rename": 1, + "autoname": "field:category_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "category_code", + "category_name", + "event_edition", + "discipline", + "distance_km", + "start_time", + "cutoff_hours", + "eligibility", + "kenyan_price", + "non_resident_price", + "early_bird_price", + "capacity", + "active", + "prize_eligible", + "inclusions" + ], + "fields": [ + { + "fieldname": "category_code", + "fieldtype": "Data", + "label": "Category Code", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "category_name", + "fieldtype": "Data", + "label": "Category Name", + "reqd": 1 + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "options": "Event Edition", + "reqd": 1 + }, + { + "default": "Running", + "fieldname": "discipline", + "fieldtype": "Select", + "label": "Discipline", + "options": "Running\nCycling\nVirtual\nRelay", + "reqd": 1 + }, + { + "fieldname": "distance_km", + "fieldtype": "Float", + "label": "Distance (KM)" + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "label": "Start Time" + }, + { + "fieldname": "cutoff_hours", + "fieldtype": "Float", + "label": "Cut-off (Hours)" + }, + { + "fieldname": "eligibility", + "fieldtype": "Small Text", + "label": "Eligibility Rules" + }, + { + "fieldname": "kenyan_price", + "fieldtype": "Currency", + "label": "Kenyan Price", + "reqd": 1 + }, + { + "fieldname": "non_resident_price", + "fieldtype": "Currency", + "label": "Non-resident Price" + }, + { + "fieldname": "early_bird_price", + "fieldtype": "Currency", + "label": "Early Bird Price" + }, + { + "fieldname": "capacity", + "fieldtype": "Int", + "label": "Capacity" + }, + { + "default": "1", + "fieldname": "active", + "fieldtype": "Check", + "label": "Active" + }, + { + "fieldname": "prize_eligible", + "fieldtype": "Check", + "label": "Prize Eligible" + }, + { + "fieldname": "inclusions", + "fieldtype": "Small Text", + "label": "Inclusions" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Race Category", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "role": "Registration Desk", + "read": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.837728" } diff --git a/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.json.bak b/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.json.bak new file mode 100644 index 0000000..7ef2c9f --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.json.bak @@ -0,0 +1,132 @@ +{ + "actions": [], + "allow_rename": 1, + "autoname": "field:category_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "category_code", + "category_name", + "event_edition", + "discipline", + "distance_km", + "start_time", + "cutoff_hours", + "eligibility", + "kenyan_price", + "non_resident_price", + "early_bird_price", + "capacity", + "active", + "prize_eligible", + "inclusions" + ], + "fields": [ + { + "fieldname": "category_code", + "fieldtype": "Data", + "label": "Category Code", + "reqd": 1, + "unique": 1 + }, + { + "fieldname": "category_name", + "fieldtype": "Data", + "label": "Category Name", + "reqd": 1 + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "options": "Event Edition", + "reqd": 1 + }, + { + "default": "Running", + "fieldname": "discipline", + "fieldtype": "Select", + "label": "Discipline", + "options": "Running\nCycling\nVirtual\nRelay", + "reqd": 1 + }, + { + "fieldname": "distance_km", + "fieldtype": "Float", + "label": "Distance (KM)" + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "label": "Start Time" + }, + { + "fieldname": "cutoff_hours", + "fieldtype": "Float", + "label": "Cut-off (Hours)" + }, + { + "fieldname": "eligibility", + "fieldtype": "Small Text", + "label": "Eligibility Rules" + }, + { + "fieldname": "kenyan_price", + "fieldtype": "Currency", + "label": "Kenyan Price", + "reqd": 1 + }, + { + "fieldname": "non_resident_price", + "fieldtype": "Currency", + "label": "Non-resident Price" + }, + { + "fieldname": "early_bird_price", + "fieldtype": "Currency", + "label": "Early Bird Price" + }, + { + "fieldname": "capacity", + "fieldtype": "Int", + "label": "Capacity" + }, + { + "default": "1", + "fieldname": "active", + "fieldtype": "Check", + "label": "Active" + }, + { + "fieldname": "prize_eligible", + "fieldtype": "Check", + "label": "Prize Eligible" + }, + { + "fieldname": "inclusions", + "fieldtype": "Small Text", + "label": "Inclusions" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Race Category", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.py b/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.py new file mode 100644 index 0000000..c7fcd73 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/race_category/race_category.py @@ -0,0 +1,6 @@ +import frappe +from frappe.model.document import Document + + +class RaceCategory(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.json b/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.json new file mode 100644 index 0000000..0539a53 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.json @@ -0,0 +1,117 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:RPI-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "bib_allocation", + "issue_status", + "issued_on", + "issued_by", + "shirt_size", + "medal_issued", + "certificate_issued", + "notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "bib_allocation", + "fieldtype": "Link", + "label": "Bib Allocation", + "reqd": 1, + "options": "Bib Allocation" + }, + { + "fieldname": "issue_status", + "fieldtype": "Select", + "label": "Issue Status", + "reqd": 1, + "options": "Ready\nIssued\nCollected\nUncollected\nCancelled" + }, + { + "fieldname": "issued_on", + "fieldtype": "Datetime", + "label": "Issued On", + "reqd": 0 + }, + { + "fieldname": "issued_by", + "fieldtype": "Link", + "label": "Issued By", + "reqd": 0, + "options": "User" + }, + { + "fieldname": "shirt_size", + "fieldtype": "Data", + "label": "Shirt Size", + "reqd": 0 + }, + { + "fieldname": "medal_issued", + "fieldtype": "Check", + "label": "Medal Issued", + "reqd": 0, + "default": "0" + }, + { + "fieldname": "certificate_issued", + "fieldtype": "Check", + "label": "Certificate Issued", + "reqd": 0, + "default": "0" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Race Pack Issue", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Registration Desk", + "read": 1, + "write": 1, + "create": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.843801" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.json.bak b/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.json.bak new file mode 100644 index 0000000..d85d60e --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.json.bak @@ -0,0 +1,104 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:RPI-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "bib_allocation", + "issue_status", + "issued_on", + "issued_by", + "shirt_size", + "medal_issued", + "certificate_issued", + "notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "bib_allocation", + "fieldtype": "Link", + "label": "Bib Allocation", + "reqd": 1, + "options": "Bib Allocation" + }, + { + "fieldname": "issue_status", + "fieldtype": "Select", + "label": "Issue Status", + "reqd": 1, + "options": "Ready\nIssued\nCollected\nUncollected\nCancelled" + }, + { + "fieldname": "issued_on", + "fieldtype": "Datetime", + "label": "Issued On", + "reqd": 0 + }, + { + "fieldname": "issued_by", + "fieldtype": "Link", + "label": "Issued By", + "reqd": 0, + "options": "User" + }, + { + "fieldname": "shirt_size", + "fieldtype": "Data", + "label": "Shirt Size", + "reqd": 0 + }, + { + "fieldname": "medal_issued", + "fieldtype": "Check", + "label": "Medal Issued", + "reqd": 0, + "default": "0" + }, + { + "fieldname": "certificate_issued", + "fieldtype": "Check", + "label": "Certificate Issued", + "reqd": 0, + "default": "0" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Race Pack Issue", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.py b/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.py new file mode 100644 index 0000000..c7a1339 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/race_pack_issue/race_pack_issue.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class RacePackIssue(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/registration/registration.json b/lukenya_marathon/lukenya_marathon/doctype/registration/registration.json index 429483d..aef3a2d 100644 --- a/lukenya_marathon/lukenya_marathon/doctype/registration/registration.json +++ b/lukenya_marathon/lukenya_marathon/doctype/registration/registration.json @@ -1,151 +1,172 @@ { - "actions": [], - "allow_rename": 0, - "autoname": "format:REG-{####}", - "doctype": "DocType", - "editable_grid": 1, - "engine": "InnoDB", - "field_order": [ - "participant", - "event_edition", - "race_category", - "registration_status", - "registration_date", - "price_tier", - "amount_due", - "payment_status", - "payment_reference", - "bib_number", - "chip_number", - "race_pack_status", - "check_in_status", - "indemnity_accepted", - "consent_timestamp", - "transfer_notes" - ], - "fields": [ - { - "fieldname": "participant", - "fieldtype": "Link", - "label": "Participant", - "options": "Participant", - "reqd": 1 - }, - { - "fieldname": "event_edition", - "fieldtype": "Link", - "label": "Event Edition", - "options": "Event Edition", - "reqd": 1 - }, - { - "fieldname": "race_category", - "fieldtype": "Link", - "label": "Race Category", - "options": "Race Category", - "reqd": 1 - }, - { - "default": "Pending", - "fieldname": "registration_status", - "fieldtype": "Select", - "label": "Registration Status", - "options": "Pending\nConfirmed\nCancelled\nTransferred\nRefunded\nCompleted", - "reqd": 1 - }, - { - "fieldname": "registration_date", - "fieldtype": "Datetime", - "label": "Registration Date", - "default": "Now" - }, - { - "default": "Standard", - "fieldname": "price_tier", - "fieldtype": "Select", - "label": "Price Tier", - "options": "Early Bird\nStandard\nNon-resident\nCorporate\nComplimentary" - }, - { - "fieldname": "amount_due", - "fieldtype": "Currency", - "label": "Amount Due", - "reqd": 1 - }, - { - "default": "Unpaid", - "fieldname": "payment_status", - "fieldtype": "Select", - "label": "Payment Status", - "options": "Unpaid\nPending\nPaid\nFailed\nPartially Paid\nRefunded", - "reqd": 1 - }, - { - "fieldname": "payment_reference", - "fieldtype": "Data", - "label": "Payment Reference" - }, - { - "fieldname": "bib_number", - "fieldtype": "Data", - "label": "Bib Number", - "unique": 1 - }, - { - "fieldname": "chip_number", - "fieldtype": "Data", - "label": "Chip Number", - "unique": 1 - }, - { - "default": "Not Issued", - "fieldname": "race_pack_status", - "fieldtype": "Select", - "label": "Race Pack Status", - "options": "Not Issued\nReady\nIssued\nCollected\nUncollected" - }, - { - "default": "Not Checked In", - "fieldname": "check_in_status", - "fieldtype": "Select", - "label": "Check-in Status", - "options": "Not Checked In\nChecked In\nDid Not Start\nFinished" - }, - { - "fieldname": "indemnity_accepted", - "fieldtype": "Check", - "label": "Indemnity Accepted", - "reqd": 1 - }, - { - "fieldname": "consent_timestamp", - "fieldtype": "Datetime", - "label": "Consent Timestamp" - }, - { - "fieldname": "transfer_notes", - "fieldtype": "Small Text", - "label": "Transfer / Admin Notes" - } - ], - "links": [], - "module": "Lukenya Marathon", - "name": "Registration", - "permissions": [ - { - "create": 1, - "delete": 1, - "email": 1, - "export": 1, - "print": 1, - "read": 1, - "report": 1, - "role": "System Manager", - "share": 1, - "write": 1 - } - ], - "sort_field": "modified", - "sort_order": "DESC", - "track_changes": 1 + "actions": [], + "allow_rename": 0, + "autoname": "format:REG-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "participant", + "event_edition", + "race_category", + "registration_status", + "registration_date", + "price_tier", + "amount_due", + "payment_status", + "payment_reference", + "bib_number", + "chip_number", + "race_pack_status", + "check_in_status", + "indemnity_accepted", + "consent_timestamp", + "transfer_notes" + ], + "fields": [ + { + "fieldname": "participant", + "fieldtype": "Link", + "label": "Participant", + "options": "Participant", + "reqd": 1 + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "options": "Event Edition", + "reqd": 1 + }, + { + "fieldname": "race_category", + "fieldtype": "Link", + "label": "Race Category", + "options": "Race Category", + "reqd": 1 + }, + { + "default": "Pending", + "fieldname": "registration_status", + "fieldtype": "Select", + "label": "Registration Status", + "options": "Pending\nConfirmed\nCancelled\nTransferred\nRefunded\nCompleted", + "reqd": 1 + }, + { + "fieldname": "registration_date", + "fieldtype": "Datetime", + "label": "Registration Date", + "default": "Now" + }, + { + "default": "Standard", + "fieldname": "price_tier", + "fieldtype": "Select", + "label": "Price Tier", + "options": "Early Bird\nStandard\nNon-resident\nCorporate\nComplimentary" + }, + { + "fieldname": "amount_due", + "fieldtype": "Currency", + "label": "Amount Due", + "reqd": 1, + "default": "0" + }, + { + "default": "Unpaid", + "fieldname": "payment_status", + "fieldtype": "Select", + "label": "Payment Status", + "options": "Unpaid\nPending\nPaid\nFailed\nPartially Paid\nRefunded", + "reqd": 1 + }, + { + "fieldname": "payment_reference", + "fieldtype": "Data", + "label": "Payment Reference" + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "unique": 1, + "read_only": 1 + }, + { + "fieldname": "chip_number", + "fieldtype": "Data", + "label": "Chip Number", + "unique": 1, + "read_only": 1 + }, + { + "default": "Not Issued", + "fieldname": "race_pack_status", + "fieldtype": "Select", + "label": "Race Pack Status", + "options": "Not Issued\nReady\nIssued\nCollected\nUncollected", + "read_only": 1 + }, + { + "default": "Not Checked In", + "fieldname": "check_in_status", + "fieldtype": "Select", + "label": "Check-in Status", + "options": "Not Checked In\nChecked In\nDid Not Start\nFinished", + "read_only": 1 + }, + { + "fieldname": "indemnity_accepted", + "fieldtype": "Check", + "label": "Indemnity Accepted", + "reqd": 1 + }, + { + "fieldname": "consent_timestamp", + "fieldtype": "Datetime", + "label": "Consent Timestamp" + }, + { + "fieldname": "transfer_notes", + "fieldtype": "Small Text", + "label": "Transfer / Admin Notes" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Registration", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + }, + { + "role": "Registration Desk", + "read": 1, + "write": 1, + "create": 1 + }, + { + "role": "Timing Officer", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.830725" } diff --git a/lukenya_marathon/lukenya_marathon/doctype/registration/registration.json.bak b/lukenya_marathon/lukenya_marathon/doctype/registration/registration.json.bak new file mode 100644 index 0000000..13db5a4 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/registration/registration.json.bak @@ -0,0 +1,152 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:REG-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "participant", + "event_edition", + "race_category", + "registration_status", + "registration_date", + "price_tier", + "amount_due", + "payment_status", + "payment_reference", + "bib_number", + "chip_number", + "race_pack_status", + "check_in_status", + "indemnity_accepted", + "consent_timestamp", + "transfer_notes" + ], + "fields": [ + { + "fieldname": "participant", + "fieldtype": "Link", + "label": "Participant", + "options": "Participant", + "reqd": 1 + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "options": "Event Edition", + "reqd": 1 + }, + { + "fieldname": "race_category", + "fieldtype": "Link", + "label": "Race Category", + "options": "Race Category", + "reqd": 1 + }, + { + "default": "Pending", + "fieldname": "registration_status", + "fieldtype": "Select", + "label": "Registration Status", + "options": "Pending\nConfirmed\nCancelled\nTransferred\nRefunded\nCompleted", + "reqd": 1 + }, + { + "fieldname": "registration_date", + "fieldtype": "Datetime", + "label": "Registration Date", + "default": "Now" + }, + { + "default": "Standard", + "fieldname": "price_tier", + "fieldtype": "Select", + "label": "Price Tier", + "options": "Early Bird\nStandard\nNon-resident\nCorporate\nComplimentary" + }, + { + "fieldname": "amount_due", + "fieldtype": "Currency", + "label": "Amount Due", + "reqd": 1, + "default": "0" + }, + { + "default": "Unpaid", + "fieldname": "payment_status", + "fieldtype": "Select", + "label": "Payment Status", + "options": "Unpaid\nPending\nPaid\nFailed\nPartially Paid\nRefunded", + "reqd": 1 + }, + { + "fieldname": "payment_reference", + "fieldtype": "Data", + "label": "Payment Reference" + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "unique": 1 + }, + { + "fieldname": "chip_number", + "fieldtype": "Data", + "label": "Chip Number", + "unique": 1 + }, + { + "default": "Not Issued", + "fieldname": "race_pack_status", + "fieldtype": "Select", + "label": "Race Pack Status", + "options": "Not Issued\nReady\nIssued\nCollected\nUncollected" + }, + { + "default": "Not Checked In", + "fieldname": "check_in_status", + "fieldtype": "Select", + "label": "Check-in Status", + "options": "Not Checked In\nChecked In\nDid Not Start\nFinished" + }, + { + "fieldname": "indemnity_accepted", + "fieldtype": "Check", + "label": "Indemnity Accepted", + "reqd": 1 + }, + { + "fieldname": "consent_timestamp", + "fieldtype": "Datetime", + "label": "Consent Timestamp" + }, + { + "fieldname": "transfer_notes", + "fieldtype": "Small Text", + "label": "Transfer / Admin Notes" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Registration", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/registration/registration.py b/lukenya_marathon/lukenya_marathon/doctype/registration/registration.py new file mode 100644 index 0000000..bbbf349 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/registration/registration.py @@ -0,0 +1,79 @@ +import frappe +from frappe import _ +from frappe.model.document import Document +from frappe.utils import flt, now_datetime + +from lukenya_marathon.lukenya_marathon.registration_rules import HOLDING_STATUSES, check_edition_capacity + + +class Registration(Document): + def validate(self): + self.validate_category_belongs_to_edition() + self.validate_capacity() + self.validate_edition_capacity() + self.validate_amount() + self.validate_consent() + + def validate_category_belongs_to_edition(self): + if not self.event_edition or not self.race_category: + return + + category_edition = frappe.db.get_value( + "Race Category", + self.race_category, + "event_edition", + ) + + if category_edition != self.event_edition: + frappe.throw( + _("The selected race category does not belong to this event edition.") + ) + + def validate_capacity(self): + if self.registration_status in ("Cancelled", "Refunded"): + return + + capacity = frappe.db.get_value( + "Race Category", + self.race_category, + "capacity", + ) + + if not capacity: + return + + filters = { + "race_category": self.race_category, + "registration_status": ["in", ["Pending", "Confirmed"]], + } + + if not self.is_new(): + filters["name"] = ["!=", self.name] + + current_count = frappe.db.count("Registration", filters) + + if current_count >= int(capacity): + frappe.throw(_("This race category has reached capacity.")) + + def validate_edition_capacity(self): + """The edition-wide limit. Only checked when an entry starts taking a place, so editing an + entry that already holds one never fails because the edition filled up around it.""" + if self.registration_status not in HOLDING_STATUSES: + return + before = None if self.is_new() else self.get_doc_before_save() + if before and before.registration_status in HOLDING_STATUSES and before.event_edition == self.event_edition: + return + check_edition_capacity(self.event_edition, exclude=None if self.is_new() else self.name) + + def validate_amount(self): + if flt(self.amount_due) < 0: + frappe.throw(_("Amount Due cannot be negative.")) + + def validate_consent(self): + if self.indemnity_accepted and not self.consent_timestamp: + self.consent_timestamp = now_datetime() + + if self.registration_status == "Confirmed" and not self.indemnity_accepted: + frappe.throw( + _("Indemnity acceptance is required before confirmation.") + ) diff --git a/lukenya_marathon/lukenya_marathon/doctype/timing_result/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/timing_result/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.json b/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.json new file mode 100644 index 0000000..7ec479c --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.json @@ -0,0 +1,166 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:RES-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "bib_number", + "event_edition", + "race_category", + "gun_time", + "chip_time", + "finish_status", + "overall_rank", + "gender_rank", + "age_group_rank", + "source", + "approved", + "review_notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "reqd": 1 + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "race_category", + "fieldtype": "Link", + "label": "Race Category", + "reqd": 1, + "options": "Race Category" + }, + { + "fieldname": "gun_time", + "fieldtype": "Duration", + "label": "Gun Time", + "reqd": 0 + }, + { + "fieldname": "chip_time", + "fieldtype": "Duration", + "label": "Chip Time", + "reqd": 0 + }, + { + "fieldname": "finish_status", + "fieldtype": "Select", + "label": "Finish Status", + "reqd": 1, + "options": "Finished\nDid Not Finish\nDid Not Start\nDisqualified\nPending Review" + }, + { + "fieldname": "overall_rank", + "fieldtype": "Int", + "label": "Overall Rank", + "reqd": 0 + }, + { + "fieldname": "gender_rank", + "fieldtype": "Int", + "label": "Gender Rank", + "reqd": 0 + }, + { + "fieldname": "age_group_rank", + "fieldtype": "Int", + "label": "Age Group Rank", + "reqd": 0 + }, + { + "fieldname": "source", + "fieldtype": "Data", + "label": "Source", + "reqd": 0 + }, + { + "fieldname": "approved", + "fieldtype": "Check", + "label": "Approved", + "reqd": 0, + "default": "0", + "permlevel": 1 + }, + { + "fieldname": "review_notes", + "fieldtype": "Small Text", + "label": "Review Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Timing Result", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "System Manager", + "permlevel": 1, + "read": 1, + "write": 1 + }, + { + "role": "Race Director", + "permlevel": 1, + "read": 1, + "write": 1 + }, + { + "role": "Timing Officer", + "permlevel": 1, + "read": 1 + }, + { + "role": "Registration Desk", + "read": 1 + }, + { + "role": "Timing Officer", + "read": 1, + "write": 1, + "create": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.833126" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.json.bak b/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.json.bak new file mode 100644 index 0000000..64408f2 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.json.bak @@ -0,0 +1,131 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:RES-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "bib_number", + "event_edition", + "race_category", + "gun_time", + "chip_time", + "finish_status", + "overall_rank", + "gender_rank", + "age_group_rank", + "source", + "approved", + "review_notes" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "bib_number", + "fieldtype": "Data", + "label": "Bib Number", + "reqd": 1 + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "race_category", + "fieldtype": "Link", + "label": "Race Category", + "reqd": 1, + "options": "Race Category" + }, + { + "fieldname": "gun_time", + "fieldtype": "Duration", + "label": "Gun Time", + "reqd": 0 + }, + { + "fieldname": "chip_time", + "fieldtype": "Duration", + "label": "Chip Time", + "reqd": 0 + }, + { + "fieldname": "finish_status", + "fieldtype": "Select", + "label": "Finish Status", + "reqd": 1, + "options": "Finished\nDid Not Finish\nDid Not Start\nDisqualified\nPending Review" + }, + { + "fieldname": "overall_rank", + "fieldtype": "Int", + "label": "Overall Rank", + "reqd": 0 + }, + { + "fieldname": "gender_rank", + "fieldtype": "Int", + "label": "Gender Rank", + "reqd": 0 + }, + { + "fieldname": "age_group_rank", + "fieldtype": "Int", + "label": "Age Group Rank", + "reqd": 0 + }, + { + "fieldname": "source", + "fieldtype": "Data", + "label": "Source", + "reqd": 0 + }, + { + "fieldname": "approved", + "fieldtype": "Check", + "label": "Approved", + "reqd": 0, + "default": "0" + }, + { + "fieldname": "review_notes", + "fieldtype": "Small Text", + "label": "Review Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Timing Result", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.py b/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.py new file mode 100644 index 0000000..cc618bb --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/timing_result/timing_result.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class TimingResult(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.json b/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.json new file mode 100644 index 0000000..5ded2a9 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.json @@ -0,0 +1,107 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:TREE-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "event_edition", + "trees_committed", + "trees_planted", + "planting_batch", + "planting_date", + "location", + "verification_status", + "evidence_url" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "trees_committed", + "fieldtype": "Int", + "label": "Trees Committed", + "reqd": 1 + }, + { + "fieldname": "trees_planted", + "fieldtype": "Int", + "label": "Trees Planted", + "reqd": 0 + }, + { + "fieldname": "planting_batch", + "fieldtype": "Data", + "label": "Planting Batch", + "reqd": 0 + }, + { + "fieldname": "planting_date", + "fieldtype": "Date", + "label": "Planting Date", + "reqd": 0 + }, + { + "fieldname": "location", + "fieldtype": "Small Text", + "label": "Location / GPS", + "reqd": 0 + }, + { + "fieldname": "verification_status", + "fieldtype": "Select", + "label": "Verification Status", + "reqd": 1, + "options": "Pending\nVerified\nRejected" + }, + { + "fieldname": "evidence_url", + "fieldtype": "Data", + "label": "Evidence URL", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Tree Impact Record", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.852836" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.json.bak b/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.json.bak new file mode 100644 index 0000000..630e7e0 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.json.bak @@ -0,0 +1,101 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:TREE-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "registration", + "event_edition", + "trees_committed", + "trees_planted", + "planting_batch", + "planting_date", + "location", + "verification_status", + "evidence_url" + ], + "fields": [ + { + "fieldname": "registration", + "fieldtype": "Link", + "label": "Registration", + "reqd": 1, + "options": "Registration" + }, + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "trees_committed", + "fieldtype": "Int", + "label": "Trees Committed", + "reqd": 1 + }, + { + "fieldname": "trees_planted", + "fieldtype": "Int", + "label": "Trees Planted", + "reqd": 0 + }, + { + "fieldname": "planting_batch", + "fieldtype": "Data", + "label": "Planting Batch", + "reqd": 0 + }, + { + "fieldname": "planting_date", + "fieldtype": "Date", + "label": "Planting Date", + "reqd": 0 + }, + { + "fieldname": "location", + "fieldtype": "Small Text", + "label": "Location / GPS", + "reqd": 0 + }, + { + "fieldname": "verification_status", + "fieldtype": "Select", + "label": "Verification Status", + "reqd": 1, + "options": "Pending\nVerified\nRejected" + }, + { + "fieldname": "evidence_url", + "fieldtype": "Data", + "label": "Evidence URL", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Tree Impact Record", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.py b/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.py new file mode 100644 index 0000000..94d9ee7 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/tree_impact_record/tree_impact_record.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class TreeImpactRecord(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/volunteer/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.json b/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.json new file mode 100644 index 0000000..ba4599a --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.json @@ -0,0 +1,107 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:volunteer_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "volunteer_code", + "first_name", + "last_name", + "email", + "phone", + "emergency_contact", + "skills", + "availability", + "status" + ], + "fields": [ + { + "fieldname": "volunteer_code", + "fieldtype": "Data", + "label": "Volunteer Code", + "reqd": 1 + }, + { + "fieldname": "first_name", + "fieldtype": "Data", + "label": "First Name", + "reqd": 1 + }, + { + "fieldname": "last_name", + "fieldtype": "Data", + "label": "Last Name", + "reqd": 1 + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "reqd": 0, + "options": "Email" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "reqd": 1 + }, + { + "fieldname": "emergency_contact", + "fieldtype": "Data", + "label": "Emergency Contact", + "reqd": 0 + }, + { + "fieldname": "skills", + "fieldtype": "Small Text", + "label": "Skills", + "reqd": 0 + }, + { + "fieldname": "availability", + "fieldtype": "Small Text", + "label": "Availability", + "reqd": 0 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Applied\nApproved\nBriefed\nActive\nInactive" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Volunteer", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.849196" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.json.bak b/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.json.bak new file mode 100644 index 0000000..0e75dd9 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.json.bak @@ -0,0 +1,100 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "field:volunteer_code", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "volunteer_code", + "first_name", + "last_name", + "email", + "phone", + "emergency_contact", + "skills", + "availability", + "status" + ], + "fields": [ + { + "fieldname": "volunteer_code", + "fieldtype": "Data", + "label": "Volunteer Code", + "reqd": 1 + }, + { + "fieldname": "first_name", + "fieldtype": "Data", + "label": "First Name", + "reqd": 1 + }, + { + "fieldname": "last_name", + "fieldtype": "Data", + "label": "Last Name", + "reqd": 1 + }, + { + "fieldname": "email", + "fieldtype": "Data", + "label": "Email", + "reqd": 0, + "options": "Email" + }, + { + "fieldname": "phone", + "fieldtype": "Data", + "label": "Phone", + "reqd": 1 + }, + { + "fieldname": "emergency_contact", + "fieldtype": "Data", + "label": "Emergency Contact", + "reqd": 0 + }, + { + "fieldname": "skills", + "fieldtype": "Small Text", + "label": "Skills", + "reqd": 0 + }, + { + "fieldname": "availability", + "fieldtype": "Small Text", + "label": "Availability", + "reqd": 0 + }, + { + "fieldname": "status", + "fieldtype": "Select", + "label": "Status", + "reqd": 1, + "options": "Applied\nApproved\nBriefed\nActive\nInactive" + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Volunteer", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.py b/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.py new file mode 100644 index 0000000..5b241f7 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/volunteer/volunteer.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class Volunteer(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/__init__.py b/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.json b/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.json new file mode 100644 index 0000000..dc41bf2 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.json @@ -0,0 +1,106 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:VSH-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "event_edition", + "volunteer", + "work_area", + "shift_date", + "start_time", + "end_time", + "attendance_status", + "notes" + ], + "fields": [ + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "volunteer", + "fieldtype": "Link", + "label": "Volunteer", + "reqd": 1, + "options": "Volunteer" + }, + { + "fieldname": "work_area", + "fieldtype": "Select", + "label": "Work Area", + "reqd": 1, + "options": "Registration\nRoute Marshal\nWater Station\nMedical\nParking\nResults\nRace Pack\nOther" + }, + { + "fieldname": "shift_date", + "fieldtype": "Date", + "label": "Shift Date", + "reqd": 1 + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "label": "Start Time", + "reqd": 1 + }, + { + "fieldname": "end_time", + "fieldtype": "Time", + "label": "End Time", + "reqd": 1 + }, + { + "fieldname": "attendance_status", + "fieldtype": "Select", + "label": "Attendance Status", + "reqd": 1, + "options": "Scheduled\nPresent\nAbsent\nCancelled" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Volunteer Shift", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + }, + { + "role": "Registration Desk", + "read": 1 + }, + { + "role": "Race Director", + "read": 1, + "write": 1, + "create": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1, + "modified": "2026-09-21 23:14:17.851018" +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.json.bak b/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.json.bak new file mode 100644 index 0000000..446294b --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.json.bak @@ -0,0 +1,95 @@ +{ + "actions": [], + "allow_rename": 0, + "autoname": "format:VSH-{####}", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "event_edition", + "volunteer", + "work_area", + "shift_date", + "start_time", + "end_time", + "attendance_status", + "notes" + ], + "fields": [ + { + "fieldname": "event_edition", + "fieldtype": "Link", + "label": "Event Edition", + "reqd": 1, + "options": "Event Edition" + }, + { + "fieldname": "volunteer", + "fieldtype": "Link", + "label": "Volunteer", + "reqd": 1, + "options": "Volunteer" + }, + { + "fieldname": "work_area", + "fieldtype": "Select", + "label": "Work Area", + "reqd": 1, + "options": "Registration\nRoute Marshal\nWater Station\nMedical\nParking\nResults\nRace Pack\nOther" + }, + { + "fieldname": "shift_date", + "fieldtype": "Date", + "label": "Shift Date", + "reqd": 1 + }, + { + "fieldname": "start_time", + "fieldtype": "Time", + "label": "Start Time", + "reqd": 1 + }, + { + "fieldname": "end_time", + "fieldtype": "Time", + "label": "End Time", + "reqd": 1 + }, + { + "fieldname": "attendance_status", + "fieldtype": "Select", + "label": "Attendance Status", + "reqd": 1, + "options": "Scheduled\nPresent\nAbsent\nCancelled" + }, + { + "fieldname": "notes", + "fieldtype": "Small Text", + "label": "Notes", + "reqd": 0 + } + ], + "links": [], + "module": "Lukenya Marathon", + "name": "Volunteer Shift", + "permissions": [ + { + "role": "System Manager", + "read": 1, + "write": 1, + "create": 1, + "delete": 1, + "submit": 1, + "cancel": 1, + "amend": 1, + "print": 1, + "email": 1, + "export": 1, + "report": 1, + "share": 1 + } + ], + "sort_field": "modified", + "sort_order": "DESC", + "track_changes": 1 +} diff --git a/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.py b/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.py new file mode 100644 index 0000000..98f13af --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/doctype/volunteer_shift/volunteer_shift.py @@ -0,0 +1,5 @@ +from frappe.model.document import Document + + +class VolunteerShift(Document): + pass diff --git a/lukenya_marathon/lukenya_marathon/events_management.py b/lukenya_marathon/lukenya_marathon/events_management.py new file mode 100644 index 0000000..5bff006 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/events_management.py @@ -0,0 +1,90 @@ +import frappe +from frappe import _ + + +@frappe.whitelist() +def get_events_summary(): + """ + Return Event Editions with their Race Categories and registration totals. + """ + editions = frappe.get_all( + "Event Edition", + fields=[ + "name", + "edition_code", + "edition_name", + "event_date", + "registration_open", + "registration_close", + "early_bird_ends", + "status", + "venue", + "county", + "registration_capacity", + "trees_commitment_per_entry", + ], + order_by="event_date desc, modified desc", + limit_page_length=100, + ) + + for edition in editions: + edition["category_count"] = frappe.db.count( + "Race Category", + {"event_edition": edition.name}, + ) + + edition["registration_count"] = frappe.db.count( + "Registration", + { + "event_edition": edition.name, + "registration_status": ["not in", ["Cancelled", "Transferred", "Refunded"]], + }, + ) + + edition["paid_count"] = frappe.db.count( + "Registration", + { + "event_edition": edition.name, + "payment_status": "Paid", + "registration_status": ["not in", ["Cancelled", "Transferred", "Refunded"]], + }, + ) + + edition["categories"] = frappe.get_all( + "Race Category", + filters={"event_edition": edition.name}, + fields=[ + "name", + "category_code", + "category_name", + "discipline", + "distance_km", + "start_time", + "cutoff_hours", + "kenyan_price", + "non_resident_price", + "early_bird_price", + "capacity", + "active", + "prize_eligible", + ], + order_by="start_time asc, category_name asc", + limit_page_length=100, + ) + + return editions + + +@frappe.whitelist() +def get_event_metrics(): + """ + Return top-level Event Edition metrics. + """ + return { + "total": frappe.db.count("Event Edition"), + "open": frappe.db.count("Event Edition", {"status": "Open"}), + "draft": frappe.db.count("Event Edition", {"status": "Draft"}), + "completed": frappe.db.count("Event Edition", {"status": "Completed"}), + "cancelled": frappe.db.count("Event Edition", {"status": "Cancelled"}), + "categories": frappe.db.count("Race Category"), + } diff --git a/lukenya_marathon/lukenya_marathon/holds.py b/lukenya_marathon/lukenya_marathon/holds.py new file mode 100644 index 0000000..b56d1c1 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/holds.py @@ -0,0 +1,74 @@ +"""Releasing places held by entries that were never paid for, and the hourly job that runs it.""" + +import frappe +from frappe import _ +from frappe.utils import add_days, add_to_date, cint, now_datetime + +from lukenya_marathon.lukenya_marathon import registration_rules, settings + +RELEASABLE_PAYMENT = ("Unpaid", "Failed") +IN_PROGRESS = ("Initiated", "Pending") +IN_PROGRESS_HOURS = 24 + + +def expired_holds(days): + """Pending, unpaid entries older than `days` whose edition is still running, with no payment + in progress and no money received.""" + cutoff = add_days(now_datetime(), -cint(days)) + recent = add_to_date(now_datetime(), hours=-IN_PROGRESS_HOURS) + return frappe.db.sql( + """ + select r.name, r.event_edition, r.race_category, r.registration_date, r.payment_status, r.amount_due, + concat_ws(' ', p.first_name, p.last_name) as participant_name + from `tabRegistration` r + join `tabEvent Edition` e on e.name = r.event_edition + join `tabParticipant` p on p.name = r.participant + where r.registration_status = 'Pending' + and r.payment_status in %(releasable)s + and coalesce(r.price_tier, '') != 'Complimentary' + and r.registration_date < %(cutoff)s + and e.status in ('Draft', 'Open', 'Closed') + and not exists ( + select 1 from `tabPayment Transaction` t + where t.registration = r.name + and (t.status = 'Paid' or t.status = 'Reconciled' + or (t.status in %(in_progress)s and t.creation > %(recent)s)) + ) + order by r.registration_date + """, + {"releasable": RELEASABLE_PAYMENT, "cutoff": cutoff, "in_progress": IN_PROGRESS, "recent": recent}, + as_dict=True, + ) + + +@frappe.whitelist() +def preview_expired_holds(days=None): + """What the release job would cancel. Changes nothing. Defaults to the number of days in settings.""" + frappe.only_for(("System Manager", "Race Director")) + days = cint(days) or cint(settings.get("hold_expiry_days")) + if not days: + return {"days": 0, "count": 0, "entries": [], "note": _("Release is switched off. Set the number of days in Lukenya Settings, or pass days.")} + rows = expired_holds(days) + return {"days": days, "count": len(rows), "entries": rows} + + +def release_expired_holds(): + days = cint(settings.get("hold_expiry_days")) + if not days: + return [] + released = [] + for row in expired_holds(days): + try: + doc = frappe.get_doc("Registration", row.name) + doc.registration_status = "Cancelled" + doc.save(ignore_permissions=True) + doc.add_comment("Info", _("Released automatically: unpaid for {0} days.").format(days)) + released.append(row.name) + except Exception: + frappe.log_error(title=f"Could not release unpaid entry {row.name}") + return released + + +def hourly(): + registration_rules.close_expired_editions() + release_expired_holds() diff --git a/lukenya_marathon/lukenya_marathon/page/__init__.py b/lukenya_marathon/lukenya_marathon/page/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/__init__.py b/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.css b/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.css new file mode 100644 index 0000000..878c4ea --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.css @@ -0,0 +1,522 @@ +/* Lukenya Marathon dashboard. + * Everything is scoped to #page-lukenya-dashboard so nothing leaks into the rest of Desk. + * Change the look from the tokens block below. + */ + +#page-lukenya-dashboard { + /* Tokens: surface */ + --lm-bg: #f3f6fb; + --lm-card: #ffffff; + --lm-line: #e5eaf3; + --lm-ink: #17233d; + --lm-muted: #66748f; + + /* Tokens: brand */ + --lm-primary: #2f6bff; + --lm-primary-ink: #ffffff; + + /* Tokens: shape */ + --lm-radius: 14px; + --lm-radius-sm: 10px; + --lm-shadow: 0 1px 2px rgba(23, 35, 61, 0.05), 0 6px 18px rgba(23, 35, 61, 0.05); + --lm-shadow-hover: 0 2px 4px rgba(23, 35, 61, 0.07), 0 10px 24px rgba(23, 35, 61, 0.09); + + /* Tokens: hero photo (drop a file at public/images/lukenya-hero.jpg to use it) */ + --lm-hero-image: url("/assets/lukenya_marathon/images/lukenya-hero.jpg"); + + background: var(--lm-bg); + color: var(--lm-ink); +} + +/* Remove Desk's own page header and width limits on this page only. */ +#page-lukenya-dashboard .page-head { + display: none; +} +#page-lukenya-dashboard .container, +#page-lukenya-dashboard .page-body { + max-width: none; + margin: 0; + padding: 0; +} +#page-lukenya-dashboard .layout-main { + margin: 0; +} +#page-lukenya-dashboard .layout-main-section-wrapper, +#page-lukenya-dashboard .layout-main-section { + padding: 0; +} + +/* ---- Shell ---------------------------------------------------------------- */ + +#page-lukenya-dashboard .lm-app { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: calc(100vh - var(--navbar-height, 48px)); +} + +#page-lukenya-dashboard .lm-app, +#page-lukenya-dashboard .lm-app * { + box-sizing: border-box; +} + +/* :where() keeps this reset weaker than the component classes below. */ +#page-lukenya-dashboard :where(.lm-app) button { + font: inherit; + color: inherit; + cursor: pointer; +} + +#page-lukenya-dashboard .lm-ico { + flex: none; +} + +#page-lukenya-dashboard .lm-main { + padding: 24px 28px 32px; + min-width: 0; +} + +/* ---- Sidebar --------------------------------------------------------------- + * Now shared across every lukenya-* page: public/css/lukenya_navigation.css + * ---------------------------------------------------------------------- */ + +/* ---- Hero ----------------------------------------------------------------- */ + +#page-lukenya-dashboard .lm-hero { + position: relative; + display: flex; + align-items: center; + min-height: 140px; + margin-bottom: 18px; + border-radius: var(--lm-radius); + overflow: hidden; + background: var(--lm-card); + border: 1px solid var(--lm-line); + box-shadow: var(--lm-shadow); +} +#page-lukenya-dashboard .lm-hero__text { + position: relative; + z-index: 1; + padding: 24px 28px; +} +#page-lukenya-dashboard .lm-hero__logo { + display: block; + height: 40px; + width: auto; + margin-bottom: 10px; +} +#page-lukenya-dashboard .lm-hero h1 { + margin: 0 0 6px; + font-size: 26px; + font-weight: 700; + line-height: 1.2; + color: var(--lm-ink); +} +#page-lukenya-dashboard .lm-hero p { + margin: 0; + font-size: 14px; + color: var(--lm-muted); +} +#page-lukenya-dashboard .lm-hero__art { + position: absolute; + inset: 0 0 0 42%; + background-image: + linear-gradient(90deg, var(--lm-card) 0%, rgba(255, 255, 255, 0) 45%), + var(--lm-hero-image), + linear-gradient(180deg, #cfe0f5 0%, #e9eef0 45%, #9fb590 46%, #6f8a63 100%); + background-size: cover; + background-position: center; +} +#page-lukenya-dashboard .lm-hero__tag { + position: absolute; + top: 22px; + right: 32px; + font-family: "Segoe Script", "Bradley Hand", "Snell Roundhand", cursive; + font-size: 20px; + line-height: 1.15; + text-align: right; + color: #0f2a55; + transform: rotate(-6deg); + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); +} + +/* ---- KPI row -------------------------------------------------------------- */ + +#page-lukenya-dashboard .lm-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} +#page-lukenya-dashboard .lm-kpi { + position: relative; + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); + text-align: left; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +#page-lukenya-dashboard .lm-kpi:hover { + box-shadow: var(--lm-shadow-hover); + border-color: #d3dcee; +} +#page-lukenya-dashboard .lm-kpi__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +#page-lukenya-dashboard .lm-kpi__label { + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-dashboard .lm-kpi__value { + font-size: 26px; + font-weight: 700; + line-height: 1.15; + color: var(--lm-ink); + white-space: nowrap; +} +#page-lukenya-dashboard .lm-kpi__go { + position: absolute; + top: 14px; + right: 14px; + color: var(--lm-muted); +} + +#page-lukenya-dashboard .lm-trend { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; +} +#page-lukenya-dashboard .lm-trend em { + font-style: normal; + font-weight: 400; + color: var(--lm-muted); +} +#page-lukenya-dashboard .lm-trend.is-up { + color: #1f9d5a; +} +#page-lukenya-dashboard .lm-trend.is-down { + color: #d0424b; +} +#page-lukenya-dashboard .lm-trend.is-flat { + color: var(--lm-muted); + font-weight: 400; +} + +/* ---- Icon chips and tones ------------------------------------------------- */ + +#page-lukenya-dashboard .lm-chip { + display: grid; + place-items: center; + flex: none; + width: 46px; + height: 46px; + border-radius: 999px; + background: var(--tone-bg, #e8f0ff); + color: var(--tone-ink, #2f6bff); +} +#page-lukenya-dashboard .lm-tone-blue { --tone-bg: #e5eeff; --tone-ink: #2f6bff; } +#page-lukenya-dashboard .lm-tone-green { --tone-bg: #e1f6ea; --tone-ink: #1f9d5a; } +#page-lukenya-dashboard .lm-tone-purple { --tone-bg: #ece6ff; --tone-ink: #7451e0; } +#page-lukenya-dashboard .lm-tone-rose { --tone-bg: #ffe6e8; --tone-ink: #dc4650; } +#page-lukenya-dashboard .lm-tone-amber { --tone-bg: #fff1d6; --tone-ink: #d98a0b; } +#page-lukenya-dashboard .lm-tone-teal { --tone-bg: #dcf5f4; --tone-ink: #12928f; } +#page-lukenya-dashboard .lm-tone-gold { --tone-bg: #fff0d1; --tone-ink: #e08a00; } +#page-lukenya-dashboard .lm-tone-peach { --tone-bg: #ffeadb; --tone-ink: #ec7a1f; } +#page-lukenya-dashboard .lm-tone-slate { --tone-bg: #e3ecfb; --tone-ink: #3563c9; } + +/* ---- Operations panel ----------------------------------------------------- */ + +#page-lukenya-dashboard .lm-ops { + padding: 20px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-dashboard .lm-ops__head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 18px; +} +#page-lukenya-dashboard .lm-ops__head > div:not(.lm-menu) { + flex: 1; + min-width: 0; +} +#page-lukenya-dashboard .lm-ops__mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border-radius: 12px; + background: #12417a; + color: #fff; +} +#page-lukenya-dashboard .lm-ops h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-dashboard .lm-ops__head p { + margin: 2px 0 0; + font-size: 13px; + color: var(--lm-muted); +} + +#page-lukenya-dashboard .lm-menu { + position: relative; +} +#page-lukenya-dashboard .lm-quick { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid #cfdcf7; + border-radius: var(--lm-radius-sm); + background: #eef3ff; + color: var(--lm-primary); + font-size: 13px; + font-weight: 600; + transition: background-color 0.15s ease; +} +#page-lukenya-dashboard .lm-quick:hover { + background: #e2ebff; +} +#page-lukenya-dashboard .lm-menu__list { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 5; + min-width: 200px; + padding: 6px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius-sm); + background: var(--lm-card); + box-shadow: var(--lm-shadow-hover); +} +#page-lukenya-dashboard .lm-menu__list[hidden] { + display: none; +} +#page-lukenya-dashboard .lm-menu__item { + display: block; + width: 100%; + padding: 9px 12px; + border: 0; + border-radius: 8px; + background: transparent; + font-size: 13px; + text-align: left; +} +#page-lukenya-dashboard .lm-menu__item:hover { + background: #f1f5fd; +} + +/* ---- Tiles ---------------------------------------------------------------- */ + +#page-lukenya-dashboard .lm-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} +#page-lukenya-dashboard .lm-tile { + position: relative; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + cursor: pointer; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +#page-lukenya-dashboard .lm-tile:hover { + box-shadow: var(--lm-shadow-hover); + border-color: #d3dcee; +} +#page-lukenya-dashboard .lm-tile.is-soon { + cursor: default; + background: #fafbfe; +} +#page-lukenya-dashboard .lm-tile.is-soon:hover { + box-shadow: none; + border-color: var(--lm-line); +} +#page-lukenya-dashboard .lm-tile.is-soon .lm-chip { + filter: saturate(0.5); + opacity: 0.8; +} +#page-lukenya-dashboard .lm-tile__body { + flex: 1; + min-width: 0; +} +#page-lukenya-dashboard .lm-tile h3 { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-dashboard .lm-tile p { + margin: 0 0 10px; + font-size: 12.5px; + line-height: 1.45; + color: var(--lm-muted); +} +#page-lukenya-dashboard .lm-tile__go { + color: var(--lm-muted); +} +#page-lukenya-dashboard .lm-tile__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +#page-lukenya-dashboard .lm-count { + display: inline-block; + min-width: 28px; + padding: 2px 10px; + border-radius: 999px; + background: #e9f0ff; + color: #2a58c9; + font-size: 12px; + font-weight: 700; + text-align: center; +} +#page-lukenya-dashboard .lm-pill { + padding: 2px 10px; + border: 1px solid var(--lm-line); + border-radius: 999px; + background: #fff; + color: var(--lm-muted); + font-size: 12px; + transition: border-color 0.15s ease, color 0.15s ease; +} +#page-lukenya-dashboard .lm-pill:hover { + border-color: var(--lm-primary); + color: var(--lm-primary); +} +#page-lukenya-dashboard .lm-soon { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: #eef1f7; + color: var(--lm-muted); + font-size: 12px; +} + +/* ---- New event bar -------------------------------------------------------- */ + +#page-lukenya-dashboard .lm-newevent { + display: flex; + align-items: center; + gap: 14px; + margin-top: 16px; + padding: 14px 16px; + border-radius: var(--lm-radius); + background: #edf3ff; +} +#page-lukenya-dashboard .lm-newevent__icon { + color: var(--lm-primary); +} +#page-lukenya-dashboard .lm-newevent > div { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +#page-lukenya-dashboard .lm-newevent strong { + font-size: 14px; + color: #1b3f8f; +} +#page-lukenya-dashboard .lm-newevent > div > span { + font-size: 12.5px; + color: var(--lm-muted); +} +#page-lukenya-dashboard .lm-cta { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border: 0; + border-radius: var(--lm-radius-sm); + background: var(--lm-primary); + color: var(--lm-primary-ink); + font-size: 14px; + font-weight: 600; + box-shadow: 0 6px 14px rgba(47, 107, 255, 0.3); + transition: background-color 0.15s ease; +} +#page-lukenya-dashboard .lm-cta:hover { + background: #2459e0; +} + +#page-lukenya-dashboard .lm-notice { + margin-bottom: 18px; + padding: 14px 16px; + border: 1px solid #f3c9cd; + border-radius: var(--lm-radius-sm); + background: #fff1f2; + color: #a3303a; + font-size: 13px; +} + +/* ---- Focus and motion ----------------------------------------------------- */ + +#page-lukenya-dashboard .lm-app :is(button, .lm-tile):focus-visible { + outline: 2px solid var(--lm-primary); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + #page-lukenya-dashboard .lm-app * { + transition: none !important; + } +} + +/* ---- Responsive ----------------------------------------------------------- */ + +@media (max-width: 1200px) { + #page-lukenya-dashboard .lm-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + #page-lukenya-dashboard .lm-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + #page-lukenya-dashboard .lm-app { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-dashboard .lm-main { + padding: 16px; + } +} + +@media (max-width: 640px) { + #page-lukenya-dashboard .lm-kpis, + #page-lukenya-dashboard .lm-grid { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-dashboard .lm-hero__art { + display: none; + } + #page-lukenya-dashboard .lm-ops__head { + flex-wrap: wrap; + } + #page-lukenya-dashboard .lm-newevent { + flex-wrap: wrap; + } +} diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.js b/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.js new file mode 100644 index 0000000..baeb1ac --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.js @@ -0,0 +1,303 @@ +/* Lukenya Marathon - staff dashboard (Desk page). + * Layout and copy live here; colours, spacing and type live in lukenya_dashboard.css. + */ +(function () { + const PAGE = "lukenya-dashboard"; + const API = "lukenya_marathon.lukenya_marathon.dashboard.get_dashboard_stats"; + + // Feather-style line icons (inner SVG only). + const ICON = { + grid: '', + calendar: '', + users: '', + flag: '', + card: '', + chart: '', + sliders: '', + award: '', + run: '', + heart: '', + file: '', + right: '', + plus: '', + zap: '', + bulb: '', + tool: '', + up: '', + down: '', + }; + + const svg = (name, size) => + ``; + + const esc = (s) => frappe.utils.escape_html(s == null ? "" : String(s)); + + const canRead = (doctype) => { + const list = frappe.boot && frappe.boot.user && frappe.boot.user.can_read; + return Array.isArray(list) ? list.includes(doctype) : true; + }; + + // ---- Configuration ------------------------------------------------------- + + const KPIS = [ + { key: "participants", label: __("Total Participants"), icon: "users", tone: "blue", route: "lukenya-participants", doctype: "Participant", compare: __("vs prior 30 days") }, + { key: "editions", label: __("Event Editions"), icon: "calendar", tone: "green", doctype: "Event Edition", compare: __("vs prior 30 days") }, + { key: "revenue", label: __("Total Revenue"), icon: "card", tone: "purple", doctype: "Payment Transaction", compare: __("vs prior 30 days"), money: true }, + { key: "categories", label: __("Race Categories"), icon: "award", tone: "rose", doctype: "Race Category", compare: __("active categories") }, + ]; + + // `pills` are secondary lists that belong to the same area of work. + const TILES = [ + { id: "event_setup", title: __("Event Setup"), desc: __("Configure editions, dates, venues and status."), icon: "calendar", tone: "blue", route: "lukenya-events" }, + { id: "race_categories", title: __("Race Categories"), desc: __("Distances, prices and capacity for each race."), icon: "run", tone: "green", route: "lukenya-race-management" }, + { id: "registrations", title: __("Participants & Registration"), desc: __("View and manage entries and runner records."), icon: "users", tone: "purple", route: "lukenya-participants" }, + { id: "payments", title: __("Payments & Transactions"), desc: __("Track payments, refunds and reconciliation."), icon: "card", tone: "amber", route: "lukenya-payments" }, + { id: "race_day", title: __("Race Day"), desc: __("Bibs, race packs, check-in and timing."), icon: "flag", tone: "teal", doctype: "Bib Allocation", pills: ["Race Pack Issue", "Check-in", "Timing Result"] }, + { id: "prizes", title: __("Prizes & Awards"), desc: __("Set prize rules and approve winners."), icon: "award", tone: "gold", doctype: "Prize Award", pills: ["Prize Rule"] }, + { id: "volunteers", title: __("Volunteers & Impact"), desc: __("Volunteer shifts and tree planting records."), icon: "heart", tone: "peach", doctype: "Volunteer", pills: ["Volunteer Shift", "Tree Impact Record"] }, + { id: "reports", title: __("Reports & Analytics"), desc: __("Entries, revenue, race day, results and impact."), icon: "file", tone: "teal", route: "lukenya-reports", nocount: true }, + { id: "settings", title: __("Settings"), desc: __("Event preferences, notifications and system setup."), icon: "sliders", tone: "slate", soon: true }, + ]; + + const QUICK_ACTIONS = [ + { label: __("New registration"), doctype: "Registration" }, + { label: __("New participant"), doctype: "Participant" }, + { label: __("Record payment"), doctype: "Payment Transaction" }, + { label: __("Allocate bib"), doctype: "Bib Allocation" }, + { label: __("New event edition"), doctype: "Event Edition" }, + ]; + + // ---- Dashboard ----------------------------------------------------------- + + class LukenyaDashboard { + constructor(wrapper) { + this.wrapper = wrapper; + this.$root = $(wrapper).find(".layout-main-section"); + this.data = null; + this.error = false; + this.render(); + this.bind(); + } + + load() { + frappe.call({ + method: API, + callback: (r) => { + this.data = r.message || null; + this.error = !this.data; + this.render(); + }, + error: () => { + this.error = true; + this.render(); + }, + }); + } + + // -- rendering -- + + render() { + this.$root.html( + `
${this.sidebar()}
${this.hero()}${this.kpiRow()}${this.operations()}
` + ); + } + + sidebar() { + return lukenya.nav.render("lukenya-dashboard"); + } + + hero() { + const first = (frappe.session.user_fullname || frappe.session.user || "").split(" ")[0]; + const edition = this.data && this.data.edition && this.data.edition.edition_name; + return ` +
+
+ +

${esc(__("Welcome back, {0}", [first]))}

+

${esc(__("Here's what's happening with {0}", [edition || __("Lukenya Marathon")]))}

+
+ +
`; + } + + kpiRow() { + if (this.error) { + return ``; + } + const cards = KPIS.map((k) => { + const kpi = this.data && this.data.kpis[k.key]; + const valueHtml = kpi + ? esc(k.money ? this.money(kpi.value) : Number(kpi.value || 0).toLocaleString()) + : "–"; + return ` + `; + }).join(""); + return `
${cards}
`; + } + + trend(kpi, cfg) { + if (!kpi) return ` `; + if (kpi.trend == null) { + return `${esc(cfg.compare)}`; + } + if (kpi.trend === 0) { + return `${esc(__("No change"))} ${esc(cfg.compare)}`; + } + const up = kpi.trend > 0; + return `${svg(up ? "up" : "down", 12)}${Math.abs(kpi.trend)}% ${esc(cfg.compare)}`; + } + + operations() { + const counts = (this.data && this.data.tiles) || {}; + const tiles = TILES.map((t) => { + const pills = (t.pills || []) + .filter((pill) => typeof pill !== "string" || canRead(pill)) + .map((pill) => + typeof pill === "string" + ? `` + : `` + ) + .join(""); + const count = t.soon || t.nocount ? "" : `${counts[t.id] != null ? counts[t.id] : "–"}`; + const attrs = t.soon + ? 'class="lm-tile is-soon" aria-disabled="true"' + : t.route + ? `class="lm-tile" role="link" tabindex="0" data-route="${esc(t.route)}"` + : `class="lm-tile" role="link" tabindex="0" data-doctype="${esc(t.doctype)}"`; + return ` +
+ ${svg(t.icon, 22)} +
+

${esc(t.title)}

+

${esc(t.desc)}

+ ${t.soon ? `${esc(__("Coming soon"))}` : `
${count}${pills}
`} +
+ ${t.soon ? "" : `${svg("right", 16)}`} +
`; + }).join(""); + + const actions = QUICK_ACTIONS.filter((a) => canRead(a.doctype)) + .map((a) => ``) + .join(""); + + return ` +
+
+ ${svg("tool", 22)} +
+

${esc(__("Marathon Operations"))}

+

${esc(__("Manage all event-related activities from one place."))}

+
+
+ + +
+
+
${tiles}
+
+ ${svg("bulb", 20)} +
+ ${esc(__("Need to create a new event?"))} + ${esc(__("Set up a new edition, then add its race categories."))} +
+ +
+
`; + } + + money(value) { + const currency = (this.data && this.data.currency) || "KES"; + try { + return format_currency(value || 0, currency, 0); + } catch (e) { + return currency + " " + Number(value || 0).toLocaleString(); + } + } + + // -- interaction -- + + bind() { + const closeMenu = () => { + this.$root.find(".lm-menu__list").attr("hidden", true); + this.$root.find(".lm-quick").attr("aria-expanded", "false"); + }; + + this.$root.on("click", (e) => { + const target = e.target; + + const action = target.closest("[data-action]"); + if (action && action.dataset.action === "menu") { + const $list = this.$root.find(".lm-menu__list"); + const open = $list.attr("hidden") === undefined; + if (open) closeMenu(); + else { + $list.removeAttr("hidden"); + $(action).attr("aria-expanded", "true"); + } + return; + } + if (action && action.dataset.action === "workspace") { + frappe.set_route("Workspaces", "Lukenya Marathon"); + return; + } + + closeMenu(); + + const creator = target.closest("[data-new]"); + if (creator) { + frappe.new_doc(creator.dataset.new); + return; + } + + const route = target.closest("[data-route]"); + if (route) { + frappe.set_route(route.dataset.route); + return; + } + + const link = target.closest("[data-doctype]"); + if (link && !link.classList.contains("is-soon")) { + frappe.set_route("List", link.dataset.doctype); + } + }); + + this.$root.on("keydown", (e) => { + if (e.key === "Escape") closeMenu(); + if (e.key === "Enter" && e.target.classList.contains("lm-tile")) { + if (e.target.dataset.route) frappe.set_route(e.target.dataset.route); + else frappe.set_route("List", e.target.dataset.doctype); + } + }); + } + } + + // ---- Page hooks ---------------------------------------------------------- + + frappe.pages[PAGE].on_page_load = function (wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + title: __("Lukenya Marathon"), + single_column: true, + }); + wrapper.lm_dashboard = new LukenyaDashboard(wrapper); + }; + + frappe.pages[PAGE].on_page_show = function (wrapper) { + if (wrapper.lm_dashboard) wrapper.lm_dashboard.load(); + }; +})(); diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.json b/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.json new file mode 100644 index 0000000..6594a63 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_dashboard/lukenya_dashboard.json @@ -0,0 +1,20 @@ +{ + "creation": "2026-09-20 00:00:00.000000", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-09-20 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "lukenya-dashboard", + "owner": "Administrator", + "page_name": "lukenya-dashboard", + "roles": [ + { + "role": "System Manager" + } + ], + "standard": "Yes", + "system_page": 0, + "title": "Lukenya Dashboard" +} diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_events/__init__.py b/lukenya_marathon/lukenya_marathon/page/lukenya_events/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.css b/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.css new file mode 100644 index 0000000..db105fc --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.css @@ -0,0 +1,237 @@ +#page-lukenya-events, +#page-lukenya-events .page-body, +#page-lukenya-events .layout-main-section-wrapper, +#page-lukenya-events .layout-main-section { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +#page-lukenya-events .page-head { + display: none !important; +} + +#page-lukenya-events .lm-events-layout { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + width: 100%; + min-height: calc(100vh - var(--navbar-height, 48px)); + background: #f5f7fb; +} + +/* Sidebar is shared across every lukenya-* page: + public/css/lukenya_navigation.css */ + +#page-lukenya-events .lm-events-main { + width: 100%; + min-width: 0; + padding: 28px 34px 40px; + overflow-x: auto; +} + +#page-lukenya-events .lm-events-header, +#page-lukenya-events .lm-events-edition-header, +#page-lukenya-events .lm-events-section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +#page-lukenya-events .lm-events-header { + margin-bottom: 20px; +} + +#page-lukenya-events h1, +#page-lukenya-events h2, +#page-lukenya-events h3 { + margin: 0 0 6px; + color: #16233d; +} + +#page-lukenya-events h1 { + font-size: 28px; +} + +#page-lukenya-events h2 { + font-size: 20px; +} + +#page-lukenya-events h3 { + font-size: 16px; +} + +#page-lukenya-events .lm-events-eyebrow { + margin: 0 0 5px; + color: #2f6bff; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; +} + +#page-lukenya-events .lm-events-muted { + margin: 0; + color: #75829a; + font-size: 13px; +} + +#page-lukenya-events .lm-events-actions, +#page-lukenya-events .lm-events-edition-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +#page-lukenya-events .lm-events-card { + border: 1px solid #e2e7f0; + border-radius: 12px; + background: #fff; + box-shadow: 0 5px 18px rgba(30, 48, 80, .05); +} + +#page-lukenya-events .lm-events-kpis { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +#page-lukenya-events .lm-events-kpi { + min-height: 105px; + padding: 16px; +} + +#page-lukenya-events .lm-events-kpi span, +#page-lukenya-events .lm-events-stats span { + display: block; + color: #75829a; + font-size: 12px; +} + +#page-lukenya-events .lm-events-kpi strong { + display: block; + margin-top: 10px; + color: #16233d; + font-size: 28px; +} + +#page-lukenya-events .lm-events-edition { + margin-bottom: 18px; + padding: 20px; +} + +#page-lukenya-events .lm-events-status { + display: inline-block; + margin-bottom: 8px; + padding: 4px 8px; + border-radius: 20px; + background: #e8f7ed; + color: #198754; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +#page-lukenya-events .lm-events-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin: 20px 0; + padding: 14px 0; + border-top: 1px solid #edf0f5; + border-bottom: 1px solid #edf0f5; +} + +#page-lukenya-events .lm-events-stats strong { + display: block; + margin-top: 5px; + color: #16233d; + font-size: 20px; +} + +#page-lukenya-events .lm-events-section-title { + margin-bottom: 12px; +} + +#page-lukenya-events .lm-events-table { + width: 100%; + min-width: 820px; + margin-bottom: 0; +} + +#page-lukenya-events .lm-events-link { + border: 0; + padding: 0; + background: transparent; + color: #2f6bff; + font-weight: 600; +} + +#page-lukenya-events .lm-active, +#page-lukenya-events .lm-inactive { + display: inline-block; + padding: 4px 8px; + border-radius: 20px; + font-size: 11px; + font-weight: 700; +} + +#page-lukenya-events .lm-active { + background: #e8f7ed; + color: #198754; +} + +#page-lukenya-events .lm-inactive { + background: #f1f3f5; + color: #75829a; +} + +#page-lukenya-events .lm-events-loading, +#page-lukenya-events .lm-events-empty, +#page-lukenya-events .lm-events-error { + padding: 28px; +} + +#page-lukenya-events .lm-events-error { + color: #b42318; +} + +@media (max-width: 1100px) { + #page-lukenya-events .lm-events-kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + #page-lukenya-events .lm-events-layout { + display: block; + } + + #page-lukenya-events .lm-side { + display: none; + } + + #page-lukenya-events .lm-events-main { + padding: 16px; + } + + #page-lukenya-events .lm-events-header, + #page-lukenya-events .lm-events-edition-header { + display: block; + } + + #page-lukenya-events .lm-events-actions, + #page-lukenya-events .lm-events-edition-actions { + margin-top: 14px; + } + + #page-lukenya-events .lm-events-kpis, + #page-lukenya-events .lm-events-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.js b/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.js new file mode 100644 index 0000000..ee9b8bf --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.js @@ -0,0 +1,351 @@ +frappe.provide("lukenya.events"); + +(() => { + const PAGE = "lukenya-events"; + const API = "lukenya_marathon.lukenya_marathon.events_management."; + + const esc = (value) => + frappe.utils.escape_html(value == null ? "" : String(value)); + + const jsonFilters = (filters) => + esc(JSON.stringify(filters)); + + class EventsPage { + constructor(wrapper) { + this.wrapper = wrapper; + this.editions = []; + this.metrics = {}; + + this.$root = $(wrapper).find(".layout-main-section"); + + this.renderShell(); + this.bindEvents(); + } + + renderShell() { + this.$root.html(` +
+ ${lukenya.nav.render("lukenya-events")} + +
+
+
+

+ Event setup +

+ +

Events

+ +

+ Manage marathon editions, dates, venues, + race categories and registration capacity. +

+
+ +
+ + + + + +
+
+ +
+
+ Loading events... +
+
+
+
+ `); + } + + bindEvents() { + this.$root.on("click", "[data-action='refresh']", () => { + this.load(); + }); + + this.$root.on("click", "[data-new]", (event) => { + const doctype = event.currentTarget.dataset.new; + frappe.new_doc(doctype); + }); + + this.$root.on("click", "[data-route]", (event) => { + const route = event.currentTarget.dataset.route; + + if (route) { + frappe.set_route(route); + } + }); + + this.$root.on("click", "[data-doctype]", (event) => { + const doctype = event.currentTarget.dataset.doctype; + + if (doctype) { + frappe.set_route("List", doctype); + } + }); + + this.$root.on("click", "[data-open-doc]", (event) => { + const doctype = event.currentTarget.dataset.openDoc; + const name = event.currentTarget.dataset.name; + + frappe.set_route("Form", doctype, name); + }); + + this.$root.on("click", "[data-open-list]", (event) => { + const doctype = event.currentTarget.dataset.openList; + const filters = JSON.parse( + event.currentTarget.dataset.filters || "{}" + ); + + frappe.route_options = filters; + frappe.set_route("List", doctype); + }); + } + + show() { + this.load(); + } + + load() { + this.$root.find("[data-content]").html(` +
+ Loading events... +
+ `); + + frappe.call({ + method: API + "get_event_metrics", + callback: (metricsResponse) => { + this.metrics = metricsResponse.message || {}; + + frappe.call({ + method: API + "get_events_summary", + callback: (eventsResponse) => { + this.editions = eventsResponse.message || []; + this.render(); + }, + error: () => { + this.showError( + "Could not load Event Editions." + ); + } + }); + }, + error: () => { + this.showError("Could not load event metrics."); + } + }); + } + + render() { + const m = this.metrics || {}; + + const cards = [ + ["Total editions", m.total || 0], + ["Open", m.open || 0], + ["Draft", m.draft || 0], + ["Completed", m.completed || 0], + ["Race categories", m.categories || 0] + ]; + + const editionCards = this.editions.length + ? this.editions.map((edition) => + this.renderEdition(edition) + ).join("") + : ` +
+ No Event Editions found. + Create your first Event Edition to continue. +
+ `; + + this.$root.find("[data-content]").html(` +
+ ${cards.map((card) => ` +
+ ${esc(card[0])} + ${esc(card[1])} +
+ `).join("")} +
+ +
+ ${editionCards} +
+ `); + } + + renderEdition(edition) { + const categories = edition.categories || []; + + const categoryRows = categories.length + ? categories.map((category) => ` + + + + + ${esc(category.discipline || "-")} + ${esc(category.distance_km || "-")} km + ${esc(category.start_time || "-")} + ${esc(category.capacity || 0)} + ${esc(category.kenyan_price || 0)} + + + ${category.active ? "Active" : "Inactive"} + + + + `).join("") + : ` + + + No race categories configured. + + + `; + + return ` +
+
+
+
+ ${esc(edition.status || "")} +
+ +

+ ${esc( + edition.edition_name || + edition.name + )} +

+ +

+ Code: ${esc(edition.edition_code || "-")} + · Date: ${esc(edition.event_date || "-")} + · Venue: ${esc(edition.venue || "-")} +

+
+ +
+ + + +
+
+ +
+
+ Registrations + ${esc(edition.registration_count || 0)} +
+ +
+ Paid entries + ${esc(edition.paid_count || 0)} +
+ +
+ Categories + ${esc(edition.category_count || 0)} +
+ +
+ Capacity + ${esc(edition.registration_capacity || 0)} +
+
+ +
+
+

Race categories

+

+ Categories configured for this edition. +

+
+ + +
+ +
+ + + + + + + + + + + + + + + ${categoryRows} + +
CategoryDisciplineDistanceStartCapacityKenyan priceStatus
+
+
+ `; + } + + showError(message) { + this.$root.find("[data-content]").html(` +
+ ${esc(message)} +
+ `); + } + } + + frappe.pages[PAGE].on_page_load = (wrapper) => { + frappe.ui.make_app_page({ + parent: wrapper, + title: __("Events"), + single_column: true + }); + + wrapper.eventsPage = new EventsPage(wrapper); + }; + + frappe.pages[PAGE].on_page_show = (wrapper) => { + if (wrapper.eventsPage) { + wrapper.eventsPage.show(); + } + }; +})(); diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.json b/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.json new file mode 100644 index 0000000..c6e9a35 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_events/lukenya_events.json @@ -0,0 +1,20 @@ +{ + "creation": "2026-09-22 00:00:00.000000", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-09-22 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "lukenya-events", + "owner": "Administrator", + "page_name": "lukenya-events", + "roles": [ + {"role": "System Manager"}, + {"role": "Race Director"}, + {"role": "Registration Desk"} + ], + "standard": "Yes", + "system_page": 0, + "title": "Events" +} diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_participants/__init__.py b/lukenya_marathon/lukenya_marathon/page/lukenya_participants/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.css b/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.css new file mode 100644 index 0000000..9a3f6c5 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.css @@ -0,0 +1,1309 @@ +/* Lukenya Marathon participants page. Shares its shell (sidebar, tokens) with the dashboard. + * Everything is scoped to #page-lukenya-participants so nothing leaks into the rest of Desk. + * Change the look from the tokens block below. + */ + +#page-lukenya-participants { + /* Tokens: surface */ + --lm-bg: #f3f6fb; + --lm-card: #ffffff; + --lm-line: #e5eaf3; + --lm-ink: #17233d; + --lm-muted: #66748f; + + /* Tokens: brand */ + --lm-primary: #2f6bff; + --lm-primary-ink: #ffffff; + + /* Tokens: shape */ + --lm-radius: 14px; + --lm-radius-sm: 10px; + --lm-shadow: 0 1px 2px rgba(23, 35, 61, 0.05), 0 6px 18px rgba(23, 35, 61, 0.05); + --lm-shadow-hover: 0 2px 4px rgba(23, 35, 61, 0.07), 0 10px 24px rgba(23, 35, 61, 0.09); + + /* Tokens: hero photo (drop a file at public/images/lukenya-hero.jpg to use it) */ + --lm-hero-image: url("/assets/lukenya_marathon/images/lukenya-hero.jpg"); + + background: var(--lm-bg); + color: var(--lm-ink); +} + +/* Remove Desk's own page header and width limits on this page only. */ +#page-lukenya-participants .page-head { + display: none; +} +#page-lukenya-participants .container, +#page-lukenya-participants .page-body { + max-width: none; + margin: 0; + padding: 0; +} +#page-lukenya-participants .layout-main { + margin: 0; +} +#page-lukenya-participants .layout-main-section-wrapper, +#page-lukenya-participants .layout-main-section { + padding: 0; +} + +/* ---- Shell ---------------------------------------------------------------- */ + +#page-lukenya-participants .lm-app { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: calc(100vh - var(--navbar-height, 48px)); +} + +#page-lukenya-participants .lm-app, +#page-lukenya-participants .lm-app * { + box-sizing: border-box; +} + +/* :where() keeps this reset weaker than the component classes below. */ +#page-lukenya-participants :where(.lm-app) button { + font: inherit; + color: inherit; + cursor: pointer; +} + +#page-lukenya-participants .lm-ico { + flex: none; +} + +#page-lukenya-participants .lm-main { + padding: 24px 28px 32px; + min-width: 0; +} + +/* ---- Sidebar --------------------------------------------------------------- + * Now shared across every lukenya-* page: public/css/lukenya_navigation.css + * ---------------------------------------------------------------------- */ + +/* ---- Hero ----------------------------------------------------------------- */ + +#page-lukenya-participants .lm-hero { + position: relative; + display: flex; + align-items: center; + min-height: 140px; + margin-bottom: 18px; + border-radius: var(--lm-radius); + overflow: hidden; + background: var(--lm-card); + border: 1px solid var(--lm-line); + box-shadow: var(--lm-shadow); +} +#page-lukenya-participants .lm-hero__text { + position: relative; + z-index: 1; + padding: 24px 28px; +} +#page-lukenya-participants .lm-hero h1 { + margin: 0 0 6px; + font-size: 26px; + font-weight: 700; + line-height: 1.2; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-hero p { + margin: 0; + font-size: 14px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-hero__art { + position: absolute; + inset: 0 0 0 42%; + background-image: + linear-gradient(90deg, var(--lm-card) 0%, rgba(255, 255, 255, 0) 45%), + var(--lm-hero-image), + linear-gradient(180deg, #cfe0f5 0%, #e9eef0 45%, #9fb590 46%, #6f8a63 100%); + background-size: cover; + background-position: center; +} +#page-lukenya-participants .lm-hero__tag { + position: absolute; + top: 22px; + right: 32px; + font-family: "Segoe Script", "Bradley Hand", "Snell Roundhand", cursive; + font-size: 20px; + line-height: 1.15; + text-align: right; + color: #0f2a55; + transform: rotate(-6deg); + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); +} + +/* ---- KPI row -------------------------------------------------------------- */ + +#page-lukenya-participants .lm-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} +#page-lukenya-participants .lm-kpi { + position: relative; + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); + text-align: left; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +#page-lukenya-participants .lm-kpi:hover { + box-shadow: var(--lm-shadow-hover); + border-color: #d3dcee; +} +#page-lukenya-participants .lm-kpi__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +#page-lukenya-participants .lm-kpi__label { + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-kpi__value { + font-size: 26px; + font-weight: 700; + line-height: 1.15; + color: var(--lm-ink); + white-space: nowrap; +} +#page-lukenya-participants .lm-kpi__go { + position: absolute; + top: 14px; + right: 14px; + color: var(--lm-muted); +} + +#page-lukenya-participants .lm-trend { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; +} +#page-lukenya-participants .lm-trend em { + font-style: normal; + font-weight: 400; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-trend.is-up { + color: #1f9d5a; +} +#page-lukenya-participants .lm-trend.is-down { + color: #d0424b; +} +#page-lukenya-participants .lm-trend.is-flat { + color: var(--lm-muted); + font-weight: 400; +} + +/* ---- Icon chips and tones ------------------------------------------------- */ + +#page-lukenya-participants .lm-chip { + display: grid; + place-items: center; + flex: none; + width: 46px; + height: 46px; + border-radius: 999px; + background: var(--tone-bg, #e8f0ff); + color: var(--tone-ink, #2f6bff); +} +#page-lukenya-participants .lm-tone-blue { --tone-bg: #e5eeff; --tone-ink: #2f6bff; } +#page-lukenya-participants .lm-tone-green { --tone-bg: #e1f6ea; --tone-ink: #1f9d5a; } +#page-lukenya-participants .lm-tone-purple { --tone-bg: #ece6ff; --tone-ink: #7451e0; } +#page-lukenya-participants .lm-tone-rose { --tone-bg: #ffe6e8; --tone-ink: #dc4650; } +#page-lukenya-participants .lm-tone-amber { --tone-bg: #fff1d6; --tone-ink: #d98a0b; } +#page-lukenya-participants .lm-tone-teal { --tone-bg: #dcf5f4; --tone-ink: #12928f; } +#page-lukenya-participants .lm-tone-gold { --tone-bg: #fff0d1; --tone-ink: #e08a00; } +#page-lukenya-participants .lm-tone-peach { --tone-bg: #ffeadb; --tone-ink: #ec7a1f; } +#page-lukenya-participants .lm-tone-slate { --tone-bg: #e3ecfb; --tone-ink: #3563c9; } + +/* ---- Operations panel ----------------------------------------------------- */ + +#page-lukenya-participants .lm-ops { + padding: 20px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-participants .lm-ops__head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 18px; +} +#page-lukenya-participants .lm-ops__head > div:not(.lm-menu) { + flex: 1; + min-width: 0; +} +#page-lukenya-participants .lm-ops__mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border-radius: 12px; + background: #12417a; + color: #fff; +} +#page-lukenya-participants .lm-ops h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-ops__head p { + margin: 2px 0 0; + font-size: 13px; + color: var(--lm-muted); +} + +#page-lukenya-participants .lm-menu { + position: relative; +} +#page-lukenya-participants .lm-quick { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid #cfdcf7; + border-radius: var(--lm-radius-sm); + background: #eef3ff; + color: var(--lm-primary); + font-size: 13px; + font-weight: 600; + transition: background-color 0.15s ease; +} +#page-lukenya-participants .lm-quick:hover { + background: #e2ebff; +} +#page-lukenya-participants .lm-menu__list { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 5; + min-width: 200px; + padding: 6px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius-sm); + background: var(--lm-card); + box-shadow: var(--lm-shadow-hover); +} +#page-lukenya-participants .lm-menu__list[hidden] { + display: none; +} +#page-lukenya-participants .lm-menu__item { + display: block; + width: 100%; + padding: 9px 12px; + border: 0; + border-radius: 8px; + background: transparent; + font-size: 13px; + text-align: left; +} +#page-lukenya-participants .lm-menu__item:hover { + background: #f1f5fd; +} + +/* ---- Tiles ---------------------------------------------------------------- */ + +#page-lukenya-participants .lm-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} +#page-lukenya-participants .lm-tile { + position: relative; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + cursor: pointer; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +#page-lukenya-participants .lm-tile:hover { + box-shadow: var(--lm-shadow-hover); + border-color: #d3dcee; +} +#page-lukenya-participants .lm-tile.is-soon { + cursor: default; + background: #fafbfe; +} +#page-lukenya-participants .lm-tile.is-soon:hover { + box-shadow: none; + border-color: var(--lm-line); +} +#page-lukenya-participants .lm-tile.is-soon .lm-chip { + filter: saturate(0.5); + opacity: 0.8; +} +#page-lukenya-participants .lm-tile__body { + flex: 1; + min-width: 0; +} +#page-lukenya-participants .lm-tile h3 { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-tile p { + margin: 0 0 10px; + font-size: 12.5px; + line-height: 1.45; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-tile__go { + color: var(--lm-muted); +} +#page-lukenya-participants .lm-tile__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +#page-lukenya-participants .lm-count { + display: inline-block; + min-width: 28px; + padding: 2px 10px; + border-radius: 999px; + background: #e9f0ff; + color: #2a58c9; + font-size: 12px; + font-weight: 700; + text-align: center; +} +#page-lukenya-participants .lm-pill { + padding: 2px 10px; + border: 1px solid var(--lm-line); + border-radius: 999px; + background: #fff; + color: var(--lm-muted); + font-size: 12px; + transition: border-color 0.15s ease, color 0.15s ease; +} +#page-lukenya-participants .lm-pill:hover { + border-color: var(--lm-primary); + color: var(--lm-primary); +} +#page-lukenya-participants .lm-soon { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: #eef1f7; + color: var(--lm-muted); + font-size: 12px; +} + +/* ---- New event bar -------------------------------------------------------- */ + +#page-lukenya-participants .lm-newevent { + display: flex; + align-items: center; + gap: 14px; + margin-top: 16px; + padding: 14px 16px; + border-radius: var(--lm-radius); + background: #edf3ff; +} +#page-lukenya-participants .lm-newevent__icon { + color: var(--lm-primary); +} +#page-lukenya-participants .lm-newevent > div { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +#page-lukenya-participants .lm-newevent strong { + font-size: 14px; + color: #1b3f8f; +} +#page-lukenya-participants .lm-newevent > div > span { + font-size: 12.5px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-cta { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border: 0; + border-radius: var(--lm-radius-sm); + background: var(--lm-primary); + color: var(--lm-primary-ink); + font-size: 14px; + font-weight: 600; + box-shadow: 0 6px 14px rgba(47, 107, 255, 0.3); + transition: background-color 0.15s ease; +} +#page-lukenya-participants .lm-cta:hover { + background: #2459e0; +} + +#page-lukenya-participants .lm-notice { + margin-bottom: 18px; + padding: 14px 16px; + border: 1px solid #f3c9cd; + border-radius: var(--lm-radius-sm); + background: #fff1f2; + color: #a3303a; + font-size: 13px; +} + +/* ---- Focus and motion ----------------------------------------------------- */ + +#page-lukenya-participants .lm-app :is(button, .lm-tile):focus-visible { + outline: 2px solid var(--lm-primary); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + #page-lukenya-participants .lm-app * { + transition: none !important; + } +} + +/* ---- Responsive ----------------------------------------------------------- */ + +@media (max-width: 1200px) { + #page-lukenya-participants .lm-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + #page-lukenya-participants .lm-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + #page-lukenya-participants .lm-app { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-participants .lm-main { + padding: 16px; + } +} + +@media (max-width: 640px) { + #page-lukenya-participants .lm-kpis, + #page-lukenya-participants .lm-grid { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-participants .lm-hero__art { + display: none; + } + #page-lukenya-participants .lm-ops__head { + flex-wrap: wrap; + } + #page-lukenya-participants .lm-newevent { + flex-wrap: wrap; + } +} + + +/* ==== Reports page ======================================================== */ + +#page-lukenya-participants .lm-rep { + display: flex; + flex-direction: column; + gap: 16px; +} +#page-lukenya-participants .lm-rep__head h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-rep__head p { + margin: 0; + font-size: 14px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-rep__body { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +/* Filters */ +#page-lukenya-participants .lm-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-participants .lm-field { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + font-size: 12px; + font-weight: 500; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-field select, +#page-lukenya-participants .lm-field input { + height: 36px; + min-width: 170px; + margin: 0; + padding: 0 10px; + border: 1px solid var(--lm-line); + border-radius: 8px; + background: #fff; + color: var(--lm-ink); + font-size: 13px; + box-shadow: none; +} +#page-lukenya-participants .lm-field select:focus, +#page-lukenya-participants .lm-field input:focus { + border-color: var(--lm-primary); + outline: 2px solid rgba(47, 107, 255, 0.2); +} +#page-lukenya-participants .lm-reset { + height: 36px; + padding: 0 14px; + border: 1px solid var(--lm-line); + border-radius: 8px; + background: #fff; + color: var(--lm-muted); + font-size: 13px; +} +#page-lukenya-participants .lm-reset:hover { + border-color: var(--lm-primary); + color: var(--lm-primary); +} +#page-lukenya-participants .lm-filters__note { + flex-basis: 100%; + margin: 0; + font-size: 12px; + color: var(--lm-muted); +} + +/* Tabs */ +#page-lukenya-participants .lm-tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + border-bottom: 1px solid var(--lm-line); +} +#page-lukenya-participants .lm-tab { + margin-bottom: -1px; + padding: 10px 14px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--lm-muted); + font-size: 14px; + font-weight: 600; + transition: color 0.15s ease; +} +#page-lukenya-participants .lm-tab:hover { + color: var(--lm-ink); +} +#page-lukenya-participants .lm-tab.is-active { + border-bottom-color: var(--lm-primary); + color: var(--lm-primary); +} + +/* KPI cards */ +#page-lukenya-participants .lm-stats { + display: grid; + grid-template-columns: repeat(var(--lm-cols, 4), minmax(0, 1fr)); + gap: 14px; +} +#page-lukenya-participants .lm-stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-participants .lm-stat__label { + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-stat__value { + font-size: 26px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-stat__hint { + font-size: 12px; + color: var(--lm-muted); +} + +/* Needs attention */ +#page-lukenya-participants .lm-attn { + padding: 14px 16px; + border: 1px solid #f3dfb8; + border-radius: var(--lm-radius); + background: #fffaf0; +} +#page-lukenya-participants .lm-attn h2 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 700; + color: #8a5a00; +} +#page-lukenya-participants .lm-attn ul { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +#page-lukenya-participants .lm-attn__item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border: 1px solid #f0d9a6; + border-radius: 999px; + background: #fff; + color: #6b4a00; + font-size: 13px; + transition: border-color 0.15s ease; +} +#page-lukenya-participants .lm-attn__item:hover { + border-color: #d99a1a; +} +#page-lukenya-participants .lm-attn__item.is-static { + cursor: default; +} +#page-lukenya-participants .lm-attn__item.is-static:hover { + border-color: #f0d9a6; +} + +/* Panels, charts and tables */ +#page-lukenya-participants .lm-charts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +#page-lukenya-participants .lm-panel { + min-width: 0; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-participants .lm-panel.is-wide { + grid-column: 1 / -1; +} +#page-lukenya-participants .lm-panel > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +#page-lukenya-participants .lm-panel h3 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-panel__actions { + display: flex; + gap: 14px; +} +#page-lukenya-participants .lm-linkbtn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0; + border: 0; + background: transparent; + color: var(--lm-primary); + font-size: 12.5px; + font-weight: 600; +} +#page-lukenya-participants .lm-linkbtn:hover { + text-decoration: underline; +} +#page-lukenya-participants .lm-chart { + min-height: 200px; +} +#page-lukenya-participants .lm-empty { + margin: 0; + padding: 28px 0; + text-align: center; + font-size: 13px; + color: var(--lm-muted); +} + +#page-lukenya-participants .lm-tablewrap { + overflow-x: auto; +} +#page-lukenya-participants .lm-table { + width: 100%; + margin: 0; + border-collapse: collapse; + font-size: 13px; +} +#page-lukenya-participants .lm-table th { + padding: 8px 10px; + border-bottom: 1px solid var(--lm-line); + background: #f8fafd; + color: var(--lm-muted); + font-size: 12px; + font-weight: 600; + text-align: left; + white-space: nowrap; +} +#page-lukenya-participants .lm-table td { + padding: 9px 10px; + border-bottom: 1px solid #eef2f8; + color: var(--lm-ink); + white-space: nowrap; +} +#page-lukenya-participants .lm-table tr:last-child td { + border-bottom: 0; +} +#page-lukenya-participants .lm-table .is-int, +#page-lukenya-participants .lm-table .is-currency, +#page-lukenya-participants .lm-table .is-percent { + text-align: right; + font-variant-numeric: tabular-nums; +} +#page-lukenya-participants .lm-link { + color: var(--lm-primary); + font-weight: 600; +} + +#page-lukenya-participants .lm-loading { + padding: 60px 0; + text-align: center; + font-size: 14px; + color: var(--lm-muted); +} + +@media (max-width: 1100px) { + #page-lukenya-participants .lm-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + #page-lukenya-participants .lm-stats { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 900px) { + #page-lukenya-participants .lm-charts { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-participants .lm-panel.is-wide { + grid-column: auto; + } + #page-lukenya-participants .lm-field { + flex: 1 1 140px; + } + #page-lukenya-participants .lm-field select, + #page-lukenya-participants .lm-field input { + width: 100%; + min-width: 0; + } +} + + +/* ==== Participants page =================================================== */ + +#page-lukenya-participants [hidden] { + display: none !important; +} + +#page-lukenya-participants .lm-pp { + position: relative; +} +#page-lukenya-participants .lm-pp__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 14px; +} +#page-lukenya-participants .lm-pp__head h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-pp__head p { + margin: 0; + font-size: 14px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-pp__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +/* Buttons */ +#page-lukenya-participants .lm-btn, +#page-lukenya-participants a.lm-btn { + display: inline-flex; + align-items: center; + gap: 7px; + height: 36px; + padding: 0 14px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius-sm); + background: #fff; + color: var(--lm-ink); + font-size: 13px; + font-weight: 600; + text-decoration: none; + transition: border-color 0.15s ease, color 0.15s ease; +} +#page-lukenya-participants .lm-btn:hover:not(:disabled) { + border-color: var(--lm-primary); + color: var(--lm-primary); +} +#page-lukenya-participants .lm-btn:disabled, +#page-lukenya-participants .lm-cta:disabled { + opacity: 0.5; + cursor: not-allowed; +} +#page-lukenya-participants .lm-btn.is-on { + border-color: var(--lm-primary); + background: #eef3ff; + color: var(--lm-primary); +} +#page-lukenya-participants .lm-cta { + height: 36px; + padding: 0 16px; + font-size: 13px; +} +#page-lukenya-participants .lm-iconbtn { + display: grid; + place-items: center; + width: 34px; + height: 34px; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-iconbtn:hover { + background: #eef2f8; + color: var(--lm-ink); +} + +/* Search */ +#page-lukenya-participants .lm-pp__search { + position: relative; + display: flex; + align-items: center; +} +#page-lukenya-participants .lm-pp__search .lm-ico { + position: absolute; + left: 14px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-search { + width: 100%; + height: 46px; + margin: 0; + padding: 0 16px 0 44px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: #fff; + color: var(--lm-ink); + font-size: 15px; + box-shadow: var(--lm-shadow); +} +#page-lukenya-participants .lm-search:focus { + border-color: var(--lm-primary); + outline: 2px solid rgba(47, 107, 255, 0.2); +} + +/* Clickable stat cards */ +#page-lukenya-participants button.lm-stat { + text-align: left; + cursor: pointer; + transition: border-color 0.15s ease, box-shadow 0.15s ease; +} +#page-lukenya-participants button.lm-stat:hover { + border-color: #c9d6f0; + box-shadow: var(--lm-shadow-hover); +} +#page-lukenya-participants .lm-pp__stats .lm-stat__value { + font-size: 24px; +} + +/* Segment chips */ +#page-lukenya-participants .lm-chips { + display: flex; + flex-wrap: wrap; + gap: 8px; +} +#page-lukenya-participants .lm-chip2 { + padding: 6px 13px; + border: 1px solid var(--lm-line); + border-radius: 999px; + background: #fff; + color: var(--lm-muted); + font-size: 13px; + font-weight: 600; + transition: border-color 0.15s ease, color 0.15s ease; +} +#page-lukenya-participants .lm-chip2:hover:not(:disabled) { + border-color: var(--lm-primary); + color: var(--lm-primary); +} +#page-lukenya-participants .lm-chip2.is-active { + border-color: var(--lm-primary); + background: var(--lm-primary); + color: #fff; +} +#page-lukenya-participants .lm-chip2:disabled { + opacity: 0.45; + cursor: not-allowed; +} + +/* Directory table */ +#page-lukenya-participants .lm-pp__panel.is-loading { + opacity: 0.6; + transition: opacity 0.15s ease; +} +#page-lukenya-participants .lm-people th, +#page-lukenya-participants .lm-people td { + vertical-align: top; + white-space: normal; +} +#page-lukenya-participants .lm-row { + cursor: pointer; +} +#page-lukenya-participants .lm-row:hover td { + background: #f7f9fe; +} +#page-lukenya-participants .lm-row:focus-visible { + outline: 2px solid var(--lm-primary); + outline-offset: -2px; +} +#page-lukenya-participants .lm-person { + display: block; + font-weight: 600; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-sub { + display: block; + font-size: 12px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-flags { + max-width: 190px; +} +#page-lukenya-participants .lm-flags .lm-badge { + margin: 0 4px 4px 0; +} + +/* Badges */ +#page-lukenya-participants .lm-badge { + display: inline-block; + padding: 2px 9px; + border-radius: 999px; + font-size: 11.5px; + font-weight: 600; + line-height: 1.5; + white-space: nowrap; +} +#page-lukenya-participants .lm-badge--green { background: #e1f6ea; color: #17784a; } +#page-lukenya-participants .lm-badge--amber { background: #fff1d6; color: #9a6300; } +#page-lukenya-participants .lm-badge--red { background: #ffe6e8; color: #b3313b; } +#page-lukenya-participants .lm-badge--blue { background: #e5eeff; color: #2452c7; } +#page-lukenya-participants .lm-badge--grey { background: #eef1f7; color: #56637d; } + +/* Pager */ +#page-lukenya-participants .lm-pager { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-top: 12px; + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-pager > div { + display: flex; + align-items: center; + gap: 10px; +} + +/* Profile drawer */ +#page-lukenya-participants .lm-scrim { + position: fixed; + inset: var(--navbar-height, 48px) 0 0 0; + z-index: 30; + background: rgba(13, 28, 57, 0.35); +} +#page-lukenya-participants .lm-drawer { + position: fixed; + top: var(--navbar-height, 48px); + right: 0; + bottom: 0; + z-index: 31; + width: min(460px, 100vw); + padding: 20px 22px 32px; + overflow-y: auto; + background: #fff; + box-shadow: -12px 0 32px rgba(23, 35, 61, 0.18); +} +#page-lukenya-participants .lm-drawer__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; +} +#page-lukenya-participants .lm-drawer h2 { + margin: 0 0 4px; + font-size: 20px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-drawer__head p { + margin: 0 0 8px; + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-drawer__flags .lm-badge { + margin: 0 4px 4px 0; +} +#page-lukenya-participants .lm-drawer__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 8px; +} +#page-lukenya-participants .lm-sec { + padding: 16px 0; + border-top: 1px solid var(--lm-line); +} +#page-lukenya-participants .lm-sec h3 { + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 10px; + font-size: 13px; + font-weight: 700; + letter-spacing: 0.02em; + text-transform: uppercase; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-sec dl { + margin: 0; +} +#page-lukenya-participants .lm-kv { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + gap: 10px; + padding: 4px 0; + font-size: 13.5px; +} +#page-lukenya-participants .lm-kv dt { + font-weight: 500; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-kv dd { + margin: 0; + color: var(--lm-ink); + overflow-wrap: anywhere; +} +#page-lukenya-participants .lm-note { + display: block; + padding: 8px 10px; + border-radius: 8px; + background: #fff8e8; + white-space: pre-wrap; +} +#page-lukenya-participants .lm-warn { + display: flex; + align-items: center; + gap: 6px; + margin: 8px 0 0; + font-size: 12.5px; + color: #a05a00; +} +#page-lukenya-participants .lm-empty--tight { + padding: 10px 0; + text-align: left; +} + +/* Entries */ +#page-lukenya-participants .lm-entry { + margin-bottom: 10px; + padding: 12px 14px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius-sm); + background: #fbfcff; +} +#page-lukenya-participants .lm-entry header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + margin-bottom: 4px; +} +#page-lukenya-participants .lm-entry p { + margin: 0 0 4px; + font-size: 13px; +} +#page-lukenya-participants .lm-entry__result { + font-weight: 600; + color: #17784a; +} + +/* Register form */ +#page-lukenya-participants .lm-register { + display: flex; + flex-direction: column; + gap: 10px; + border-top: 1px solid var(--lm-line); + background: #f7faff; + margin: 0 -22px; + padding: 16px 22px; +} +#page-lukenya-participants .lm-register .lm-field select { + width: 100%; +} +#page-lukenya-participants .lm-price { + margin: 0; + font-size: 22px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-participants .lm-check { + display: flex; + align-items: center; + gap: 8px; + margin: 0; + font-size: 13.5px; +} +#page-lukenya-participants .lm-register__actions { + display: flex; + gap: 8px; +} + +/* Duplicates */ +#page-lukenya-participants .lm-pp__dups { + display: flex; + flex-direction: column; + gap: 14px; +} +#page-lukenya-participants .lm-dups__head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +#page-lukenya-participants .lm-dups__head h2 { + margin: 0 0 4px; + font-size: 18px; +} +#page-lukenya-participants .lm-dups__head p { + margin: 0; + max-width: 620px; + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-participants .lm-dups__members { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +#page-lukenya-participants .lm-dup { + position: relative; + display: flex; + gap: 10px; + margin: 0; + padding: 12px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius-sm); + background: #fff; + cursor: pointer; +} +#page-lukenya-participants .lm-dup.is-keep { + border-color: var(--lm-primary); + background: #f3f7ff; +} +#page-lukenya-participants .lm-dup__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 13px; +} +#page-lukenya-participants .lm-dup__tag { + position: absolute; + top: 10px; + right: 12px; + font-style: normal; + font-size: 11.5px; + font-weight: 700; + color: var(--lm-primary); +} +#page-lukenya-participants .lm-dupgroup__actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 12px; +} +#page-lukenya-participants .lm-merge { + margin-top: 14px; + padding: 14px 16px; + border: 1px solid #f0d9a6; + border-radius: var(--lm-radius-sm); + background: #fffaf0; +} +#page-lukenya-participants .lm-merge h4 { + margin: 0 0 8px; + font-size: 14px; +} +#page-lukenya-participants .lm-merge ul { + margin: 0 0 8px; + padding-left: 18px; + font-size: 13px; +} + +@media (max-width: 900px) { + #page-lukenya-participants .lm-drawer { + width: 100vw; + } + #page-lukenya-participants .lm-kv { + grid-template-columns: 110px minmax(0, 1fr); + } +} diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.js b/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.js new file mode 100644 index 0000000..4af38c0 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.js @@ -0,0 +1,920 @@ +/* Lukenya Marathon - Participants (Desk page). + * Data comes from lukenya_marathon.lukenya_marathon.participants. Styling lives in lukenya_participants.css. + */ +(function () { + const PAGE = "lukenya-participants"; + const API = "lukenya_marathon.lukenya_marathon.participants."; + const PAGE_LENGTH = 25; + + // Feather-style line icons (inner SVG only). + const ICON = { + grid: '', + calendar: '', + users: '', + flag: '', + card: '', + chart: '', + sliders: '', + run: '', + right: '', + alert: '', + download: '', + external: '', + search: '', + x: '', + plus: '', + phone: '', + mail: '', + chat: '', + edit: '', + eye: '', + merge: '', + shield: '', + userplus: '', + }; + + const svg = (name, size) => + ``; + + const esc = (s) => frappe.utils.escape_html(s == null ? "" : String(s)); + + const fmtDate = (value) => { + if (!value) return ""; + const d = new Date(String(value).slice(0, 10) + "T00:00:00"); + return isNaN(d) ? esc(value) : d.toLocaleDateString(undefined, { day: "numeric", month: "short", year: "numeric" }); + }; + + // ---- Configuration ------------------------------------------------------- + + const CHIPS = [ + { key: "", label: __("Everyone") }, + { key: "entered", label: __("In this edition"), edition: true }, + { key: "returning", label: __("Returning") }, + { key: "first_timers", label: __("First-timers") }, + { key: "not_reentered", label: __("Not yet re-entered"), edition: true }, + { key: "unpaid", label: __("Unpaid") }, + { key: "no_bib", label: __("No bib") }, + { key: "minor", label: __("Under 18") }, + { key: "missing_info", label: __("Missing info") }, + { key: "non_resident", label: __("Non-resident") }, + { key: "consent", label: __("Agreed to contact") }, + ]; + + const STATUSES = ["Pending", "Confirmed", "Completed", "Cancelled", "Transferred", "Refunded"]; + const PAYMENTS = ["Unpaid", "Pending", "Paid", "Partially Paid", "Failed", "Refunded"]; + const GENDERS = ["Male", "Female", "Other", "Prefer not to say", "Not given"]; + + const STATUS_TONE = { Confirmed: "green", Completed: "green", Pending: "amber", Cancelled: "grey", Transferred: "grey", Refunded: "grey" }; + const PAYMENT_TONE = { Paid: "green", "Partially Paid": "amber", Unpaid: "amber", Pending: "amber", Failed: "red", Refunded: "grey" }; + const FLAG_LABEL = { minor: __("Under 18"), missing_info: __("Missing info"), no_consent: __("No contact consent"), returning: __("Returning") }; + const FLAG_TONE = { minor: "red", missing_info: "amber", no_consent: "grey", returning: "blue" }; + + const FIELD_LABEL = { + id_number: __("ID number"), date_of_birth: __("date of birth"), gender: __("gender"), nationality: __("nationality"), + email: __("email"), phone: __("phone"), whatsapp: __("WhatsApp"), shirt_size: __("shirt size"), + emergency_contact_name: __("emergency contact"), emergency_contact_phone: __("emergency contact phone"), + guardian_name: __("guardian"), guardian_phone: __("guardian phone"), medical_notes: __("medical notes"), + }; + const fieldLabels = (fields) => fields.map((f) => FIELD_LABEL[f] || f).join(", "); + const who = (p) => `${p.full_name} (${p.code})`; + + const badge = (text, tone) => `${esc(text)}`; + + // ---- Page ---------------------------------------------------------------- + + class LukenyaParticipants { + constructor(wrapper) { + this.wrapper = wrapper; + this.$root = $(wrapper).find(".layout-main-section"); + this.state = { + view: "directory", edition: null, query: "", chip: "", category: "", status: "", payment: "", + gender: "", age_group: "", nationality: "", sort: "recent", page: 1, + }; + this.options = null; + this.summary = null; + this.result = null; + this.loading = true; + this.error = false; + this.detail = null; // { profile, entries, open_edition } + this.detailLoading = false; + this.register = null; // inline register form state + this.dups = null; + this.merge = null; // { keep, remove, plan } + this.req = 0; + this.timer = null; + this.renderShell(); + this.bind(); + } + + show() { + const opts = frappe.route_options; + if (this.options) { + this.refresh(); + this.openFromRoute(opts); + return; + } + frappe.call({ + method: API + "get_options", + callback: (r) => { + this.options = r.message || { editions: [], categories: [], nationalities: [], age_groups: [], default_edition: null, price_tiers: [] }; + if (this.state.edition === null) this.state.edition = this.options.default_edition || ""; + this.renderFilters(); + this.renderChips(); + this.renderStats(); + this.refresh(); + this.openFromRoute(opts); + }, + error: () => this.fail(), + }); + } + + openFromRoute(opts) { + if (opts && opts.participant) { + frappe.route_options = null; + this.openProfile(opts.participant); + } + } + + refresh() { + this.loadSummary(); + this.loadList(); + if (this.state.view === "duplicates") this.loadDuplicates(); + } + + fail() { + this.loading = false; + this.error = true; + this.renderResults(); + } + + // -- data -- + + listArgs() { + const s = this.state; + return { + query: s.query || null, edition: s.edition || null, chip: s.chip || null, + race_category: s.category || null, status: s.status || null, payment: s.payment || null, + gender: s.gender || null, age_group: s.age_group || null, nationality: s.nationality || null, + }; + } + + loadList() { + const token = ++this.req; + this.loading = true; + this.error = false; + this.renderResults(); + frappe.call({ + method: API + "search_participants", + args: Object.assign(this.listArgs(), { sort: this.state.sort, page: this.state.page, page_length: PAGE_LENGTH }), + callback: (r) => { + if (token !== this.req) return; + this.result = r.message || null; + this.loading = false; + this.error = !this.result; + this.renderResults(); + }, + error: () => token === this.req && this.fail(), + }); + } + + loadSummary() { + frappe.call({ + method: API + "get_summary", + args: { edition: this.state.edition || null }, + callback: (r) => { + this.summary = r.message || null; + this.renderStats(); + this.renderDupButton(); + }, + }); + } + + loadDuplicates() { + frappe.call({ + method: API + "find_duplicates", + callback: (r) => { + this.dups = (r.message && r.message.groups) || []; + this.renderDuplicates(); + }, + }); + } + + openProfile(name, reveal) { + this.detailLoading = true; + this.register = null; + if (!reveal) this.detail = null; + this.renderDrawer(); + frappe.call({ + method: API + "get_profile", + args: { name, reveal: reveal ? 1 : 0 }, + callback: (r) => { + this.detail = r.message || null; + this.detailLoading = false; + this.renderDrawer(); + }, + error: () => { + this.detailLoading = false; + this.closeProfile(); + }, + }); + } + + closeProfile() { + this.detail = null; + this.detailLoading = false; + this.register = null; + this.renderDrawer(); + } + + // -- shell -- + + renderShell() { + this.$root.html(` +
+ ${lukenya.nav.render("lukenya-participants")} +
+
+
+

${esc(__("Participants"))}

+

${esc(__("Find a runner, see their history, and register them for a race."))}

+
+
+ + + +
+
+ +
+
+ +
+ +
+ + +
`); + this.renderStats(); + this.renderFilters(); + this.renderChips(); + this.renderResults(); + } + + // -- stats / filters / chips -- + + renderStats() { + const s = this.summary; + const ed = this.editionName(); + const cards = [ + { label: __("Participants"), value: s && s.total, hint: __("On record") }, + { label: ed ? __("In {0}", [ed]) : __("In an edition"), value: s && s.in_edition, hint: ed ? __("Active entries") : __("Choose an edition"), chip: "entered" }, + { label: __("Returning runners"), value: s && s.returning, hint: __("Entered more than one edition"), chip: "returning" }, + { label: __("Ran before, not yet entered"), value: s && s.ran_before, hint: ed ? __("Worth a nudge") : __("Choose an edition"), chip: "not_reentered" }, + { label: __("Missing details"), value: s && s.missing, hint: __("Birth date, gender, nationality or emergency contact"), chip: "missing_info" }, + { label: __("Under 18"), value: s && s.minors, hint: __("Need guardian details"), chip: "minor" }, + ]; + const html = cards.map((c) => { + const value = c.value === null || c.value === undefined ? "–" : esc(Number(c.value).toLocaleString()); + const tag = c.chip ? "button" : "div"; + return `<${tag}${c.chip ? ` type="button" data-chip="${c.chip}"` : ""} class="lm-stat${c.chip ? " is-link" : ""}"> + ${esc(c.label)} + ${value} + ${esc(c.hint)} + `; + }).join(""); + this.$root.find(".lm-pp__stats").html(html); + } + + renderDupButton() { + const n = this.summary ? this.summary.duplicates : null; + this.$root.find(".lm-dupbtn").text(n ? __("Possible duplicates ({0})", [n]) : __("Possible duplicates")); + } + + editionName() { + const e = this.options && this.options.editions.find((x) => x.name === this.state.edition); + return e ? e.edition_name || e.name : ""; + } + + select(key, label, values, current, blank) { + const opts = [``] + .concat(values.map((v) => { + const value = typeof v === "string" ? v : v.value; + const text = typeof v === "string" ? v : v.label; + return ``; + })) + .join(""); + return ``; + } + + renderFilters() { + const o = this.options || { editions: [], categories: [], nationalities: [], age_groups: [] }; + const s = this.state; + const cats = o.categories.filter((c) => !s.edition || c.event_edition === s.edition).map((c) => ({ value: c.name, label: c.category_name })); + this.$root.find(".lm-filters").html( + this.select("edition", __("Edition"), o.editions.map((e) => ({ value: e.name, label: e.edition_name || e.name })), s.edition || "", __("All editions")) + + this.select("category", __("Race category"), cats, s.category, __("All categories")) + + this.select("status", __("Entry status"), STATUSES, s.status, __("Any status")) + + this.select("payment", __("Payment"), PAYMENTS, s.payment, __("Any payment")) + + this.select("gender", __("Gender"), GENDERS, s.gender, __("Any gender")) + + this.select("age_group", __("Age group"), o.age_groups, s.age_group, __("Any age")) + + this.select("nationality", __("Nationality"), o.nationalities.concat([__("Not given")]), s.nationality, __("Any nationality")) + + this.select("sort", __("Sort by"), [{ value: "name", label: __("Name A to Z") }], s.sort === "recent" ? "" : s.sort, __("Recently updated")) + + `` + ); + } + + renderChips() { + const s = this.state; + this.$root.find(".lm-chips").html( + CHIPS.map((c) => { + const off = c.edition && !s.edition; + return ``; + }).join("") + ); + } + + // -- results -- + + renderResults() { + const $box = this.$root.find(".lm-pp__results"); + if (this.state.view === "duplicates") { + $box.attr("hidden", true); + return; + } + $box.removeAttr("hidden"); + if (this.loading && !this.result) { + $box.html(`
${esc(__("Loading participants..."))}
`); + return; + } + if (this.error || !this.result) { + $box.html(``); + return; + } + const r = this.result; + const rows = r.rows.map((p) => this.row(p)).join(""); + const from = r.total ? (r.page - 1) * r.page_length + 1 : 0; + const to = Math.min(r.page * r.page_length, r.total); + const pages = Math.max(Math.ceil(r.total / r.page_length), 1); + $box.html(` +
+ ${ + r.rows.length + ? `
+ + ${rows}
${esc(__("Runner"))}${esc(__("Age / gender"))}${esc(__("Nationality"))}${esc(__("Contact"))}${esc(__("Latest entry"))}${esc(__("Status"))}${esc(__("Bib"))}${esc(__("Flags"))}
` + : `

${esc(__("No participants match these filters."))}

` + } +
+ ${esc(__("{0} to {1} of {2}", [from, to, r.total.toLocaleString()]))} +
+ + ${esc(__("Page {0} of {1}", [r.page, pages]))} + +
+
+
`); + } + + row(p) { + const e = p.entry; + const flags = p.flags + .map((f) => badge(f === "missing_info" ? `${FLAG_LABEL[f]} (${p.missing.length})` : FLAG_LABEL[f], FLAG_TONE[f])) + .join(" "); + const age = p.age === null || p.age === undefined ? "–" : esc(p.age); + return ` + + ${esc(p.full_name)}${esc(p.code)} + ${age}${p.gender ? " · " + esc(p.gender) : ""} + ${esc(p.nationality) || "–"} + ${esc(p.phone) || "–"}${esc(p.email)} + ${e ? `${esc(e.category || "")}${esc(e.edition)}` : `${esc(__("No entry"))}`} + ${e ? badge(e.status, STATUS_TONE[e.status]) + " " + badge(e.payment, PAYMENT_TONE[e.payment]) : ""} + ${e && e.bib ? esc(e.bib) : "–"} + ${flags} + `; + } + + // -- profile drawer -- + + renderDrawer() { + const $d = this.$root.find(".lm-drawer"); + const $scrim = this.$root.find(".lm-scrim"); + if (!this.detail && !this.detailLoading) { + $d.attr("hidden", true).empty(); + $scrim.attr("hidden", true); + return; + } + $d.removeAttr("hidden"); + $scrim.removeAttr("hidden"); + if (!this.detail) { + $d.html(`
${esc(__("Loading profile..."))}
`); + return; + } + const { profile: p, entries, open_edition } = this.detail; + const ageText = p.age === null || p.age === undefined ? "" : __("{0} years", [p.age]); + const meta = [p.code, ageText, p.gender, p.nationality].filter(Boolean).map(esc).join(" · "); + const digits = (p.whatsapp || p.phone || "").replace(/\D/g, ""); + const flags = []; + if (p.minor) flags.push(badge(__("Under 18"), "red")); + if (entries.filter((e) => !["Cancelled", "Refunded", "Transferred"].includes(e.status)).length > 1) flags.push(badge(__("Returning"), "blue")); + flags.push(p.consent ? badge(__("Agreed to be contacted"), "green") : badge(__("No contact consent"), "grey")); + + const line = (label, value, icon) => + `
${esc(label)}
${value || '–'}
`; + const link = (href, text) => (text ? `${esc(text)}` : ""); + + const guardian = p.minor || p.guardian_name || p.guardian_phone + ? `

${esc(__("Guardian"))}

${line(__("Name"), esc(p.guardian_name))}${line(__("Phone"), link("tel:" + p.guardian_phone, p.guardian_phone))}
+ ${p.minor && !p.guardian_name ? `

${svg("alert", 14)} ${esc(__("No guardian details on file for this runner under 18."))}

` : ""}
` + : ""; + + const sensitive = ` +
+

${svg("shield", 15)} ${esc(__("Restricted details"))}

+
+ ${line(__("ID / passport"), p.id_number ? esc(p.id_number) : "")} + ${line(__("Medical notes"), p.revealed ? (p.medical_notes ? `${esc(p.medical_notes)}` : esc(__("None on file"))) : p.has_medical_notes ? esc(__("On file")) : esc(__("None on file")))} +
+ ${ + p.revealed + ? `

${esc(__("Viewing these details was recorded on the runner's timeline."))}

` + : `` + } +
`; + + const entryCards = entries.length + ? entries.map((e) => this.entryCard(e)).join("") + : `

${esc(__("No entries yet."))}

`; + + const canRegister = !!(this.state.edition || open_edition); + $d.html(` +
+
+

${esc(p.full_name)}

+

${meta}

+
${flags.join(" ")}
+
+ +
+
+ + ${canRegister ? `` : ""} + ${p.phone ? `${svg("phone", 15)}${esc(__("Call"))}` : ""} + ${digits ? `${svg("chat", 15)}${esc(__("WhatsApp"))}` : ""} +
+ ${this.registerForm()} +

${esc(__("Contact"))}

+ ${line(__("Phone"), link("tel:" + p.phone, p.phone))} + ${line(__("WhatsApp"), link("tel:" + p.whatsapp, p.whatsapp))} + ${line(__("Email"), link("mailto:" + p.email, p.email))} + ${line(__("Emergency contact"), esc([p.emergency_contact_name, p.emergency_contact_phone].filter(Boolean).join(" \u00b7 ")))} +
+ ${guardian} +

${esc(__("Details"))}

+ ${line(__("Date of birth"), esc(fmtDate(p.date_of_birth)))} + ${line(__("Shirt size"), esc(p.shirt_size))} + ${line(__("On record since"), esc(fmtDate(p.since)))} +
+ ${sensitive} +

${esc(__("Entries"))}

${entryCards}
`); + } + + entryCard(e) { + const money = (v) => { + try { + return format_currency(v || 0, "KES", 0); + } catch (err) { + return "KES " + Number(v || 0).toLocaleString(); + } + }; + const bits = []; + if (e.bib) bits.push(`${esc(__("Bib"))} ${esc(e.bib)}`); + if (e.pack) bits.push(`${esc(__("Pack"))}: ${esc(e.pack)}`); + if (e.check_in) bits.push(`${esc(__("Check-in"))}: ${esc(e.check_in)}`); + const result = e.result + ? `

${esc(e.result.status)}${e.result.time ? " · " + esc(e.result.time) : ""}${e.result.rank ? " · " + esc(__("Overall {0}", [e.result.rank])) : ""}

` + : ""; + return ` +
+
+ ${esc(e.edition)} + ${badge(e.status, STATUS_TONE[e.status])} ${badge(e.payment, PAYMENT_TONE[e.payment])} +
+

${esc(e.category)}${e.tier ? " · " + esc(e.tier) : ""}

+

${esc(money(e.paid))} ${esc(__("paid of"))} ${esc(money(e.amount_due))}${e.balance ? " · " + esc(__("balance")) + " " + esc(money(e.balance)) : ""}

+ ${bits.length ? `

${bits.join(" · ")}

` : ""} + ${result} + +
`; + } + + registerForm() { + const r = this.register; + if (!r) return ""; + const cats = r.categories + .map((c) => { + const full = c.spots_left !== null && c.spots_left <= 0; + const note = c.spots_left === null ? "" : full ? ` (${__("full")})` : ` (${__("{0} left", [c.spots_left])})`; + return ``; + }) + .join(""); + const tiers = this.options.price_tiers + .map((t) => ``) + .join(""); + const p = r.preview; + const price = p ? `

${esc(this.money(p.amount))}${p.already_entered ? ` ${esc(__("Already entered in this edition"))}` : ""}

` : `

 

`; + const disabled = !r.category || !r.indemnity || r.busy || (p && p.already_entered) ? " disabled" : ""; + return ` +
+

${esc(__("Register for {0}", [r.editionName]))}

+ + + ${price} + +
+ + +
+

${esc(__("Creates a pending, unpaid entry. Record payment and confirm it from the registration."))}

+
`; + } + + money(value) { + try { + return format_currency(value || 0, "KES", 0); + } catch (e) { + return "KES " + Number(value || 0).toLocaleString(); + } + } + + startRegister() { + const edition = this.state.edition || (this.detail && this.detail.open_edition); + if (!edition) return; + const name = (this.options.editions.find((e) => e.name === edition) || {}).edition_name || edition; + this.register = { edition, editionName: name, categories: [], category: "", tier: "Standard", indemnity: false, preview: null, busy: false }; + this.renderDrawer(); + frappe.call({ + method: API + "get_edition_categories", + args: { edition }, + callback: (r) => { + if (!this.register) return; + this.register.categories = r.message || []; + this.renderDrawer(); + }, + }); + } + + refreshPreview() { + const r = this.register; + if (!r || !r.category) { + if (r) r.preview = null; + return this.renderDrawer(); + } + frappe.call({ + method: API + "preview_price", + args: { participant: this.detail.profile.name, race_category: r.category, price_tier: r.tier }, + callback: (res) => { + if (!this.register) return; + this.register.preview = res.message || null; + this.renderDrawer(); + }, + }); + } + + confirmRegister() { + const r = this.register; + if (!r || r.busy) return; + r.busy = true; + this.renderDrawer(); + frappe.call({ + method: API + "register_for_edition", + args: { participant: this.detail.profile.name, race_category: r.category, price_tier: r.tier, indemnity_accepted: r.indemnity ? 1 : 0 }, + callback: (res) => { + const out = res.message; + this.register = null; + frappe.show_alert({ message: __("Entry {0} created for {1}", [out.registration, this.money(out.amount_due)]), indicator: "green" }); + this.openProfile(this.detail.profile.name, this.detail.profile.revealed); + this.refresh(); + }, + error: () => { + if (this.register) this.register.busy = false; + this.renderDrawer(); + }, + }); + } + + // -- duplicates -- + + showDuplicates(on) { + this.state.view = on ? "duplicates" : "directory"; + this.merge = null; + this.$root.find(".lm-pp__dups").attr("hidden", !on); + this.$root.find(".lm-pp__search, .lm-pp__stats, .lm-filters, .lm-chips, .lm-pp__results").attr("hidden", on); + this.$root.find(".lm-dupbtn").closest("button").toggleClass("is-on", on); + if (on) { + this.dups = null; + this.renderDuplicates(); + this.loadDuplicates(); + } else { + this.renderResults(); + } + } + + renderDuplicates() { + const $box = this.$root.find(".lm-pp__dups"); + if (this.state.view !== "duplicates") return; + if (!this.dups) { + $box.html(`
${esc(__("Looking for duplicates..."))}
`); + return; + } + const head = `

${esc(__("Possible duplicates"))}

${esc(__("Runners who share a name with a phone number, email or date of birth. Choose which record to keep, preview, then merge."))}

+
`; + if (!this.dups.length) { + $box.html(head + `

${esc(__("No likely duplicates found."))}

`); + return; + } + const groups = this.dups.map((g, gi) => this.dupGroup(g, gi)).join(""); + $box.html(head + groups); + } + + bestKeep(group) { + return group.members.slice().sort((a, b) => b.entries - a.entries || a.name.localeCompare(b.name))[0].name; + } + + dupGroup(g, gi) { + const keep = g.keep || (g.keep = this.bestKeep(g)); + const members = g.members + .map( + (m) => ` + ` + ) + .join(""); + const others = g.members.filter((m) => m.name !== keep); + const buttons = others + .map((m) => ``) + .join(" "); + const m = this.merge && this.merge.group === gi ? this.mergePreview() : ""; + return `
+

${g.reasons.map((r) => esc(r)).join(" · ")}

+
${members}
+
${buttons}
+ ${m} +
`; + } + + mergePreview() { + const { plan } = this.merge; + const fills = Object.keys(plan.fill); + const moves = Object.entries(plan.moves).map(([k, n]) => `${n} ${k.split(".")[0]}`).join(", ") || __("nothing"); + const conflicts = plan.conflicts.length + ? `

${svg("alert", 14)} ${esc(__("Both runners have an active entry in {0}. Cancel one entry first, then merge.", [plan.conflicts.map((c) => c.edition).join(", ")]))}

` + : ""; + return `
+

${esc(__("Merging {0} into {1}", [who(plan.remove), who(plan.keep)]))}

+
    +
  • ${esc(__("Moves: {0}", [moves]))}
  • +
  • ${esc(__("Fills empty fields on the kept record: {0}", [fills.length ? fieldLabels(fills) : __("none")]))}.${plan.medical_notes_merged ? " " + esc(__("Medical notes will be combined.")) : ""}
  • + ${plan.kept_values.length ? `
  • ${esc(__("Different values stay as on the kept record: {0}", [fieldLabels(plan.kept_values.map((k) => k.field))]))}
  • ` : ""} +
  • ${esc(__("The other record is deleted. Frappe keeps deleted documents, so an administrator can restore it."))}
  • +
+ ${conflicts} +
+ + +
+
`; + } + + previewMerge(gi, remove) { + const g = this.dups[gi]; + frappe.call({ + method: API + "merge_participants", + args: { keep: g.keep, remove, dry_run: 1 }, + callback: (r) => { + this.merge = { group: gi, keep: g.keep, remove, plan: r.message }; + this.renderDuplicates(); + }, + }); + } + + runMerge() { + const m = this.merge; + if (!m) return; + frappe.confirm(__("Merge {0} into {1}? This can't be undone from this page.", [who(m.plan.remove), who(m.plan.keep)]), () => { + frappe.call({ + method: API + "merge_participants", + args: { keep: m.keep, remove: m.remove, dry_run: 0 }, + callback: () => { + frappe.show_alert({ message: __("Merged"), indicator: "green" }); + this.merge = null; + this.loadDuplicates(); + this.loadSummary(); + this.loadList(); + }, + }); + }); + } + + // -- export -- + + exportCsv() { + frappe.call({ + method: API + "export_participants", + args: this.listArgs(), + callback: (r) => { + const out = r.message; + if (!out || !out.rows.length) return frappe.show_alert({ message: __("Nothing to export."), indicator: "orange" }); + const cols = [ + ["code", __("Code")], ["name", __("Name")], ["gender", __("Gender")], ["age_group", __("Age group")], + ["nationality", __("Nationality")], ["phone", __("Phone")], ["email", __("Email")], + ["contact_consent", __("Agreed to be contacted")], ["edition", __("Edition")], ["category", __("Category")], + ["entry_status", __("Entry status")], ["payment_status", __("Payment status")], ["bib", __("Bib")], + ]; + const cell = (v) => { + if (v === null || v === undefined) return '""'; + if (typeof v === "number") return String(v); + let text = String(v); + if (/^[=+\-@]/.test(text)) text = "'" + text; // stop spreadsheet formula injection + return '"' + text.replace(/"/g, '""') + '"'; + }; + const lines = [cols.map((c) => cell(c[1])).join(",")].concat(out.rows.map((row) => cols.map((c) => cell(row[c[0]])).join(","))); + const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "lukenya-participants.csv"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + frappe.show_alert({ + message: out.truncated ? __("Exported the first {0} of {1}. Narrow the filters to export the rest.", [out.rows.length, out.total]) : __("Exported {0} people. Contacts are only included for people who agreed to be contacted.", [out.rows.length]), + indicator: out.truncated ? "orange" : "green", + }); + }, + }); + } + + // -- interaction -- + + reset() { + const edition = (this.options && this.options.default_edition) || ""; + Object.assign(this.state, { edition, query: "", chip: "", category: "", status: "", payment: "", gender: "", age_group: "", nationality: "", sort: "recent", page: 1 }); + this.$root.find("[data-search]").val(""); + this.renderFilters(); + this.renderChips(); + this.refresh(); + } + + setChip(key) { + const needsEdition = (CHIPS.find((c) => c.key === key) || {}).edition; + if (needsEdition && !this.state.edition) { + return frappe.show_alert({ message: __("Choose an edition first."), indicator: "orange" }); + } + if (this.state.view === "duplicates") this.showDuplicates(false); + this.state.chip = this.state.chip === key ? "" : key; + this.state.page = 1; + this.renderChips(); + this.loadList(); + } + + bind() { + const $r = this.$root; + + $r.on("input", "[data-search]", (e) => { + const value = e.target.value; + clearTimeout(this.timer); + this.timer = setTimeout(() => { + this.state.query = value.trim(); + this.state.page = 1; + this.loadList(); + }, 250); + }); + + $r.on("change", "[data-filter]", (e) => { + const key = e.target.dataset.filter; + this.state[key] = e.target.value || (key === "sort" ? "recent" : ""); + this.state.page = 1; + if (key === "edition") { + this.state.category = ""; + if (!this.state.edition && ["entered", "not_reentered"].includes(this.state.chip)) this.state.chip = ""; + this.renderFilters(); + this.renderChips(); + this.loadSummary(); + } + this.loadList(); + }); + + $r.on("change", "[data-reg]", (e) => { + const r = this.register; + if (!r) return; + const key = e.target.dataset.reg; + if (key === "indemnity") { + r.indemnity = e.target.checked; + return this.renderDrawer(); + } + r[key] = e.target.value; + r.preview = null; + this.refreshPreview(); + }); + + $r.on("change", "[data-keep]", (e) => { + const gi = Number(e.target.dataset.keep); + this.dups[gi].keep = e.target.value; + this.merge = null; + this.renderDuplicates(); + }); + + $r.on("click", (e) => { + const t = e.target; + + const action = t.closest("[data-action]"); + if (action) { + const a = action.dataset.action; + if (a === "close") return this.closeProfile(); + if (a === "workspace") return frappe.set_route("Workspaces", "Lukenya Marathon"); + if (a === "reset") return this.reset(); + if (a === "new") return frappe.new_doc("Participant"); + if (a === "export") return this.exportCsv(); + if (a === "duplicates") return this.showDuplicates(this.state.view !== "duplicates"); + if (a === "back") return this.showDuplicates(false); + if (a === "edit" && this.detail) return frappe.set_route("Form", "Participant", this.detail.profile.name); + if (a === "reveal" && this.detail) return this.openProfile(this.detail.profile.name, true); + if (a === "register") return this.startRegister(); + if (a === "cancel-register") { + this.register = null; + return this.renderDrawer(); + } + if (a === "confirm-register") return this.confirmRegister(); + if (a === "cancel-merge") { + this.merge = null; + return this.renderDuplicates(); + } + if (a === "confirm-merge") return this.runMerge(); + return; + } + + const page = t.closest("[data-page]"); + if (page && !page.disabled) { + this.state.page = Math.max(this.state.page + Number(page.dataset.page), 1); + return this.loadList(); + } + + const chip = t.closest("[data-chip]"); + if (chip && !chip.disabled) return this.setChip(chip.dataset.chip); + + const merge = t.closest("[data-merge]"); + if (merge) return this.previewMerge(Number(merge.dataset.merge), merge.dataset.remove); + + const doc = t.closest("[data-doc]"); + if (doc) return frappe.set_route("Form", doc.dataset.doc, doc.dataset.name); + + const open = t.closest("[data-open]"); + if (open) { + e.preventDefault(); + return this.openProfile(open.dataset.open); + } + + const nav = t.closest("[data-route]"); + if (nav) return frappe.set_route(nav.dataset.route); + + const list = t.closest("[data-doctype]"); + if (list && !list.classList.contains("is-soon")) frappe.set_route("List", list.dataset.doctype); + }); + + $r.on("keydown", (e) => { + if (e.key === "Enter" && e.target.classList && e.target.classList.contains("lm-row")) this.openProfile(e.target.dataset.open); + }); + + // Escape closes the profile even when focus has dropped to the page body. + $(document).on("keydown.lmparticipants", (e) => { + if (e.key === "Escape" && (this.detail || this.detailLoading) && $(this.wrapper).is(":visible")) this.closeProfile(); + }); + } + } + + // ---- Page hooks ---------------------------------------------------------- + + frappe.pages[PAGE].on_page_load = function (wrapper) { + frappe.ui.make_app_page({ parent: wrapper, title: __("Participants"), single_column: true }); + wrapper.lm_participants = new LukenyaParticipants(wrapper); + }; + + frappe.pages[PAGE].on_page_show = function (wrapper) { + if (wrapper.lm_participants) wrapper.lm_participants.show(); + }; +})(); diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.json b/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.json new file mode 100644 index 0000000..8db981f --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_participants/lukenya_participants.json @@ -0,0 +1,20 @@ +{ + "creation": "2026-09-21 00:00:00.000000", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-09-21 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "lukenya-participants", + "owner": "Administrator", + "page_name": "lukenya-participants", + "roles": [ + { + "role": "System Manager" + } + ], + "standard": "Yes", + "system_page": 0, + "title": "Lukenya Participants" +} \ No newline at end of file diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_payments/__init__.py b/lukenya_marathon/lukenya_marathon/page/lukenya_payments/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.css b/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.css new file mode 100644 index 0000000..b3f1894 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.css @@ -0,0 +1,271 @@ +#page-lukenya-payments, +#page-lukenya-payments .page-body, +#page-lukenya-payments .layout-main-section-wrapper, +#page-lukenya-payments .layout-main-section { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +#page-lukenya-payments .page-head { + display: none !important; +} + +#page-lukenya-payments .lm-payments-layout { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + width: 100%; + min-height: calc(100vh - var(--navbar-height, 48px)); + background: #f5f7fb; +} + +/* Sidebar is shared across every lukenya-* page: + public/css/lukenya_navigation.css */ + +#page-lukenya-payments .lm-payments-main { + width: 100%; + min-width: 0; + padding: 28px 34px 40px; + overflow-x: auto; +} + +#page-lukenya-payments .lm-payments-header, +#page-lukenya-payments .lm-payments-section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +#page-lukenya-payments .lm-payments-header { + margin-bottom: 20px; +} + +#page-lukenya-payments h1, +#page-lukenya-payments h2 { + margin: 0 0 6px; + color: #16233d; +} + +#page-lukenya-payments h1 { + font-size: 28px; +} + +#page-lukenya-payments h2 { + font-size: 18px; +} + +#page-lukenya-payments .lm-payments-eyebrow { + margin: 0 0 5px; + color: #2f6bff; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; +} + +#page-lukenya-payments .lm-payments-muted, +#page-lukenya-payments .lm-payments-section-title p { + margin: 0; + color: #75829a; + font-size: 13px; +} + +#page-lukenya-payments .lm-payments-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +#page-lukenya-payments .lm-payments-card { + border: 1px solid #e2e7f0; + border-radius: 12px; + background: #fff; + box-shadow: 0 5px 18px rgba(30, 48, 80, .05); +} + +#page-lukenya-payments .lm-payments-filters { + display: grid; + grid-template-columns: 1.4fr 1fr 1fr 2fr; + gap: 12px; + margin-bottom: 18px; + padding: 16px; +} + +#page-lukenya-payments .lm-payments-filters label { + display: grid; + gap: 6px; +} + +#page-lukenya-payments .lm-payments-filters label span { + color: #75829a; + font-size: 12px; + font-weight: 600; +} + +#page-lukenya-payments .lm-payments-filters select, +#page-lukenya-payments .lm-payments-filters input { + width: 100%; + height: 34px; + border: 1px solid #d8dfec; + border-radius: 6px; + background: #fff; + padding: 0 10px; +} + +#page-lukenya-payments .lm-payments-kpis { + display: grid; + grid-template-columns: repeat(8, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 18px; +} + +#page-lukenya-payments .lm-payments-kpi { + min-height: 105px; + padding: 15px; +} + +#page-lukenya-payments .lm-payments-kpi span { + display: block; + color: #75829a; + font-size: 12px; +} + +#page-lukenya-payments .lm-payments-kpi strong { + display: block; + margin-top: 10px; + color: #16233d; + font-size: 20px; +} + +#page-lukenya-payments .lm-payments-grid { + display: grid; + grid-template-columns: 1fr 1.4fr; + gap: 16px; + margin-bottom: 18px; +} + +#page-lukenya-payments .lm-payments-panel { + margin-bottom: 18px; + padding: 20px; +} + +#page-lukenya-payments .lm-payments-section-title { + margin-bottom: 14px; +} + +#page-lukenya-payments .lm-payments-table { + width: 100%; + min-width: 760px; + margin-bottom: 0; +} + +#page-lukenya-payments .lm-payments-table td small { + display: block; + color: #75829a; + font-size: 11px; +} + +#page-lukenya-payments .lm-payments-link { + border: 0; + padding: 0; + background: transparent; + color: #2f6bff; + font-weight: 600; +} + +#page-lukenya-payments .lm-payment-status { + display: inline-block; + border-radius: 20px; + padding: 4px 8px; + font-size: 11px; + font-weight: 700; +} + +#page-lukenya-payments .lm-status-paid, +#page-lukenya-payments .lm-status-reconciled { + background: #e8f7ed; + color: #198754; +} + +#page-lukenya-payments .lm-status-pending, +#page-lukenya-payments .lm-status-initiated { + background: #fff4d6; + color: #946200; +} + +#page-lukenya-payments .lm-status-failed { + background: #fde8e8; + color: #b42318; +} + +#page-lukenya-payments .lm-status-refunded { + background: #eee8ff; + color: #6941c6; +} + +#page-lukenya-payments .lm-balance-due { + color: #b42318; + font-weight: 700; +} + +#page-lukenya-payments .lm-credit { + color: #198754; + font-weight: 700; +} + +#page-lukenya-payments .lm-payments-loading, +#page-lukenya-payments .lm-payments-error { + padding: 28px; +} + +#page-lukenya-payments .lm-payments-error { + color: #b42318; +} + +@media (max-width: 1400px) { + #page-lukenya-payments .lm-payments-kpis { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +@media (max-width: 1050px) { + #page-lukenya-payments .lm-payments-filters { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + #page-lukenya-payments .lm-payments-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 760px) { + #page-lukenya-payments .lm-payments-layout { + display: block; + } + + #page-lukenya-payments .lm-side { + display: none; + } + + #page-lukenya-payments .lm-payments-main { + padding: 16px; + } + + #page-lukenya-payments .lm-payments-header { + display: block; + } + + #page-lukenya-payments .lm-payments-actions { + margin-top: 14px; + } + + #page-lukenya-payments .lm-payments-filters, + #page-lukenya-payments .lm-payments-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.js b/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.js new file mode 100644 index 0000000..46a1861 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.js @@ -0,0 +1,595 @@ +frappe.provide("lukenya.payments"); + +(() => { + const PAGE = "lukenya-payments"; + const API = "lukenya_marathon.lukenya_marathon.payments_management."; + + const esc = (value) => + frappe.utils.escape_html(value == null ? "" : String(value)); + + const money = (value, currency = "KES") => + `${currency} ${Number(value || 0).toLocaleString( + undefined, + { + minimumFractionDigits: 2, + maximumFractionDigits: 2 + } + )}`; + + const filtersJson = (filters) => + esc(JSON.stringify(filters)); + + class PaymentsPage { + constructor(wrapper) { + this.wrapper = wrapper; + this.editions = []; + this.edition = ""; + this.filters = { + gateway: "", + status: "", + search: "" + }; + this.metrics = {}; + this.breakdown = { + statuses: [], + gateways: [] + }; + this.transactions = []; + this.exceptions = []; + + this.$root = $(wrapper).find(".layout-main-section"); + + this.renderShell(); + this.bindEvents(); + } + + renderShell() { + this.$root.html(` +
+ ${lukenya.nav.render("lukenya-payments")} + +
+
+
+

+ Financial operations +

+ +

Payments

+ +

+ Monitor collections, outstanding balances, + payment failures and reconciliation. +

+
+ +
+ + + +
+
+ +
+ + + + + + + +
+ +
+
+ Loading payment data... +
+
+
+
+ `); + } + + bindEvents() { + this.$root.on("click", "[data-action='refresh']", () => { + this.loadAll(); + }); + + this.$root.on("click", "[data-new]", (event) => { + frappe.new_doc(event.currentTarget.dataset.new); + }); + + this.$root.on("change", "[data-filter]", (event) => { + const key = event.currentTarget.dataset.filter; + const value = event.currentTarget.value || ""; + + if (key === "edition") { + this.edition = value; + } else { + this.filters[key] = value; + } + + this.loadAll(); + }); + + this.$root.on("keyup", "[data-filter='search']", (event) => { + clearTimeout(this.searchTimer); + + this.searchTimer = setTimeout(() => { + this.filters.search = event.currentTarget.value || ""; + this.loadAll(); + }, 350); + }); + + this.$root.on("click", "[data-route]", (event) => { + const route = event.currentTarget.dataset.route; + + if (route) { + frappe.set_route(route); + } + }); + + this.$root.on("click", "[data-list]", (event) => { + const doctype = event.currentTarget.dataset.list; + const filters = JSON.parse( + event.currentTarget.dataset.filters || "{}" + ); + + frappe.route_options = filters; + frappe.set_route("List", doctype); + }); + + this.$root.on("click", "[data-form]", (event) => { + frappe.set_route( + "Form", + event.currentTarget.dataset.form, + event.currentTarget.dataset.name + ); + }); + } + + show() { + this.loadEditions(); + } + + loadEditions() { + frappe.call({ + method: API + "get_payment_editions", + + callback: (response) => { + this.editions = response.message || []; + + if (!this.edition) { + const open = this.editions.find( + (edition) => edition.status === "Open" + ); + + this.edition = + (open || this.editions[0] || {}).name || ""; + } + + this.renderEditionOptions(); + this.loadAll(); + }, + + error: () => { + this.showError("Unable to load Event Editions."); + } + }); + } + + renderEditionOptions() { + const options = [ + ``, + ...this.editions.map( + (edition) => ` + + ` + ) + ].join(""); + + this.$root.find("[data-filter='edition']").html(options); + } + + loadAll() { + const args = { + event_edition: this.edition || null, + gateway: this.filters.gateway || null, + status: this.filters.status || null, + search: this.filters.search || null, + page: 1, + page_length: 25 + }; + + this.$root.find("[data-content]").html(` +
+ Loading payment data... +
+ `); + + frappe.call({ + method: API + "get_payment_metrics", + args, + callback: (metricsResponse) => { + this.metrics = metricsResponse.message || {}; + + frappe.call({ + method: API + "get_payment_breakdown", + args, + callback: (breakdownResponse) => { + this.breakdown = + breakdownResponse.message || {}; + + frappe.call({ + method: API + "get_payment_transactions", + args, + callback: (transactionResponse) => { + const result = + transactionResponse.message || {}; + + this.transactions = + result.rows || []; + + frappe.call({ + method: API + "get_payment_exceptions", + args: { + event_edition: this.edition || null, + page: 1, + page_length: 10 + }, + callback: (exceptionResponse) => { + this.exceptions = + exceptionResponse.message || []; + + this.render(); + }, + error: () => { + this.exceptions = []; + this.render(); + } + }); + }, + error: () => { + this.showError( + "Unable to load payment transactions." + ); + } + }); + }, + error: () => { + this.showError( + "Unable to load payment breakdown." + ); + } + }); + }, + error: () => { + this.showError("Unable to load payment metrics."); + } + }); + } + + render() { + const m = this.metrics || {}; + const b = this.breakdown || {}; + + const cards = [ + ["Total billed", money(m.total_billed)], + ["Total received", money(m.total_received)], + ["Outstanding", money(m.outstanding)], + ["Net received", money(m.net_received)], + ["Paid registrations", m.paid_registrations || 0], + ["Unpaid registrations", m.unpaid_registrations || 0], + ["Failed payments", m.failed_transactions || 0], + ["Reconciliation issues", m.reconciliation_exceptions || 0] + ]; + + const statusRows = (b.statuses || []).length + ? b.statuses.map((row) => ` + + ${esc(row.status || "-")} + ${esc(row.transaction_count || 0)} + ${money(row.amount, "KES")} + + `).join("") + : ` + + No payment statuses found. + + `; + + const gatewayRows = (b.gateways || []).length + ? b.gateways.map((row) => ` + + ${esc(row.gateway || "-")} + ${esc(row.transaction_count || 0)} + ${money(row.amount, "KES")} + ${esc(row.paid_count || 0)} + ${esc(row.pending_count || 0)} + ${esc(row.failed_count || 0)} + + `).join("") + : ` + + No gateway activity found. + + `; + + const transactionRows = this.transactions.length + ? this.transactions.map((row) => ` + + ${esc(row.payment_date || "-")} + + + + + + ${esc(row.participant_name || "")} + + ${esc(row.edition_name || "-")} + ${esc(row.gateway || "-")} + ${money(row.amount, row.currency || "KES")} + + + ${esc(row.status || "-")} + + + ${esc(row.gateway_transaction_id || "-")} + + `).join("") + : ` + + + No payment transactions found. + + + `; + + const exceptionRows = this.exceptions.length + ? this.exceptions.map((row) => ` + + + + ${esc(row.participant_name || "")} + + ${money(row.amount_due)} + ${money(row.paid)} + + ${money(row.difference)} + + ${esc(row.payment_status || "-")} + + `).join("") + : ` + + + No reconciliation exceptions found. + + + `; + + this.$root.find("[data-content]").html(` +
+ ${cards.map((card) => ` +
+ ${esc(card[0])} + ${esc(card[1])} +
+ `).join("")} +
+ +
+
+
+
+

Payment status summary

+

Transaction totals by status.

+
+
+ +
+ + + + + + + + + + ${statusRows} + +
StatusTransactionsAmount
+
+
+ +
+
+
+

Gateway summary

+

Collections by payment channel.

+
+
+ +
+ + + + + + + + + + + + + ${gatewayRows} + +
GatewayTransactionsAmountPaidPendingFailed
+
+
+
+ +
+
+
+

Payment transactions

+

Recent payment activity for the selected scope.

+
+ + +
+ +
+ + + + + + + + + + + + + + + ${transactionRows} + +
DateTransactionRegistrationEditionGatewayAmountStatusReference
+
+
+ +
+
+
+

Reconciliation exceptions

+

+ Registrations where amount due does not match + confirmed payment transactions. +

+
+ + +
+ +
+ + + + + + + + + + + + ${exceptionRows} + +
RegistrationAmount duePaidDifferenceRegistration payment status
+
+
+ `); + } + + showError(message) { + this.$root.find("[data-content]").html(` +
+ ${esc(message)} +
+ `); + } + } + + frappe.pages[PAGE].on_page_load = (wrapper) => { + frappe.ui.make_app_page({ + parent: wrapper, + title: __("Payments"), + single_column: true + }); + + wrapper.paymentsPage = new PaymentsPage(wrapper); + }; + + frappe.pages[PAGE].on_page_show = (wrapper) => { + if (wrapper.paymentsPage) { + wrapper.paymentsPage.show(); + } + }; +})(); diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.json b/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.json new file mode 100644 index 0000000..dae8df8 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_payments/lukenya_payments.json @@ -0,0 +1,20 @@ +{ + "creation": "2026-09-22 00:00:00.000000", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-09-22 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "lukenya-payments", + "owner": "Administrator", + "page_name": "lukenya-payments", + "roles": [ + {"role": "System Manager"}, + {"role": "Race Director"}, + {"role": "Registration Desk"} + ], + "standard": "Yes", + "system_page": 0, + "title": "Payments" +} diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/__init__.py b/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.css b/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.css new file mode 100644 index 0000000..f2b3a3d --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.css @@ -0,0 +1,275 @@ +/* -------------------------------------------------------------------------- */ +/* Full-width Frappe page container */ +/* -------------------------------------------------------------------------- */ + +#page-lukenya-race-management, +#page-lukenya-race-management .page-body, +#page-lukenya-race-management .layout-main-section-wrapper, +#page-lukenya-race-management .layout-main-section { + width: 100% !important; + max-width: none !important; + margin: 0 !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +#page-lukenya-race-management .page-body { + min-height: calc(100vh - var(--navbar-height, 48px)); +} + +#page-lukenya-race-management .layout-main-section { + min-height: calc(100vh - var(--navbar-height, 48px)); +} + +/* Remove any Frappe horizontal gutters around the custom page */ +#page-lukenya-race-management .container, +#page-lukenya-race-management .container-fluid { + width: 100% !important; + max-width: none !important; + padding-left: 0 !important; + padding-right: 0 !important; +} + +/* -------------------------------------------------------------------------- */ +/* Race Management layout */ +/* -------------------------------------------------------------------------- */ + +#page-lukenya-race-management .page-head { + display: none !important; +} + +#page-lukenya-race-management .lm-rm-layout { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + width: 100%; + min-height: calc(100vh - var(--navbar-height, 48px)); + background: #f5f7fb; +} + +/* -------------------------------------------------------------------------- */ +/* Sidebar — shared across every lukenya-* page: */ +/* public/css/lukenya_navigation.css */ +/* -------------------------------------------------------------------------- */ +/* Main content */ +/* -------------------------------------------------------------------------- */ + +#page-lukenya-race-management .lm-rm-main { + width: 100%; + min-width: 0; + padding: 28px 34px 40px; + overflow-x: auto; +} + +#page-lukenya-race-management .lm-rm-header, +#page-lukenya-race-management .lm-rm-edition, +#page-lukenya-race-management .lm-rm-section-title { + display: flex; + align-items: center; + justify-content: space-between; + gap: 20px; +} + +#page-lukenya-race-management .lm-rm-header { + margin-bottom: 20px; +} + +#page-lukenya-race-management h1, +#page-lukenya-race-management h2 { + margin: 0 0 5px; + color: #16233d; +} + +#page-lukenya-race-management h1 { + font-size: 28px; +} + +#page-lukenya-race-management h2 { + font-size: 18px; +} + +#page-lukenya-race-management .lm-rm-eyebrow { + margin: 0 0 5px; + color: #2f6bff; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .08em; +} + +#page-lukenya-race-management .lm-rm-muted, +#page-lukenya-race-management .lm-rm-edition p, +#page-lukenya-race-management .lm-rm-section-title p { + margin: 0; + color: #75829a; + font-size: 13px; +} + +#page-lukenya-race-management .lm-rm-controls { + display: flex; + align-items: center; + gap: 8px; +} + +#page-lukenya-race-management .lm-rm-controls select { + min-width: 240px; + height: 34px; + border: 1px solid #d8dfec; + border-radius: 6px; + background: #fff; + padding: 0 10px; +} + +/* -------------------------------------------------------------------------- */ +/* Cards */ +/* -------------------------------------------------------------------------- */ + +#page-lukenya-race-management .lm-rm-card { + border: 1px solid #e2e7f0; + border-radius: 12px; + background: #fff; + box-shadow: 0 5px 18px rgba(30, 48, 80, .05); +} + +#page-lukenya-race-management .lm-rm-edition { + padding: 18px 20px; + margin-bottom: 16px; +} + +#page-lukenya-race-management .lm-rm-status { + display: inline-block; + margin-bottom: 7px; + padding: 4px 8px; + border-radius: 20px; + background: #e8f7ed; + color: #198754; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; +} + +#page-lukenya-race-management .lm-rm-kpis { + display: grid; + grid-template-columns: repeat(6, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} + +#page-lukenya-race-management .lm-rm-kpi { + min-height: 112px; + padding: 16px; + text-align: left; + cursor: pointer; +} + +#page-lukenya-race-management .lm-rm-kpi:hover, +#page-lukenya-race-management .lm-rm-shortcut:hover { + border-color: #2f6bff; + transform: translateY(-1px); +} + +#page-lukenya-race-management .lm-rm-kpi span, +#page-lukenya-race-management .lm-rm-kpi small { + display: block; + color: #75829a; + font-size: 12px; +} + +#page-lukenya-race-management .lm-rm-kpi strong { + display: block; + margin: 9px 0 3px; + color: #16233d; + font-size: 26px; +} + +#page-lukenya-race-management .lm-rm-panel { + padding: 20px; +} + +#page-lukenya-race-management .lm-rm-section-title { + margin-bottom: 15px; +} + +#page-lukenya-race-management .lm-rm-table { + width: 100%; + min-width: 820px; + margin: 0; +} + +#page-lukenya-race-management .lm-rm-table td small { + display: block; + color: #75829a; + font-size: 11px; +} + +#page-lukenya-race-management .lm-rm-shortcuts { + display: grid; + grid-template-columns: repeat(5, minmax(0, 1fr)); + gap: 12px; + margin-top: 16px; +} + +#page-lukenya-race-management .lm-rm-shortcut { + display: grid; + gap: 10px; + padding: 16px; + border: 0; + text-align: left; + cursor: pointer; +} + +#page-lukenya-race-management .lm-rm-shortcut span { + color: #2f6bff; + font-size: 12px; +} + +#page-lukenya-race-management .lm-rm-loading, +#page-lukenya-race-management .lm-rm-empty, +#page-lukenya-race-management .lm-rm-error { + padding: 28px; +} + +#page-lukenya-race-management .lm-rm-error { + color: #b42318; +} + +/* -------------------------------------------------------------------------- */ +/* Responsive behavior */ +/* -------------------------------------------------------------------------- */ + +@media (max-width: 1250px) { + #page-lukenya-race-management .lm-rm-kpis { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } + + #page-lukenya-race-management .lm-rm-shortcuts { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (max-width: 760px) { + #page-lukenya-race-management .lm-rm-layout { + display: block; + } + + #page-lukenya-race-management .lm-side { + display: none; + } + + #page-lukenya-race-management .lm-rm-main { + padding: 16px; + } + + #page-lukenya-race-management .lm-rm-header { + display: block; + } + + #page-lukenya-race-management .lm-rm-controls { + margin-top: 14px; + } + + #page-lukenya-race-management .lm-rm-kpis, + #page-lukenya-race-management .lm-rm-shortcuts { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.js b/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.js new file mode 100644 index 0000000..9c184b6 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.js @@ -0,0 +1,170 @@ +frappe.provide("lukenya.race_management"); + +(() => { + const PAGE = "lukenya-race-management"; + const API = "lukenya_marathon.lukenya_marathon.race_management."; + const esc = (value) => frappe.utils.escape_html(value == null ? "" : String(value)); + const pct = (value) => `${Number(value || 0).toFixed(1)}%`; + + class RaceManagement { + constructor(wrapper) { + this.wrapper = wrapper; + this.state = { editions: [], edition: null, summary: null, loading: false }; + this.renderShell(); + this.bind(); + } + + renderShell() { + this.$root = $(this.wrapper).find(".layout-main-section"); + this.$root.html(` +
+ ${lukenya.nav.render("lukenya-race-management")} +
+
+
+

${esc(__("Race day operations"))}

+

${esc(__("Race Management"))}

+

${esc(__("Monitor registration readiness, bibs, race packs, check-in and results."))}

+
+
+ + +
+
+
+
+
+ `); + } + + bind() { + this.$root.on("click", "[data-action=refresh]", () => this.refresh()); + this.$root.on("change", "[data-edition]", (event) => { + this.state.edition = event.target.value || null; + this.refresh(); + }); + this.$root.on("click", "[data-route]", (event) => { + const route = event.currentTarget.dataset.route; + const doctype = event.currentTarget.dataset.doctype; + if (route) frappe.set_route(route); + else if (doctype) frappe.set_route("List", doctype); + }); + this.$root.on("click", "[data-list]", (event) => { + const doctype = event.currentTarget.dataset.list; + const filters = event.currentTarget.dataset.filters || "{}"; + frappe.route_options = JSON.parse(filters); + frappe.set_route("List", doctype); + }); + } + + show() { + this.loadEditions(); + } + + loadEditions() { + frappe.call({ + method: API + "get_event_editions", + callback: (r) => { + this.state.editions = r.message || []; + if (!this.state.edition) { + const open = this.state.editions.find((item) => item.status === "Open"); + this.state.edition = (open || this.state.editions[0] || {}).name || null; + } + this.renderEditionOptions(); + this.refresh(); + }, + error: () => this.showError(__("Unable to load event editions.")), + }); + } + + renderEditionOptions() { + const options = this.state.editions.map((item) => + `` + ).join(""); + this.$root.find("[data-edition]").html(options || ``); + } + + refresh() { + if (!this.state.edition) { + this.renderEmpty(__("Create an Event Edition before using Race Management.")); + return; + } + this.state.loading = true; + this.renderLoading(); + frappe.call({ + method: API + "get_race_management_summary", + args: { event_edition: this.state.edition }, + callback: (r) => { + this.state.loading = false; + this.state.summary = r.message || {}; + this.renderSummary(); + }, + error: () => { + this.state.loading = false; + this.showError(__("Unable to load race management data.")); + }, + }); + } + + renderLoading() { + this.$root.find("[data-content]").html(`
${esc(__("Loading race management data…"))}
`); + } + + renderEmpty(message) { + this.$root.find("[data-content]").html(`
${esc(message)}
`); + } + + showError(message) { + this.$root.find("[data-content]").html(`
${esc(message)}
`); + } + + renderSummary() { + const data = this.state.summary; + const edition = data.edition; + if (!edition) return this.renderEmpty(__("No Event Edition data found.")); + const k = data.kpis || {}; + const filters = JSON.stringify({ event_edition: edition.name }); + const cards = [ + [__("Registrations"), k.registrations, ""], + [__("Paid entries"), k.paid, pct(k.paid_percent)], + [__("Bibs allocated"), k.bibs, pct(k.bibs_percent)], + [__("Packs collected"), k.packs, pct(k.packs_percent)], + [__("Checked in"), k.checked_in, pct(k.checked_in_percent)], + [__("Finishers"), k.finishers, pct(k.finishers_percent)], + ]; + const rows = (data.categories || []).map((row) => ` + + ${esc(row.category_name || row.name)}${esc(row.distance_km || "")} km + ${esc(row.start_time || "—")} + ${esc(row.capacity || "—")} + ${esc(row.registered || 0)} + ${esc(row.paid || 0)} + ${esc(row.bibs || 0)} + ${esc(row.checked_in || 0)} + ${esc(row.finishers || 0)} + + + `).join(""); + this.$root.find("[data-content]").html(` +
+
${esc(edition.status || "")}

${esc(edition.edition_name || edition.name)}

${esc(edition.event_date || "")} · ${esc(edition.venue || "")}

+ +
+
${cards.map((card) => ``).join("")}
+
+

${esc(__("Race categories"))}

${esc(__("Operational progress by category."))}

+
${rows || ``}
${esc(__("Category"))}${esc(__("Start"))}${esc(__("Capacity"))}${esc(__("Registered"))}${esc(__("Paid"))}${esc(__("Bibs"))}${esc(__("Checked in"))}${esc(__("Finished"))}
${esc(__("No race categories found for this edition."))}
+
+
+ ${[["Bib Allocation", __("Bib allocations")], ["Race Pack Issue", __("Race packs")], ["Check-in", __("Check-in")], ["Timing Result", __("Timing results")], ["Payment Transaction", __("Payments")]].map((item) => ``).join("")} +
+ `); + } + } + + frappe.pages[PAGE].on_page_load = (wrapper) => { + frappe.ui.make_app_page({ parent: wrapper, title: __("Race Management"), single_column: true }); + wrapper.race_management = new RaceManagement(wrapper); + }; + frappe.pages[PAGE].on_page_show = (wrapper) => wrapper.race_management && wrapper.race_management.show(); +})(); diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.json b/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.json new file mode 100644 index 0000000..63d9f40 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_race_management/lukenya_race_management.json @@ -0,0 +1,21 @@ +{ + "creation": "2026-09-22 00:00:00.000000", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-09-22 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "lukenya-race-management", + "owner": "Administrator", + "page_name": "lukenya-race-management", + "roles": [ + {"role": "System Manager"}, + {"role": "Race Director"}, + {"role": "Registration Desk"}, + {"role": "Timing Officer"} + ], + "standard": "Yes", + "system_page": 0, + "title": "Race Management" +} diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_reports/__init__.py b/lukenya_marathon/lukenya_marathon/page/lukenya_reports/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.css b/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.css new file mode 100644 index 0000000..d736b51 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.css @@ -0,0 +1,836 @@ +/* Lukenya Marathon reports page. Shares its shell (sidebar, tokens) with the dashboard. + * Everything is scoped to #page-lukenya-reports so nothing leaks into the rest of Desk. + * Change the look from the tokens block below. + */ + +#page-lukenya-reports { + /* Tokens: surface */ + --lm-bg: #f3f6fb; + --lm-card: #ffffff; + --lm-line: #e5eaf3; + --lm-ink: #17233d; + --lm-muted: #66748f; + + /* Tokens: brand */ + --lm-primary: #2f6bff; + --lm-primary-ink: #ffffff; + + /* Tokens: shape */ + --lm-radius: 14px; + --lm-radius-sm: 10px; + --lm-shadow: 0 1px 2px rgba(23, 35, 61, 0.05), 0 6px 18px rgba(23, 35, 61, 0.05); + --lm-shadow-hover: 0 2px 4px rgba(23, 35, 61, 0.07), 0 10px 24px rgba(23, 35, 61, 0.09); + + /* Tokens: hero photo (drop a file at public/images/lukenya-hero.jpg to use it) */ + --lm-hero-image: url("/assets/lukenya_marathon/images/lukenya-hero.jpg"); + + background: var(--lm-bg); + color: var(--lm-ink); +} + +/* Remove Desk's own page header and width limits on this page only. */ +#page-lukenya-reports .page-head { + display: none; +} +#page-lukenya-reports .container, +#page-lukenya-reports .page-body { + max-width: none; + margin: 0; + padding: 0; +} +#page-lukenya-reports .layout-main { + margin: 0; +} +#page-lukenya-reports .layout-main-section-wrapper, +#page-lukenya-reports .layout-main-section { + padding: 0; +} + +/* ---- Shell ---------------------------------------------------------------- */ + +#page-lukenya-reports .lm-app { + display: grid; + grid-template-columns: 248px minmax(0, 1fr); + min-height: calc(100vh - var(--navbar-height, 48px)); +} + +#page-lukenya-reports .lm-app, +#page-lukenya-reports .lm-app * { + box-sizing: border-box; +} + +/* :where() keeps this reset weaker than the component classes below. */ +#page-lukenya-reports :where(.lm-app) button { + font: inherit; + color: inherit; + cursor: pointer; +} + +#page-lukenya-reports .lm-ico { + flex: none; +} + +#page-lukenya-reports .lm-main { + padding: 24px 28px 32px; + min-width: 0; +} + +/* ---- Sidebar --------------------------------------------------------------- + * Now shared across every lukenya-* page: public/css/lukenya_navigation.css + * ---------------------------------------------------------------------- */ + +/* ---- Hero ----------------------------------------------------------------- */ + +#page-lukenya-reports .lm-hero { + position: relative; + display: flex; + align-items: center; + min-height: 140px; + margin-bottom: 18px; + border-radius: var(--lm-radius); + overflow: hidden; + background: var(--lm-card); + border: 1px solid var(--lm-line); + box-shadow: var(--lm-shadow); +} +#page-lukenya-reports .lm-hero__text { + position: relative; + z-index: 1; + padding: 24px 28px; +} +#page-lukenya-reports .lm-hero h1 { + margin: 0 0 6px; + font-size: 26px; + font-weight: 700; + line-height: 1.2; + color: var(--lm-ink); +} +#page-lukenya-reports .lm-hero p { + margin: 0; + font-size: 14px; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-hero__art { + position: absolute; + inset: 0 0 0 42%; + background-image: + linear-gradient(90deg, var(--lm-card) 0%, rgba(255, 255, 255, 0) 45%), + var(--lm-hero-image), + linear-gradient(180deg, #cfe0f5 0%, #e9eef0 45%, #9fb590 46%, #6f8a63 100%); + background-size: cover; + background-position: center; +} +#page-lukenya-reports .lm-hero__tag { + position: absolute; + top: 22px; + right: 32px; + font-family: "Segoe Script", "Bradley Hand", "Snell Roundhand", cursive; + font-size: 20px; + line-height: 1.15; + text-align: right; + color: #0f2a55; + transform: rotate(-6deg); + text-shadow: 0 1px 0 rgba(255, 255, 255, 0.7); +} + +/* ---- KPI row -------------------------------------------------------------- */ + +#page-lukenya-reports .lm-kpis { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 18px; +} +#page-lukenya-reports .lm-kpi { + position: relative; + display: flex; + align-items: center; + gap: 14px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); + text-align: left; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +#page-lukenya-reports .lm-kpi:hover { + box-shadow: var(--lm-shadow-hover); + border-color: #d3dcee; +} +#page-lukenya-reports .lm-kpi__body { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + flex: 1; +} +#page-lukenya-reports .lm-kpi__label { + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-kpi__value { + font-size: 26px; + font-weight: 700; + line-height: 1.15; + color: var(--lm-ink); + white-space: nowrap; +} +#page-lukenya-reports .lm-kpi__go { + position: absolute; + top: 14px; + right: 14px; + color: var(--lm-muted); +} + +#page-lukenya-reports .lm-trend { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + font-weight: 600; +} +#page-lukenya-reports .lm-trend em { + font-style: normal; + font-weight: 400; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-trend.is-up { + color: #1f9d5a; +} +#page-lukenya-reports .lm-trend.is-down { + color: #d0424b; +} +#page-lukenya-reports .lm-trend.is-flat { + color: var(--lm-muted); + font-weight: 400; +} + +/* ---- Icon chips and tones ------------------------------------------------- */ + +#page-lukenya-reports .lm-chip { + display: grid; + place-items: center; + flex: none; + width: 46px; + height: 46px; + border-radius: 999px; + background: var(--tone-bg, #e8f0ff); + color: var(--tone-ink, #2f6bff); +} +#page-lukenya-reports .lm-tone-blue { --tone-bg: #e5eeff; --tone-ink: #2f6bff; } +#page-lukenya-reports .lm-tone-green { --tone-bg: #e1f6ea; --tone-ink: #1f9d5a; } +#page-lukenya-reports .lm-tone-purple { --tone-bg: #ece6ff; --tone-ink: #7451e0; } +#page-lukenya-reports .lm-tone-rose { --tone-bg: #ffe6e8; --tone-ink: #dc4650; } +#page-lukenya-reports .lm-tone-amber { --tone-bg: #fff1d6; --tone-ink: #d98a0b; } +#page-lukenya-reports .lm-tone-teal { --tone-bg: #dcf5f4; --tone-ink: #12928f; } +#page-lukenya-reports .lm-tone-gold { --tone-bg: #fff0d1; --tone-ink: #e08a00; } +#page-lukenya-reports .lm-tone-peach { --tone-bg: #ffeadb; --tone-ink: #ec7a1f; } +#page-lukenya-reports .lm-tone-slate { --tone-bg: #e3ecfb; --tone-ink: #3563c9; } + +/* ---- Operations panel ----------------------------------------------------- */ + +#page-lukenya-reports .lm-ops { + padding: 20px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-reports .lm-ops__head { + display: flex; + align-items: center; + gap: 14px; + margin-bottom: 18px; +} +#page-lukenya-reports .lm-ops__head > div:not(.lm-menu) { + flex: 1; + min-width: 0; +} +#page-lukenya-reports .lm-ops__mark { + display: grid; + place-items: center; + width: 46px; + height: 46px; + border-radius: 12px; + background: #12417a; + color: #fff; +} +#page-lukenya-reports .lm-ops h2 { + margin: 0; + font-size: 18px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-reports .lm-ops__head p { + margin: 2px 0 0; + font-size: 13px; + color: var(--lm-muted); +} + +#page-lukenya-reports .lm-menu { + position: relative; +} +#page-lukenya-reports .lm-quick { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 9px 14px; + border: 1px solid #cfdcf7; + border-radius: var(--lm-radius-sm); + background: #eef3ff; + color: var(--lm-primary); + font-size: 13px; + font-weight: 600; + transition: background-color 0.15s ease; +} +#page-lukenya-reports .lm-quick:hover { + background: #e2ebff; +} +#page-lukenya-reports .lm-menu__list { + position: absolute; + right: 0; + top: calc(100% + 6px); + z-index: 5; + min-width: 200px; + padding: 6px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius-sm); + background: var(--lm-card); + box-shadow: var(--lm-shadow-hover); +} +#page-lukenya-reports .lm-menu__list[hidden] { + display: none; +} +#page-lukenya-reports .lm-menu__item { + display: block; + width: 100%; + padding: 9px 12px; + border: 0; + border-radius: 8px; + background: transparent; + font-size: 13px; + text-align: left; +} +#page-lukenya-reports .lm-menu__item:hover { + background: #f1f5fd; +} + +/* ---- Tiles ---------------------------------------------------------------- */ + +#page-lukenya-reports .lm-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; +} +#page-lukenya-reports .lm-tile { + position: relative; + display: flex; + align-items: flex-start; + gap: 14px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + cursor: pointer; + transition: box-shadow 0.15s ease, border-color 0.15s ease; +} +#page-lukenya-reports .lm-tile:hover { + box-shadow: var(--lm-shadow-hover); + border-color: #d3dcee; +} +#page-lukenya-reports .lm-tile.is-soon { + cursor: default; + background: #fafbfe; +} +#page-lukenya-reports .lm-tile.is-soon:hover { + box-shadow: none; + border-color: var(--lm-line); +} +#page-lukenya-reports .lm-tile.is-soon .lm-chip { + filter: saturate(0.5); + opacity: 0.8; +} +#page-lukenya-reports .lm-tile__body { + flex: 1; + min-width: 0; +} +#page-lukenya-reports .lm-tile h3 { + margin: 0 0 4px; + font-size: 15px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-reports .lm-tile p { + margin: 0 0 10px; + font-size: 12.5px; + line-height: 1.45; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-tile__go { + color: var(--lm-muted); +} +#page-lukenya-reports .lm-tile__foot { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +#page-lukenya-reports .lm-count { + display: inline-block; + min-width: 28px; + padding: 2px 10px; + border-radius: 999px; + background: #e9f0ff; + color: #2a58c9; + font-size: 12px; + font-weight: 700; + text-align: center; +} +#page-lukenya-reports .lm-pill { + padding: 2px 10px; + border: 1px solid var(--lm-line); + border-radius: 999px; + background: #fff; + color: var(--lm-muted); + font-size: 12px; + transition: border-color 0.15s ease, color 0.15s ease; +} +#page-lukenya-reports .lm-pill:hover { + border-color: var(--lm-primary); + color: var(--lm-primary); +} +#page-lukenya-reports .lm-soon { + display: inline-block; + padding: 2px 10px; + border-radius: 999px; + background: #eef1f7; + color: var(--lm-muted); + font-size: 12px; +} + +/* ---- New event bar -------------------------------------------------------- */ + +#page-lukenya-reports .lm-newevent { + display: flex; + align-items: center; + gap: 14px; + margin-top: 16px; + padding: 14px 16px; + border-radius: var(--lm-radius); + background: #edf3ff; +} +#page-lukenya-reports .lm-newevent__icon { + color: var(--lm-primary); +} +#page-lukenya-reports .lm-newevent > div { + flex: 1; + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +#page-lukenya-reports .lm-newevent strong { + font-size: 14px; + color: #1b3f8f; +} +#page-lukenya-reports .lm-newevent > div > span { + font-size: 12.5px; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-cta { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 10px 18px; + border: 0; + border-radius: var(--lm-radius-sm); + background: var(--lm-primary); + color: var(--lm-primary-ink); + font-size: 14px; + font-weight: 600; + box-shadow: 0 6px 14px rgba(47, 107, 255, 0.3); + transition: background-color 0.15s ease; +} +#page-lukenya-reports .lm-cta:hover { + background: #2459e0; +} + +#page-lukenya-reports .lm-notice { + margin-bottom: 18px; + padding: 14px 16px; + border: 1px solid #f3c9cd; + border-radius: var(--lm-radius-sm); + background: #fff1f2; + color: #a3303a; + font-size: 13px; +} + +/* ---- Focus and motion ----------------------------------------------------- */ + +#page-lukenya-reports .lm-app :is(button, .lm-tile):focus-visible { + outline: 2px solid var(--lm-primary); + outline-offset: 2px; +} + +@media (prefers-reduced-motion: reduce) { + #page-lukenya-reports .lm-app * { + transition: none !important; + } +} + +/* ---- Responsive ----------------------------------------------------------- */ + +@media (max-width: 1200px) { + #page-lukenya-reports .lm-kpis { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + #page-lukenya-reports .lm-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 900px) { + #page-lukenya-reports .lm-app { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-reports .lm-main { + padding: 16px; + } +} + +@media (max-width: 640px) { + #page-lukenya-reports .lm-kpis, + #page-lukenya-reports .lm-grid { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-reports .lm-hero__art { + display: none; + } + #page-lukenya-reports .lm-ops__head { + flex-wrap: wrap; + } + #page-lukenya-reports .lm-newevent { + flex-wrap: wrap; + } +} + + +/* ==== Reports page ======================================================== */ + +#page-lukenya-reports .lm-rep { + display: flex; + flex-direction: column; + gap: 16px; +} +#page-lukenya-reports .lm-rep__head h1 { + margin: 0 0 4px; + font-size: 24px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-reports .lm-rep__head p { + margin: 0; + font-size: 14px; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-rep__body { + display: flex; + flex-direction: column; + gap: 16px; + min-width: 0; +} + +/* Filters */ +#page-lukenya-reports .lm-filters { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: 12px; + padding: 14px 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-reports .lm-field { + display: flex; + flex-direction: column; + gap: 4px; + margin: 0; + font-size: 12px; + font-weight: 500; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-field select, +#page-lukenya-reports .lm-field input { + height: 36px; + min-width: 170px; + margin: 0; + padding: 0 10px; + border: 1px solid var(--lm-line); + border-radius: 8px; + background: #fff; + color: var(--lm-ink); + font-size: 13px; + box-shadow: none; +} +#page-lukenya-reports .lm-field select:focus, +#page-lukenya-reports .lm-field input:focus { + border-color: var(--lm-primary); + outline: 2px solid rgba(47, 107, 255, 0.2); +} +#page-lukenya-reports .lm-reset { + height: 36px; + padding: 0 14px; + border: 1px solid var(--lm-line); + border-radius: 8px; + background: #fff; + color: var(--lm-muted); + font-size: 13px; +} +#page-lukenya-reports .lm-reset:hover { + border-color: var(--lm-primary); + color: var(--lm-primary); +} +#page-lukenya-reports .lm-filters__note { + flex-basis: 100%; + margin: 0; + font-size: 12px; + color: var(--lm-muted); +} + +/* Tabs */ +#page-lukenya-reports .lm-tabs { + display: flex; + flex-wrap: wrap; + gap: 4px; + border-bottom: 1px solid var(--lm-line); +} +#page-lukenya-reports .lm-tab { + margin-bottom: -1px; + padding: 10px 14px; + border: 0; + border-bottom: 2px solid transparent; + background: transparent; + color: var(--lm-muted); + font-size: 14px; + font-weight: 600; + transition: color 0.15s ease; +} +#page-lukenya-reports .lm-tab:hover { + color: var(--lm-ink); +} +#page-lukenya-reports .lm-tab.is-active { + border-bottom-color: var(--lm-primary); + color: var(--lm-primary); +} + +/* KPI cards */ +#page-lukenya-reports .lm-stats { + display: grid; + grid-template-columns: repeat(var(--lm-cols, 4), minmax(0, 1fr)); + gap: 14px; +} +#page-lukenya-reports .lm-stat { + display: flex; + flex-direction: column; + gap: 2px; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-reports .lm-stat__label { + font-size: 13px; + color: var(--lm-muted); +} +#page-lukenya-reports .lm-stat__value { + font-size: 26px; + font-weight: 700; + line-height: 1.2; + white-space: nowrap; + color: var(--lm-ink); +} +#page-lukenya-reports .lm-stat__hint { + font-size: 12px; + color: var(--lm-muted); +} + +/* Needs attention */ +#page-lukenya-reports .lm-attn { + padding: 14px 16px; + border: 1px solid #f3dfb8; + border-radius: var(--lm-radius); + background: #fffaf0; +} +#page-lukenya-reports .lm-attn h2 { + margin: 0 0 8px; + font-size: 14px; + font-weight: 700; + color: #8a5a00; +} +#page-lukenya-reports .lm-attn ul { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} +#page-lukenya-reports .lm-attn__item { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 7px 12px; + border: 1px solid #f0d9a6; + border-radius: 999px; + background: #fff; + color: #6b4a00; + font-size: 13px; + transition: border-color 0.15s ease; +} +#page-lukenya-reports .lm-attn__item:hover { + border-color: #d99a1a; +} +#page-lukenya-reports .lm-attn__item.is-static { + cursor: default; +} +#page-lukenya-reports .lm-attn__item.is-static:hover { + border-color: #f0d9a6; +} + +/* Panels, charts and tables */ +#page-lukenya-reports .lm-charts { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} +#page-lukenya-reports .lm-panel { + min-width: 0; + padding: 16px; + border: 1px solid var(--lm-line); + border-radius: var(--lm-radius); + background: var(--lm-card); + box-shadow: var(--lm-shadow); +} +#page-lukenya-reports .lm-panel.is-wide { + grid-column: 1 / -1; +} +#page-lukenya-reports .lm-panel > header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +#page-lukenya-reports .lm-panel h3 { + margin: 0; + font-size: 15px; + font-weight: 700; + color: var(--lm-ink); +} +#page-lukenya-reports .lm-panel__actions { + display: flex; + gap: 14px; +} +#page-lukenya-reports .lm-linkbtn { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 0; + border: 0; + background: transparent; + color: var(--lm-primary); + font-size: 12.5px; + font-weight: 600; +} +#page-lukenya-reports .lm-linkbtn:hover { + text-decoration: underline; +} +#page-lukenya-reports .lm-chart { + min-height: 200px; +} +#page-lukenya-reports .lm-empty { + margin: 0; + padding: 28px 0; + text-align: center; + font-size: 13px; + color: var(--lm-muted); +} + +#page-lukenya-reports .lm-tablewrap { + overflow-x: auto; +} +#page-lukenya-reports .lm-table { + width: 100%; + margin: 0; + border-collapse: collapse; + font-size: 13px; +} +#page-lukenya-reports .lm-table th { + padding: 8px 10px; + border-bottom: 1px solid var(--lm-line); + background: #f8fafd; + color: var(--lm-muted); + font-size: 12px; + font-weight: 600; + text-align: left; + white-space: nowrap; +} +#page-lukenya-reports .lm-table td { + padding: 9px 10px; + border-bottom: 1px solid #eef2f8; + color: var(--lm-ink); + white-space: nowrap; +} +#page-lukenya-reports .lm-table tr:last-child td { + border-bottom: 0; +} +#page-lukenya-reports .lm-table .is-int, +#page-lukenya-reports .lm-table .is-currency, +#page-lukenya-reports .lm-table .is-percent { + text-align: right; + font-variant-numeric: tabular-nums; +} +#page-lukenya-reports .lm-link { + color: var(--lm-primary); + font-weight: 600; +} + +#page-lukenya-reports .lm-loading { + padding: 60px 0; + text-align: center; + font-size: 14px; + color: var(--lm-muted); +} + +@media (max-width: 1100px) { + #page-lukenya-reports .lm-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 560px) { + #page-lukenya-reports .lm-stats { + grid-template-columns: minmax(0, 1fr); + } +} + +@media (max-width: 900px) { + #page-lukenya-reports .lm-charts { + grid-template-columns: minmax(0, 1fr); + } + #page-lukenya-reports .lm-panel.is-wide { + grid-column: auto; + } + #page-lukenya-reports .lm-field { + flex: 1 1 140px; + } + #page-lukenya-reports .lm-field select, + #page-lukenya-reports .lm-field input { + width: 100%; + min-width: 0; + } +} diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.js b/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.js new file mode 100644 index 0000000..659d76d --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.js @@ -0,0 +1,464 @@ +/* Lukenya Marathon - Reports & Analytics (Desk page). + * Data comes from lukenya_marathon.lukenya_marathon.analytics. Styling lives in lukenya_reports.css. + */ +(function () { + const PAGE = "lukenya-reports"; + const API = "lukenya_marathon.lukenya_marathon.analytics."; + const PALETTE = ["#2f6bff", "#1f9d5a", "#e08a00", "#7451e0", "#12928f", "#dc4650", "#ec7a1f", "#3563c9"]; + + // Feather-style line icons (inner SVG only). + const ICON = { + grid: '', + calendar: '', + users: '', + flag: '', + card: '', + chart: '', + sliders: '', + run: '', + right: '', + alert: '', + download: '', + external: '', + }; + + const svg = (name, size) => + ``; + + const esc = (s) => frappe.utils.escape_html(s == null ? "" : String(s)); + + // ---- Configuration ------------------------------------------------------- + + const TABS = [ + { key: "overview", label: __("Overview") }, + { key: "registrations", label: __("Registrations") }, + { key: "payments", label: __("Payments") }, + { key: "race_day", label: __("Race Day") }, + { key: "results", label: __("Results & Prizes") }, + { key: "volunteers", label: __("Volunteers & Impact") }, + ]; + + // What the filters do on each tab. + const NOTES = { + overview: __("Dates filter entries by registration date."), + registrations: __("Dates filter entries by registration date."), + payments: __("Dates filter payments by payment date."), + race_day: __("Dates filter entries by registration date."), + results: __("Results follow the edition and category. Dates are not used here."), + volunteers: __("Dates filter shifts by shift date. Race category is not used here."), + }; + + // ---- Page ---------------------------------------------------------------- + + class LukenyaReports { + constructor(wrapper) { + this.wrapper = wrapper; + this.$root = $(wrapper).find(".layout-main-section"); + this.state = { tab: "overview", edition: null, category: "", from: "", to: "" }; + this.options = null; + this.data = null; + this.error = false; + this.loading = true; + this.req = 0; + this.charts = []; + this.renderShell(); + this.bind(); + } + + show() { + if (this.options) return this.load(); + frappe.call({ + method: API + "get_filter_options", + callback: (r) => { + this.options = r.message || { editions: [], categories: [], default_edition: null }; + if (this.state.edition === null) this.state.edition = this.options.default_edition || ""; + this.renderFilters(); + this.load(); + }, + error: () => this.fail(), + }); + } + + load() { + const token = ++this.req; + this.loading = true; + this.error = false; + this.renderTabs(); + this.renderBody(); + const s = this.state; + frappe.call({ + method: API + "get_analytics", + args: { + tab: s.tab, + event_edition: s.edition || null, + race_category: s.category || null, + from_date: s.from || null, + to_date: s.to || null, + }, + callback: (r) => { + if (token !== this.req) return; // a newer request replaced this one + this.data = r.message || null; + this.loading = false; + this.error = !this.data; + this.renderBody(); + }, + error: () => token === this.req && this.fail(), + }); + } + + fail() { + this.loading = false; + this.error = true; + this.renderBody(); + } + + // -- formatting -- + + money(value) { + const currency = (this.data && this.data.currency) || "KES"; + try { + return format_currency(value || 0, currency, 0); + } catch (e) { + return currency + " " + Number(value || 0).toLocaleString(); + } + } + + format(value, type) { + if (value === null || value === undefined || value === "") return "–"; + if (type === "currency") return esc(this.money(value)); + if (type === "percent") return esc(Number(value).toLocaleString(undefined, { maximumFractionDigits: 1 }) + "%"); + if (type === "int") return esc(Number(value).toLocaleString(undefined, { maximumFractionDigits: 1 })); + return esc(value); + } + + // -- rendering -- + + renderShell() { + this.$root.html(` +
+ ${lukenya.nav.render("lukenya-reports")} +
+
+
+

${esc(__("Reports & Analytics"))}

+

${esc(__("Numbers for entries, money, race day and impact, one edition at a time."))}

+
+
+
+ +
+
+
`); + this.renderFilters(); + this.renderTabs(); + this.renderBody(); + } + + renderFilters() { + const o = this.options || { editions: [], categories: [] }; + const s = this.state; + const editions = [``] + .concat(o.editions.map((e) => ``)) + .join(""); + const cats = [``] + .concat( + o.categories + .filter((c) => !s.edition || c.event_edition === s.edition) + .map((c) => ``) + ) + .join(""); + + this.$root.find(".lm-filters").html(` + + + + + +

${esc(NOTES[s.tab])}

`); + } + + renderTabs() { + const tabs = TABS.map( + (t) => + `` + ).join(""); + this.$root.find(".lm-tabs").html(tabs); + this.$root.find(".lm-filters__note").text(NOTES[this.state.tab]); + } + + renderBody() { + this.destroyCharts(); + const $body = this.$root.find(".lm-rep__body"); + + if (this.loading) { + $body.html(`
${esc(__("Loading figures..."))}
`); + return; + } + if (this.error || !this.data) { + $body.html(``); + return; + } + + const d = this.data; + $body.html(this.kpis(d) + this.attention(d) + this.chartGrid(d) + this.tables(d)); + this.drawCharts(); + } + + kpis(d) { + if (!d.kpis.length) return ""; + const cards = d.kpis + .map( + (k) => ` +
+ ${esc(k.label)} + ${this.format(k.value, k.format)} + ${k.hint ? esc(k.hint) : " "} +
` + ) + .join(""); + const n = d.kpis.length; + const cols = n <= 5 ? n : n === 6 ? 3 : 4; + return `
${cards}
`; + } + + attention(d) { + if (!d.attention.length) return ""; + const items = d.attention + .map((a, i) => { + const actionable = a.report || a.doctype; + return `
  • `; + }) + .join(""); + return `

    ${esc(__("Needs attention"))}

      ${items}
    `; + } + + chartGrid(d) { + if (!d.charts.length) return ""; + let col = 0; + const wide = d.charts.map((c) => { + if (c.wide) { + col = 0; + return true; + } + col = (col + 1) % 2; + return false; + }); + if (col === 1) wide[wide.length - 1] = true; + + const panels = d.charts + .map( + (c, i) => ` +
    +
    +

    ${esc(c.title)}

    + ${c.report ? `` : ""} +
    +
    +
    ` + ) + .join(""); + return `
    ${panels}
    `; + } + + tables(d) { + return d.tables + .map((t) => { + const head = t.columns.map((c) => `${esc(c.label)}`).join(""); + const body = t.rows + .map( + (r) => + "" + + t.columns + .map((c) => { + const v = r[c.field]; + if (c.type === "link" && v) { + return `${esc(v)}`; + } + return `${this.format(v, c.type)}`; + }) + .join("") + + "" + ) + .join(""); + const actions = + `${t.report ? `` : ""}` + + (t.rows.length ? `` : ""); + return ` +
    +

    ${esc(t.title)}

    ${actions}
    + ${ + t.rows.length + ? `
    ${head}${body}
    ` + : `

    ${esc(t.empty)}

    ` + } +
    `; + }) + .join(""); + } + + // -- charts -- + + destroyCharts() { + // Charts listen for window resizes; unbind them before their containers are replaced. + this.charts.forEach((chart) => { + try { + if (chart && typeof chart.destroy === "function") chart.destroy(); + } catch (e) { + /* already gone */ + } + }); + this.charts = []; + } + + drawCharts() { + (this.data.charts || []).forEach((c, i) => { + const el = document.getElementById("lm-chart-" + i); + if (!el) return; + const total = (c.datasets || []).reduce((n, ds) => n + ds.values.reduce((a, b) => a + Math.abs(b || 0), 0), 0); + if (!c.labels.length || !total) { + el.innerHTML = `

    ${esc(__("No data yet."))}

    `; + return; + } + const options = { + data: { labels: c.labels, datasets: c.datasets }, + type: c.type, + height: c.type === "donut" ? 230 : 250, + colors: PALETTE, + barOptions: { spaceRatio: 0.45 }, + lineOptions: { regionFill: 1, hideDots: c.labels.length > 25 ? 1 : 0 }, + axisOptions: { xIsSeries: c.type === "line" ? 1 : 0 }, + }; + if (c.format === "currency") { + options.tooltipOptions = { formatTooltipY: (v) => this.money(v) }; + } + try { + this.charts.push(new frappe.Chart(el, options)); + } catch (e) { + el.innerHTML = `

    ${esc(__("This chart couldn't be drawn."))}

    `; + } + }); + } + + // -- interaction -- + + reportFilters() { + const s = this.state; + const filters = {}; + if (s.edition) filters.event_edition = s.edition; + if (s.category) filters.race_category = s.category; + if (s.from) filters.from_date = s.from; + if (s.to) filters.to_date = s.to; + return filters; + } + + openReport(name) { + frappe.route_options = this.reportFilters(); + frappe.set_route("query-report", name); + } + + exportCsv(id) { + const t = (this.data.tables || []).find((x) => x.id === id); + if (!t) return; + const cell = (v) => { + if (v === null || v === undefined) return '""'; + if (typeof v === "number") return String(v); + let text = String(v); + if (/^[=+\-@]/.test(text)) text = "'" + text; // stop spreadsheet formula injection + return '"' + text.replace(/"/g, '""') + '"'; + }; + const lines = [t.columns.map((c) => cell(c.label)).join(",")].concat( + t.rows.map((r) => t.columns.map((c) => cell(r[c.field])).join(",")) + ); + const blob = new Blob(["\ufeff" + lines.join("\n")], { type: "text/csv;charset=utf-8" }); + const a = document.createElement("a"); + a.href = URL.createObjectURL(blob); + a.download = "lukenya-" + t.id + ".csv"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(a.href); + } + + bind() { + this.$root.on("change", "[data-filter]", (e) => { + const key = e.target.dataset.filter; + this.state[key] = e.target.value; + if (key === "edition") { + this.state.category = ""; + this.renderFilters(); + } + this.load(); + }); + + this.$root.on("click", (e) => { + const t = e.target; + + const tab = t.closest("[data-tab]"); + if (tab) { + this.state.tab = tab.dataset.tab; + this.load(); + return; + } + + const action = t.closest("[data-action]"); + if (action) { + if (action.dataset.action === "workspace") frappe.set_route("Workspaces", "Lukenya Marathon"); + if (action.dataset.action === "reset") { + this.state = { tab: this.state.tab, edition: (this.options && this.options.default_edition) || "", category: "", from: "", to: "" }; + this.renderFilters(); + this.load(); + } + return; + } + + const attn = t.closest("[data-attn]"); + if (attn && this.data) { + const item = this.data.attention[Number(attn.dataset.attn)]; + if (item && item.report) this.openReport(item.report); + else if (item && item.doctype) { + frappe.route_options = item.filters || {}; + frappe.set_route("List", item.doctype); + } + return; + } + + const report = t.closest("[data-report]"); + if (report) return this.openReport(report.dataset.report); + + const exp = t.closest("[data-export]"); + if (exp) return this.exportCsv(exp.dataset.export); + + const doc = t.closest("[data-doc]"); + if (doc) { + e.preventDefault(); + frappe.set_route("Form", doc.dataset.doc, doc.dataset.name); + return; + } + + const nav = t.closest("[data-route]"); + if (nav) return frappe.set_route(nav.dataset.route); + + const list = t.closest("[data-doctype]"); + if (list && !list.classList.contains("is-soon")) frappe.set_route("List", list.dataset.doctype); + }); + } + } + + // ---- Page hooks ---------------------------------------------------------- + + frappe.pages[PAGE].on_page_load = function (wrapper) { + frappe.ui.make_app_page({ + parent: wrapper, + title: __("Reports & Analytics"), + single_column: true, + }); + wrapper.lm_reports = new LukenyaReports(wrapper); + }; + + frappe.pages[PAGE].on_page_show = function (wrapper) { + if (wrapper.lm_reports) wrapper.lm_reports.show(); + }; +})(); diff --git a/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.json b/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.json new file mode 100644 index 0000000..598389b --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/page/lukenya_reports/lukenya_reports.json @@ -0,0 +1,20 @@ +{ + "creation": "2026-09-20 00:00:00.000000", + "docstatus": 0, + "doctype": "Page", + "idx": 0, + "modified": "2026-09-20 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "lukenya-reports", + "owner": "Administrator", + "page_name": "lukenya-reports", + "roles": [ + { + "role": "System Manager" + } + ], + "standard": "Yes", + "system_page": 0, + "title": "Lukenya Reports" +} diff --git a/lukenya_marathon/lukenya_marathon/participant_utils.py b/lukenya_marathon/lukenya_marathon/participant_utils.py new file mode 100644 index 0000000..41046d2 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/participant_utils.py @@ -0,0 +1,94 @@ +"""Small helpers that keep participant records tidy. Used by the Participant controller and the +one-off patch that cleans existing records, so both apply exactly the same rules.""" + +import re + +from frappe.utils import getdate + +# Demonyms and common spellings mapped to one country name, so reports group people correctly. +# "Kenya" must stay "Kenya": the registration pricing treats "kenya" and "kenyan" as resident. +NATIONALITIES = { + "kenyan": "Kenya", "kenya": "Kenya", "ke": "Kenya", + "ugandan": "Uganda", "uganda": "Uganda", + "tanzanian": "Tanzania", "tanzania": "Tanzania", + "rwandan": "Rwanda", "rwanda": "Rwanda", + "burundian": "Burundi", "burundi": "Burundi", + "ethiopian": "Ethiopia", "ethiopia": "Ethiopia", + "somali": "Somalia", "somalia": "Somalia", + "sudanese": "Sudan", "sudan": "Sudan", + "south sudanese": "South Sudan", "south sudan": "South Sudan", + "nigerian": "Nigeria", "nigeria": "Nigeria", + "ghanaian": "Ghana", "ghana": "Ghana", + "south african": "South Africa", "south africa": "South Africa", + "british": "United Kingdom", "uk": "United Kingdom", "united kingdom": "United Kingdom", "english": "United Kingdom", + "american": "United States", "usa": "United States", "us": "United States", "united states": "United States", + "canadian": "Canada", "canada": "Canada", + "australian": "Australia", "australia": "Australia", + "indian": "India", "india": "India", + "chinese": "China", "china": "China", + "german": "Germany", "germany": "Germany", + "french": "France", "france": "France", + "italian": "Italy", "italy": "Italy", + "dutch": "Netherlands", "netherlands": "Netherlands", +} + +MIN_PHONE_DIGITS = 9 +MAX_PHONE_DIGITS = 15 + + +def clean_name(value): + """Trim, collapse spaces, and fix names typed entirely in capitals or lower case.""" + text = re.sub(r"\s+", " ", (value or "").strip()) + return text.title() if text and (text.isupper() or text.islower()) else text + + +def build_full_name(first_name, last_name): + return " ".join(part for part in (clean_name(first_name), clean_name(last_name)) if part) + + +def normalize_nationality(value): + text = re.sub(r"\s+", " ", (value or "").strip()) + if not text: + return "" + return NATIONALITIES.get(text.lower(), text.title() if text.isupper() or text.islower() else text) + + +def normalize_phone(value): + """Kenyan numbers become +254..., other numbers are only tidied. Odd input is left alone.""" + text = (value or "").strip() + if not text: + return "" + digits = re.sub(r"\D", "", text) + if not digits: + return text + if text.startswith("+"): + return "+" + digits + if digits.startswith("00"): + return "+" + digits[2:] + if digits.startswith("254") and len(digits) == 12: + return "+" + digits + if len(digits) == 10 and digits[0] == "0" and digits[1] in "71": + return "+254" + digits[1:] + if len(digits) == 9 and digits[0] in "71": + return "+254" + digits + return digits + + +def phone_is_valid(value): + digits = re.sub(r"\D", "", value or "") + return MIN_PHONE_DIGITS <= len(digits) <= MAX_PHONE_DIGITS + + +def phone_key(value): + """Last nine digits, so 0712 345 678 and +254712345678 count as the same number.""" + digits = re.sub(r"\D", "", value or "") + return digits[-9:] if len(digits) >= 9 else "" + + +def age_on(dob, reference): + """Whole years on the reference date, or None when the date of birth is missing or absurd.""" + if not dob: + return None + dob, reference = getdate(dob), getdate(reference) + age = reference.year - dob.year - ((reference.month, reference.day) < (dob.month, dob.day)) + return age if 0 <= age <= 120 else None diff --git a/lukenya_marathon/lukenya_marathon/participants.py b/lukenya_marathon/lukenya_marathon/participants.py new file mode 100644 index 0000000..941f8c3 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/participants.py @@ -0,0 +1,820 @@ +"""Server side of the Participants page: search, profile, registering, duplicates and merging. + +Rules used here +- An "entry" is a Registration that is not Cancelled, Transferred or Refunded (same as the reports). +- The selected edition is context: it decides which entry shows as the runner's latest entry and what + "not yet re-entered" means. It does not hide runners who have no entry in that edition. +- Sensitive details (full ID number, medical notes) are only returned when the caller asks to reveal + them, and every reveal is written to the participant's timeline. +""" + +import re + +import frappe +from frappe import _ +from frappe.utils import cint, flt, getdate, now_datetime, nowdate + +from lukenya_marathon.lukenya_marathon import analytics as an +from lukenya_marathon.lukenya_marathon.api import _get_price +from lukenya_marathon.lukenya_marathon.participant_utils import age_on, normalize_phone, phone_key + +INACTIVE = an.INACTIVE +UNSETTLED = an.UNSETTLED +CONFIRMED = an.CONFIRMED +BIB_LIVE = an.BIB_LIVE +AGE_BANDS = an.AGE_BANDS +NOT_GIVEN = "Not given" + +PRICE_TIERS = ["Standard", "Early Bird", "Non-resident", "Corporate", "Complimentary"] +CLOSED_EDITIONS = ("Completed", "Cancelled") +MAX_EXPORT_ROWS = 5000 +MAX_PAGE_LENGTH = 100 + +# Chips that only make sense against a chosen edition. +EDITION_CHIPS = ("entered", "not_reentered") +CHIPS = ( + "entered", "returning", "first_timers", "not_reentered", "unpaid", "no_bib", + "minor", "missing_info", "non_resident", "consent", +) + +MISSING_LABELS = { + "date_of_birth": "Date of birth", + "gender": "Gender", + "nationality": "Nationality", + "emergency_contact_name": "Emergency contact", + "emergency_contact_phone": "Emergency contact phone", +} + +# Fields copied from the removed record into the kept one when the kept one is empty. +MERGE_FIELDS = ( + "id_number", "date_of_birth", "gender", "nationality", "email", "phone", "whatsapp", "shirt_size", + "emergency_contact_name", "emergency_contact_phone", "guardian_name", "guardian_phone", +) + + +# ---- helpers ------------------------------------------------------------------------------- + + +def _guard(): + frappe.only_for("System Manager") + + +def _rows(sql, params=None): + return frappe.db.sql(sql, params or {}, as_dict=True) + + +def _one(sql, params=None): + rows = _rows(sql, params) + return rows[0] if rows else frappe._dict() + + +def _mask(value): + value = (value or "").strip() + if not value: + return "" + return "\u2022" * max(len(value) - 3, 3) + value[-3:] + + +def _like(text): + return "%" + re.sub(r"([\\%_])", r"\\\1", text) + "%" + + +def _edition_date(edition): + if not edition: + return getdate(nowdate()) + value = frappe.db.get_value("Event Edition", edition, "event_date") + return getdate(value) if value else getdate(nowdate()) + + +def _band_for(age): + if age is None: + return NOT_GIVEN + for low, high, label in AGE_BANDS: + if low <= age <= high: + return label + return NOT_GIVEN + + +def _band_range(label): + return next(((low, high) for low, high, name in AGE_BANDS if name == label), None) + + +# ---- search -------------------------------------------------------------------------------- + + +def _scope(f): + """WHERE clause and params for participant queries, from the page's filters.""" + f = frappe._dict(f) + where, params = ["1 = 1"], { + "inactive": INACTIVE, + "unsettled": UNSETTLED, + "confirmed": CONFIRMED, + "bib_live": BIB_LIVE, + "age_ref": _edition_date(f.edition), + } + entered_any = "select count(distinct rc.event_edition) from `tabRegistration` rc where rc.participant = p.name and rc.registration_status not in %(inactive)s" + + def exists(extra=None, entry_only=True): + conds = ["rr.participant = p.name"] + if entry_only: + conds.append("rr.registration_status not in %(inactive)s") + if f.edition: + conds.append("rr.event_edition = %(edition)s") + conds += extra or [] + return "exists (select 1 from `tabRegistration` rr where " + " and ".join(conds) + ")" + + if f.edition: + params["edition"] = f.edition + + # text search: every word must be in the name; or the whole text matches a code, contact or bib + query = (f.query or "").strip() + if query: + tokens = query.split()[:5] + name_clause = " and ".join(f"p.full_name like %(t{i})s" for i in range(len(tokens))) + for i, token in enumerate(tokens): + params[f"t{i}"] = _like(token) + params["q_like"] = _like(query) + params["q_exact"] = query + other = [ + "p.participant_code like %(q_like)s", "p.id_number like %(q_like)s", "p.email like %(q_like)s", + "p.phone like %(q_like)s", + "exists (select 1 from `tabBib Allocation` b join `tabRegistration` rb on rb.name = b.registration" + " where rb.participant = p.name and b.bib_number = %(q_exact)s)", + "exists (select 1 from `tabRegistration` rb where rb.participant = p.name and rb.bib_number = %(q_exact)s)", + ] + digits = re.sub(r"\D", "", query) + if len(digits) >= 4: + params["digits_like"] = "%" + digits[-9:] + "%" + other.append("regexp_replace(coalesce(p.phone, ''), '[^0-9]', '') like %(digits_like)s") + other.append("regexp_replace(coalesce(p.whatsapp, ''), '[^0-9]', '') like %(digits_like)s") + where.append("((" + name_clause + ") or " + " or ".join(other) + ")") + + if f.gender: + if f.gender == NOT_GIVEN: + where.append("coalesce(p.gender, '') = ''") + else: + where.append("p.gender = %(gender)s") + params["gender"] = f.gender + + if f.nationality: + if f.nationality == NOT_GIVEN: + where.append("coalesce(p.nationality, '') = ''") + else: + where.append("p.nationality = %(nationality)s") + params["nationality"] = f.nationality + + if f.age_group: + if f.age_group == NOT_GIVEN: + where.append("p.date_of_birth is null") + elif _band_range(f.age_group): + low, high = _band_range(f.age_group) + where.append("p.date_of_birth is not null and timestampdiff(year, p.date_of_birth, %(age_ref)s) between %(age_low)s and %(age_high)s") + params.update(age_low=low, age_high=high) + + # entry filters look at the chosen edition, or any edition when none is chosen + entry_conds = [] + if f.race_category: + entry_conds.append("rr.race_category = %(race_category)s") + params["race_category"] = f.race_category + if f.status: + entry_conds.append("rr.registration_status = %(status)s") + params["status"] = f.status + if f.payment: + entry_conds.append("rr.payment_status = %(payment)s") + params["payment"] = f.payment + if entry_conds: + where.append(exists(entry_conds, entry_only=not f.status)) + + chip = f.chip + if chip in EDITION_CHIPS and not f.edition: + frappe.throw(_("Choose an edition to use this segment.")) + + if chip == "entered": + where.append(exists()) + elif chip == "returning": + where.append(f"({entered_any}) > 1") + elif chip == "first_timers": + where.append(f"({entered_any}) = 1") + elif chip == "not_reentered": + where.append(f"({entered_any}) >= 1 and not " + exists()) + elif chip == "unpaid": + where.append(exists(["rr.payment_status in %(unsettled)s", "coalesce(rr.price_tier, '') != 'Complimentary'"])) + elif chip == "no_bib": + where.append(exists([ + "rr.registration_status in %(confirmed)s", + "not exists (select 1 from `tabBib Allocation` bb where bb.registration = rr.name and bb.status in %(bib_live)s)", + ])) + elif chip == "minor": + where.append("p.date_of_birth is not null and timestampdiff(year, p.date_of_birth, %(age_ref)s) < 18") + elif chip == "missing_info": + where.append(_missing_sql()) + elif chip == "non_resident": + where.append("coalesce(p.nationality, '') != '' and lower(p.nationality) not in ('kenya', 'kenyan')") + elif chip == "consent": + where.append("p.communication_consent = 1") + + return " and ".join(where), params + + +def _missing_sql(): + return ( + "(p.date_of_birth is null or coalesce(p.gender, '') = '' or coalesce(p.nationality, '') = ''" + " or coalesce(p.emergency_contact_name, '') = '' or coalesce(p.emergency_contact_phone, '') = '')" + ) + + +def _latest_entries(names, edition): + """Most recent entry for each participant (in the chosen edition when one is chosen).""" + if not names: + return {} + params = {"names": tuple(names), "inactive": INACTIVE, "bib_live": BIB_LIVE} + edition_sql = "" + if edition: + edition_sql = "and r.event_edition = %(edition)s" + params["edition"] = edition + rows = _rows( + f""" + select r.participant, r.name as registration, r.event_edition, e.edition_name, e.event_date, + r.race_category, c.category_name, r.registration_status, r.payment_status, r.registration_date, + coalesce( + (select b.bib_number from `tabBib Allocation` b + where b.registration = r.name and b.status in %(bib_live)s order by b.creation desc limit 1), + r.bib_number + ) as bib + from `tabRegistration` r + join `tabEvent Edition` e on e.name = r.event_edition + left join `tabRace Category` c on c.name = r.race_category + where r.participant in %(names)s and r.registration_status not in %(inactive)s {edition_sql} + order by e.event_date desc, r.registration_date desc + """, + params, + ) + latest = {} + for row in rows: + latest.setdefault(row.participant, row) + return latest + + +def _decorate(row, latest, ref): + age = age_on(row.date_of_birth, ref) + missing = [ + label for key, label in MISSING_LABELS.items() + if (row.date_of_birth is None if key == "date_of_birth" else not (row.get(key) or "").strip()) + ] + flags = [] + if age is not None and age < 18: + flags.append("minor") + if missing: + flags.append("missing_info") + if not cint(row.communication_consent): + flags.append("no_consent") + if cint(row.editions_entered) > 1: + flags.append("returning") + entry = latest.get(row.name) + return { + "name": row.name, + "code": row.participant_code, + "full_name": row.full_name or row.name, + "gender": row.gender or "", + "age": age, + "age_group": _band_for(age), + "nationality": row.nationality or "", + "phone": row.phone or "", + "email": row.email or "", + "consent": cint(row.communication_consent), + "editions_entered": cint(row.editions_entered), + "missing": missing, + "flags": flags, + "entry": entry and { + "registration": entry.registration, + "edition": entry.edition_name, + "category": entry.category_name, + "status": entry.registration_status, + "payment": entry.payment_status, + "bib": entry.bib or "", + }, + } + + +_ROW_SQL = """ + select p.name, p.participant_code, p.full_name, p.gender, p.date_of_birth, p.nationality, p.phone, + p.email, p.communication_consent, p.emergency_contact_name, p.emergency_contact_phone, + (select count(distinct rc.event_edition) from `tabRegistration` rc + where rc.participant = p.name and rc.registration_status not in %(inactive)s) as editions_entered + from `tabParticipant` p +""" + + +def _search(f, page, page_length): + where, params = _scope(f) + order = "p.full_name asc" if f.sort == "name" else "p.modified desc" + total = cint(_one(f"select count(*) as n from `tabParticipant` p where {where}", params).n) + offset = (max(page, 1) - 1) * page_length + rows = _rows(f"{_ROW_SQL} where {where} order by {order} limit {int(page_length)} offset {int(offset)}", params) + latest = _latest_entries([r.name for r in rows], f.edition) + ref = params["age_ref"] + return total, [_decorate(r, latest, ref) for r in rows] + + +def _filters(**kw): + return frappe._dict({k: (v or None) for k, v in kw.items()}) + + +@frappe.whitelist() +def get_options(): + _guard() + editions = _rows("select name, edition_name, event_date, status from `tabEvent Edition` order by event_date desc") + categories = _rows("select name, category_name, event_edition from `tabRace Category` where active = 1 order by distance_km desc") + nationalities = [ + r.label for r in _rows( + "select nationality as label, count(*) as n from `tabParticipant` " + "where coalesce(nationality, '') != '' group by nationality order by n desc limit 40" + ) + ] + default = next((e.name for e in editions if e.status == "Open"), editions[0].name if editions else None) + return { + "editions": editions, + "categories": categories, + "nationalities": nationalities, + "age_groups": [b[2] for b in AGE_BANDS] + [NOT_GIVEN], + "default_edition": default, + "price_tiers": PRICE_TIERS, + } + + +@frappe.whitelist() +def search_participants( + query=None, edition=None, chip=None, race_category=None, status=None, payment=None, + gender=None, age_group=None, nationality=None, sort="recent", page=1, page_length=25, +): + _guard() + if chip and chip not in CHIPS: + frappe.throw(_("Unknown segment: {0}").format(chip)) + f = _filters( + query=query, edition=edition, chip=chip, race_category=race_category, status=status, + payment=payment, gender=gender, age_group=age_group, nationality=nationality, sort=sort, + ) + page_length = min(max(cint(page_length) or 25, 1), MAX_PAGE_LENGTH) + total, rows = _search(f, cint(page) or 1, page_length) + return {"total": total, "rows": rows, "page": cint(page) or 1, "page_length": page_length} + + +@frappe.whitelist() +def get_summary(edition=None): + _guard() + edition = edition or None + ref = _edition_date(edition) + params = {"inactive": INACTIVE, "edition": edition, "age_ref": ref} + total = cint(_one("select count(*) as n from `tabParticipant`").n) + in_edition = ran_before = None + if edition: + in_edition = cint(_one( + "select count(distinct participant) as n from `tabRegistration` where event_edition = %(edition)s and registration_status not in %(inactive)s", + params, + ).n) + ran_before = cint(_one( + """select count(*) as n from ( + select participant from `tabRegistration` where registration_status not in %(inactive)s + group by participant + having sum(event_edition = %(edition)s) = 0 + ) x""", + params, + ).n) + missing = cint(_one(f"select count(*) as n from `tabParticipant` p where {_missing_sql()}").n) + minors = cint(_one("select count(*) as n from `tabParticipant` p where p.date_of_birth is not null and timestampdiff(year, p.date_of_birth, %(age_ref)s) < 18", params).n) + return { + "total": total, + "in_edition": in_edition, + "returning": an._returning_runners(), + "ran_before": ran_before, + "missing": missing, + "minors": minors, + "duplicates": len(_duplicate_groups()), + } + + +# ---- profile ------------------------------------------------------------------------------- + + +@frappe.whitelist() +def get_profile(name, reveal=0): + _guard() + p = frappe.db.get_value( + "Participant", name, + ["name", "participant_code", "full_name", "first_name", "last_name", "id_number", "date_of_birth", "gender", + "nationality", "email", "phone", "whatsapp", "shirt_size", "emergency_contact_name", + "emergency_contact_phone", "guardian_name", "guardian_phone", "medical_notes", "communication_consent", "creation"], + as_dict=True, + ) + if not p: + frappe.throw(_("Participant {0} was not found.").format(name)) + + revealed = bool(cint(reveal)) + if revealed: + frappe.get_doc("Participant", name).add_comment( + "Info", _("ID number and medical notes were viewed by {0}.").format(frappe.session.user) + ) + + entries = _entries(name) + next_edition = frappe.db.get_value("Event Edition", {"status": "Open"}, "name", order_by="event_date desc") + age = age_on(p.date_of_birth, nowdate()) + profile = { + "name": p.name, + "code": p.participant_code, + "full_name": p.full_name or p.name, + "gender": p.gender or "", + "date_of_birth": p.date_of_birth, + "age": age, + "minor": age is not None and age < 18, + "nationality": p.nationality or "", + "email": p.email or "", + "phone": p.phone or "", + "whatsapp": p.whatsapp or "", + "shirt_size": p.shirt_size or "", + "emergency_contact_name": p.emergency_contact_name or "", + "emergency_contact_phone": p.emergency_contact_phone or "", + "guardian_name": p.guardian_name or "", + "guardian_phone": p.guardian_phone or "", + "consent": cint(p.communication_consent), + "has_medical_notes": bool((p.medical_notes or "").strip()), + "revealed": revealed, + "id_number": (p.id_number or "") if revealed else _mask(p.id_number), + "medical_notes": (p.medical_notes or "") if revealed else "", + "since": p.creation, + } + return {"profile": profile, "entries": entries, "open_edition": next_edition} + + +def _entries(participant): + regs = _rows( + """ + select r.name as registration, r.event_edition, e.edition_name, e.event_date, r.race_category, + c.category_name, r.registration_status, r.payment_status, r.price_tier, r.amount_due, r.registration_date + from `tabRegistration` r + join `tabEvent Edition` e on e.name = r.event_edition + left join `tabRace Category` c on c.name = r.race_category + where r.participant = %(p)s + order by e.event_date desc, r.registration_date desc + """, + {"p": participant}, + ) + if not regs: + return [] + names = tuple(r.registration for r in regs) + params = {"names": names, "paid": an.PAID, "bib_live": BIB_LIVE} + paid = {r.registration: flt(r.paid) for r in _rows("select registration, sum(amount) as paid from `tabPayment Transaction` where registration in %(names)s and status in %(paid)s group by registration", params)} + bibs = {} + for r in _rows("select registration, bib_number from `tabBib Allocation` where registration in %(names)s and status in %(bib_live)s order by creation", params): + bibs[r.registration] = r.bib_number + packs = {r.registration: r.issue_status for r in _rows("select registration, issue_status from `tabRace Pack Issue` where registration in %(names)s order by creation", params)} + checkins = {} + if frappe.db.table_exists("Check-in"): + checkins = {r.registration: r.status for r in _rows("select registration, status from `tabCheck-in` where registration in %(names)s order by creation", params)} + results = { + r.registration: r + for r in _rows( + "select registration, finish_status, overall_rank, coalesce(nullif(chip_time, 0), nullif(gun_time, 0)) as seconds from `tabTiming Result` where registration in %(names)s", + params, + ) + } + out = [] + for r in regs: + result = results.get(r.registration) + out.append({ + "registration": r.registration, + "edition": r.edition_name, + "category": r.category_name or r.race_category, + "status": r.registration_status, + "payment": r.payment_status, + "tier": r.price_tier or "", + "amount_due": flt(r.amount_due), + "paid": paid.get(r.registration, 0.0), + "balance": max(flt(r.amount_due) - paid.get(r.registration, 0.0), 0.0), + "bib": bibs.get(r.registration, ""), + "pack": packs.get(r.registration, ""), + "check_in": checkins.get(r.registration, ""), + "result": result and { + "status": result.finish_status, + "rank": result.overall_rank, + "time": an._fmt_duration(result.seconds) if result.seconds else "", + }, + }) + return out + + +# ---- registering --------------------------------------------------------------------------- + + +def _pricing(participant, race_category, price_tier): + category = frappe.get_doc("Race Category", race_category) + nationality = frappe.db.get_value("Participant", participant, "nationality") or "Kenya" + tier = price_tier if price_tier in PRICE_TIERS else "Standard" + amount = 0.0 if tier == "Complimentary" else flt(_get_price(category, tier, nationality)) + capacity = cint(category.capacity) + taken = frappe.db.count("Registration", {"race_category": category.name, "registration_status": ["in", ["Pending", "Confirmed"]]}) if capacity else 0 + duplicate = frappe.db.exists("Registration", { + "participant": participant, "event_edition": category.event_edition, + "registration_status": ["not in", ["Cancelled", "Refunded"]], + }) + return category, tier, amount, (capacity - taken) if capacity else None, duplicate + + +@frappe.whitelist() +def preview_price(participant, race_category, price_tier="Standard"): + _guard() + category, tier, amount, spots_left, duplicate = _pricing(participant, race_category, price_tier) + return {"amount": amount, "currency": an.CURRENCY, "tier": tier, "spots_left": spots_left, "already_entered": bool(duplicate)} + + +@frappe.whitelist() +def register_for_edition(participant, race_category, price_tier="Standard", indemnity_accepted=0): + """Enter an existing runner in a race, the way the public form would.""" + _guard() + if not cint(indemnity_accepted): + frappe.throw(_("Confirm that the runner has accepted the indemnity.")) + + category, tier, amount, spots_left, duplicate = _pricing(participant, race_category, price_tier) + if not category.active: + frappe.throw(_("This race category is not active.")) + edition_status = frappe.db.get_value("Event Edition", category.event_edition, "status") + if edition_status in CLOSED_EDITIONS: + frappe.throw(_("This edition is {0}, so it can't take new entries.").format(edition_status)) + if duplicate: + frappe.throw(_("This runner already has an active entry for this edition.")) + if spots_left is not None and spots_left <= 0: + frappe.throw(_("This race category is full.")) + + registration = frappe.get_doc({ + "doctype": "Registration", + "participant": participant, + "event_edition": category.event_edition, + "race_category": category.name, + "registration_status": "Pending", + "registration_date": now_datetime(), + "price_tier": tier, + "amount_due": amount, + "payment_status": "Paid" if tier == "Complimentary" else "Unpaid", + "indemnity_accepted": 1, + "consent_timestamp": now_datetime(), + }) + registration.insert() + return {"registration": registration.name, "amount_due": amount, "currency": an.CURRENCY} + + +@frappe.whitelist() +def get_edition_categories(edition): + """Categories open for entry in an edition, with spots left.""" + _guard() + rows = _rows( + """ + select c.name, c.category_name, coalesce(c.capacity, 0) as capacity, + (select count(*) from `tabRegistration` r where r.race_category = c.name + and r.registration_status in ('Pending', 'Confirmed')) as taken + from `tabRace Category` c where c.event_edition = %(e)s and c.active = 1 order by c.distance_km desc + """, + {"e": edition}, + ) + for r in rows: + r["spots_left"] = (cint(r.capacity) - cint(r.taken)) if cint(r.capacity) else None + return rows + + +# ---- duplicates and merging ---------------------------------------------------------------- + + +def _duplicate_groups(): + rows = _rows("select name, participant_code, full_name, first_name, last_name, phone, email, date_of_birth from `tabParticipant`") + parent = {} + + def find(x): + while parent.setdefault(x, x) != x: + parent[x] = parent[parent[x]] + x = parent[x] + return x + + buckets = {} + reasons = {} + for r in rows: + key_name = " ".join(f"{r.first_name or ''} {r.last_name or ''}".lower().split()) + keys = [] + phone = phone_key(r.phone) + email = (r.email or "").strip().lower() + if key_name and phone: + keys.append(("Same name and phone", key_name, phone)) + if key_name and email: + keys.append(("Same name and email", key_name, email)) + if key_name and r.date_of_birth: + keys.append(("Same name and date of birth", key_name, str(r.date_of_birth))) + if phone and r.date_of_birth: + keys.append(("Same phone and date of birth", phone, str(r.date_of_birth))) + for reason, a, b in keys: + buckets.setdefault((reason, a, b), []).append(r.name) + + for (reason, _a, _b), members in buckets.items(): + if len(members) < 2: + continue + for other in members[1:]: + parent[find(other)] = find(members[0]) + for m in members: + reasons.setdefault(m, set()).add(reason) + + groups = {} + for r in rows: + if r.name in reasons: + groups.setdefault(find(r.name), []).append(r) + if not groups: + return [] + + counts = { + r.participant: r + for r in _rows( + "select participant, count(*) as entries, count(distinct event_edition) as editions from `tabRegistration` " + "where registration_status not in %(inactive)s group by participant", + {"inactive": INACTIVE}, + ) + } + out = [] + for members in groups.values(): + names = {m.name for m in members} + out.append({ + "reasons": sorted({reason for m in members for reason in reasons[m.name]}), + "members": [ + { + "name": m.name, + "code": m.participant_code, + "full_name": m.full_name or m.name, + "phone": m.phone or "", + "email": m.email or "", + "date_of_birth": m.date_of_birth, + "entries": cint(counts[m.name].entries) if m.name in counts else 0, + "editions": cint(counts[m.name].editions) if m.name in counts else 0, + } + for m in sorted(members, key=lambda x: x.name) + ], + "names": sorted(names), + }) + out.sort(key=lambda g: (-len(g["members"]), g["members"][0]["full_name"])) + return out + + +@frappe.whitelist() +def find_duplicates(): + _guard() + return {"groups": _duplicate_groups()} + + +def _participant_links(): + """Every (DocType, field) that links to Participant, including custom fields.""" + links = set() + for table in ("tabDocField", "tabCustom Field"): + parent_col = "parent" if table == "tabDocField" else "dt" + for r in _rows(f"select `{parent_col}` as doctype, fieldname from `{table}` where fieldtype = 'Link' and options = 'Participant'"): + links.add((r.doctype, r.fieldname)) + return sorted(links) + + +def _merge_plan(keep, remove): + if keep == remove: + frappe.throw(_("Choose two different participants.")) + fields = ", ".join(f"`{f}`" for f in ("name", "participant_code", "full_name", "medical_notes", *MERGE_FIELDS)) + docs = {r.name: r for r in _rows(f"select {fields} from `tabParticipant` where name in %(n)s", {"n": (keep, remove)})} + if keep not in docs or remove not in docs: + frappe.throw(_("One of these participants no longer exists.")) + k, r = docs[keep], docs[remove] + + fill, kept = {}, [] + for f in MERGE_FIELDS: + kv, rv = k.get(f), r.get(f) + empty_k = kv in (None, "") + if empty_k and rv not in (None, ""): + fill[f] = rv + elif not empty_k and rv not in (None, "") and str(kv).strip().lower() != str(rv).strip().lower(): + kept.append({"field": f, "kept": kv, "dropped": rv}) + notes_k, notes_r = (k.medical_notes or "").strip(), (r.medical_notes or "").strip() + if notes_r and not notes_k: + fill["medical_notes"] = notes_r + elif notes_r and notes_r != notes_k: + fill["medical_notes"] = f"{notes_k}\n\n(From {r.participant_code}) {notes_r}" + + moving = {} + for doctype, fieldname in _participant_links(): + n = cint(_one(f"select count(*) as n from `tab{doctype}` where `{fieldname}` = %(r)s", {"r": remove}).n) + if n: + moving[f"{doctype}.{fieldname}"] = n + + keep_editions = { + x.event_edition for x in _rows( + "select event_edition from `tabRegistration` where participant = %(k)s and registration_status not in ('Cancelled', 'Refunded')", {"k": keep} + ) + } + conflicts = [ + {"edition": x.edition_name, "registration": x.name} + for x in _rows( + """select r.name, e.edition_name, r.event_edition from `tabRegistration` r + join `tabEvent Edition` e on e.name = r.event_edition + where r.participant = %(r)s and r.registration_status not in ('Cancelled', 'Refunded')""", + {"r": remove}, + ) + if x.event_edition in keep_editions + ] + return { + "keep": {"name": k.name, "code": k.participant_code, "full_name": k.full_name}, + "remove": {"name": r.name, "code": r.participant_code, "full_name": r.full_name}, + "fill": {f: v for f, v in fill.items() if f != "medical_notes"}, + "medical_notes_merged": "medical_notes" in fill, + "kept_values": [{"field": x["field"]} for x in kept], # values are not echoed; they may be sensitive + "moves": moving, + "conflicts": conflicts, + "_fill_full": fill, + } + + +@frappe.whitelist() +def merge_participants(keep, remove, dry_run=1): + """Fold one participant into another. Always preview first (dry_run=1); the real merge refuses + when both have an active entry in the same edition.""" + _guard() + plan = _merge_plan(keep, remove) + full_fill = plan.pop("_fill_full") + if cint(dry_run): + plan["dry_run"] = True + return plan + if plan["conflicts"]: + frappe.throw(_("Both runners have an active entry in the same edition. Cancel one entry first, then merge.")) + + for key in plan["moves"]: + doctype, fieldname = key.split(".", 1) + frappe.db.sql(f"update `tab{doctype}` set `{fieldname}` = %(k)s where `{fieldname}` = %(r)s", {"k": keep, "r": remove}) + + # delete first so a unique ID number can move across + frappe.delete_doc("Participant", remove, force=True) + if full_fill: + frappe.db.set_value("Participant", keep, full_fill) + + moved = sum(plan["moves"].values()) + frappe.get_doc("Participant", keep).add_comment( + "Info", + _("Merged in {0} ({1}) by {2}. Moved {3} linked record(s); filled: {4}.").format( + plan["remove"]["full_name"], plan["remove"]["code"], frappe.session.user, moved, + ", ".join(sorted(full_fill)) or _("nothing"), + ), + ) + plan["dry_run"] = False + plan["merged"] = True + return plan + + +# ---- export -------------------------------------------------------------------------------- + + +@frappe.whitelist() +def export_participants( + query=None, edition=None, chip=None, race_category=None, status=None, payment=None, + gender=None, age_group=None, nationality=None, +): + """Rows for a CSV. Contact details are only included for people who agreed to be contacted; + ID numbers, medical notes and emergency contacts are never included.""" + _guard() + if chip and chip not in CHIPS: + frappe.throw(_("Unknown segment: {0}").format(chip)) + f = _filters( + query=query, edition=edition, chip=chip, race_category=race_category, status=status, + payment=payment, gender=gender, age_group=age_group, nationality=nationality, sort="name", + ) + total, rows = _search(f, 1, MAX_EXPORT_ROWS) + out = [] + for r in rows: + entry = r["entry"] or {} + out.append({ + "code": r["code"], + "name": r["full_name"], + "gender": r["gender"], + "age_group": r["age_group"], + "nationality": r["nationality"], + "phone": r["phone"] if r["consent"] else "", + "email": r["email"] if r["consent"] else "", + "contact_consent": _("Yes") if r["consent"] else _("No"), + "edition": entry.get("edition", ""), + "category": entry.get("category", ""), + "entry_status": entry.get("status", ""), + "payment_status": entry.get("payment", ""), + "bib": entry.get("bib", ""), + }) + _log_export(f, len(out)) + return {"rows": out, "total": total, "truncated": total > len(out)} + + +def _log_export(filters, count): + """Leave a trace of who exported personal data. A failure here must not block the export.""" + try: + frappe.get_doc({ + "doctype": "Access Log", + "user": frappe.session.user, + "export_from": "Participant", + "file_type": "CSV", + "method": "Export", + "filters": frappe.as_json({k: v for k, v in filters.items() if v}), + "columns": str(count) + " rows", + }).insert(ignore_permissions=True) + except Exception: + frappe.log_error(title="Participant export was not logged") diff --git a/lukenya_marathon/lukenya_marathon/payments_management.py b/lukenya_marathon/lukenya_marathon/payments_management.py new file mode 100644 index 0000000..dea85c0 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/payments_management.py @@ -0,0 +1,448 @@ +import frappe +from frappe import _ +from frappe.utils import flt + + +INACTIVE_REGISTRATION_STATUSES = ( + "Cancelled", + "Transferred", + "Refunded", +) + +CONFIRMED_PAYMENT_STATUSES = ( + "Paid", + "Reconciled", +) + +REFUND_STATUS = "Refunded" + + +def _edition_filter(alias, event_edition): + if event_edition: + return f" and {alias}.event_edition = %(event_edition)s" + return "" + + +def _date_filter(alias, from_date=None, to_date=None): + filters = "" + + if from_date: + filters += f" and date({alias}.payment_date) >= %(from_date)s" + + if to_date: + filters += f" and date({alias}.payment_date) <= %(to_date)s" + + return filters + + +def _payment_params(event_edition=None, from_date=None, to_date=None): + return { + "event_edition": event_edition, + "from_date": from_date, + "to_date": to_date, + "inactive": INACTIVE_REGISTRATION_STATUSES, + "confirmed": CONFIRMED_PAYMENT_STATUSES, + } + + +def _single_value(sql, params): + rows = frappe.db.sql(sql, params, as_dict=True) + if not rows: + return 0 + + return rows[0].value or 0 + + +@frappe.whitelist() +def get_payment_editions(): + return frappe.get_all( + "Event Edition", + fields=[ + "name", + "edition_name", + "edition_code", + "event_date", + "status", + ], + order_by="event_date desc, modified desc", + limit_page_length=100, + ) + + +@frappe.whitelist() +def get_payment_metrics( + event_edition=None, + from_date=None, + to_date=None, + gateway=None, + status=None, +): + params = _payment_params(event_edition, from_date, to_date) + + registration_scope = """ + r.registration_status not in %(inactive)s + """ + + if event_edition: + registration_scope += " and r.event_edition = %(event_edition)s" + + billed = _single_value( + f""" + select coalesce(sum(r.amount_due), 0) as value + from `tabRegistration` r + where {registration_scope} + """, + params, + ) + + transaction_scope = """ + r.registration_status not in %(inactive)s + """ + + if event_edition: + transaction_scope += " and r.event_edition = %(event_edition)s" + + transaction_scope += _date_filter("p", from_date, to_date) + + if gateway: + transaction_scope += " and p.gateway = %(gateway)s" + params["gateway"] = gateway + + if status: + transaction_scope += " and p.status = %(status)s" + params["status"] = status + + received = _single_value( + f""" + select coalesce(sum(p.amount), 0) as value + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + where {transaction_scope} + and p.status in %(confirmed)s + """, + params, + ) + + refunded = _single_value( + f""" + select coalesce(sum(p.amount), 0) as value + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + where {transaction_scope} + and p.status = 'Refunded' + """, + params, + ) + + paid_registrations = _single_value( + f""" + select count(*) as value + from `tabRegistration` r + where {registration_scope} + and r.payment_status = 'Paid' + """, + params, + ) + + unpaid_registrations = _single_value( + f""" + select count(*) as value + from `tabRegistration` r + where {registration_scope} + and r.payment_status in ( + 'Unpaid', + 'Pending', + 'Partially Paid', + 'Failed' + ) + """, + params, + ) + + pending_transactions = _single_value( + f""" + select count(*) as value + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + where {transaction_scope} + and p.status = 'Pending' + """, + params, + ) + + failed_transactions = _single_value( + f""" + select count(*) as value + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + where {transaction_scope} + and p.status = 'Failed' + """, + params, + ) + + refunded_transactions = _single_value( + f""" + select count(*) as value + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + where {transaction_scope} + and p.status = 'Refunded' + """, + params, + ) + + reconciliation_exceptions = _single_value( + f""" + select count(*) as value + from `tabRegistration` r + left join ( + select registration, sum(amount) as paid + from `tabPayment Transaction` + where status in %(confirmed)s + group by registration + ) p on p.registration = r.name + where {registration_scope} + and abs(coalesce(r.amount_due, 0) - coalesce(p.paid, 0)) > 0.01 + """, + params, + ) + + total_received = flt(received) + total_refunded = flt(refunded) + + return { + "total_billed": flt(billed), + "total_received": total_received, + "refunded_amount": total_refunded, + "net_received": total_received - total_refunded, + "outstanding": max(flt(billed) - total_received, 0), + "paid_registrations": int(paid_registrations), + "unpaid_registrations": int(unpaid_registrations), + "pending_transactions": int(pending_transactions), + "failed_transactions": int(failed_transactions), + "refunded_transactions": int(refunded_transactions), + "reconciliation_exceptions": int(reconciliation_exceptions), + } + + +@frappe.whitelist() +def get_payment_breakdown( + event_edition=None, + from_date=None, + to_date=None, +): + params = _payment_params(event_edition, from_date, to_date) + + edition_sql = "" + if event_edition: + edition_sql = " and r.event_edition = %(event_edition)s" + + date_sql = _date_filter("p", from_date, to_date) + + statuses = frappe.db.sql( + f""" + select + p.status, + count(*) as transaction_count, + coalesce(sum(p.amount), 0) as amount + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + where r.registration_status not in %(inactive)s + {edition_sql} + {date_sql} + group by p.status + order by p.status + """, + params, + as_dict=True, + ) + + gateways = frappe.db.sql( + f""" + select + p.gateway, + count(*) as transaction_count, + coalesce(sum(p.amount), 0) as amount, + sum(case when p.status in %(confirmed)s then 1 else 0 end) as paid_count, + sum(case when p.status = 'Pending' then 1 else 0 end) as pending_count, + sum(case when p.status = 'Failed' then 1 else 0 end) as failed_count + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + where r.registration_status not in %(inactive)s + {edition_sql} + {date_sql} + group by p.gateway + order by amount desc + """, + params, + as_dict=True, + ) + + return { + "statuses": statuses, + "gateways": gateways, + } + + +@frappe.whitelist() +def get_payment_transactions( + event_edition=None, + from_date=None, + to_date=None, + gateway=None, + status=None, + search=None, + page=1, + page_length=25, +): + page = max(int(page or 1), 1) + page_length = min(max(int(page_length or 25), 1), 100) + offset = (page - 1) * page_length + + params = _payment_params(event_edition, from_date, to_date) + + conditions = [ + "r.registration_status not in %(inactive)s", + ] + + if event_edition: + conditions.append("r.event_edition = %(event_edition)s") + + if from_date: + conditions.append("date(p.payment_date) >= %(from_date)s") + + if to_date: + conditions.append("date(p.payment_date) <= %(to_date)s") + + if gateway: + conditions.append("p.gateway = %(gateway)s") + params["gateway"] = gateway + + if status: + conditions.append("p.status = %(status)s") + params["status"] = status + + if search: + conditions.append( + """ + ( + p.name like %(search)s + or p.gateway_transaction_id like %(search)s + or p.registration like %(search)s + or part.full_name like %(search)s + ) + """ + ) + params["search"] = f"%{search}%" + + where_sql = " and ".join(conditions) + + rows = frappe.db.sql( + f""" + select + p.name, + p.registration, + p.gateway, + p.gateway_transaction_id, + p.amount, + p.currency, + p.status, + p.payment_date, + p.failure_reason, + r.event_edition, + r.race_category, + r.amount_due, + r.payment_status, + part.full_name as participant_name, + e.edition_name, + c.category_name + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + left join `tabParticipant` part on part.name = r.participant + left join `tabEvent Edition` e on e.name = r.event_edition + left join `tabRace Category` c on c.name = r.race_category + where {where_sql} + order by p.payment_date desc, p.modified desc + limit %(limit)s offset %(offset)s + """, + { + **params, + "limit": page_length, + "offset": offset, + }, + as_dict=True, + ) + + total = _single_value( + f""" + select count(*) as value + from `tabPayment Transaction` p + join `tabRegistration` r on r.name = p.registration + left join `tabParticipant` part on part.name = r.participant + where {where_sql} + """, + params, + ) + + return { + "rows": rows, + "total": int(total), + "page": page, + "page_length": page_length, + } + + +@frappe.whitelist() +def get_payment_exceptions( + event_edition=None, + page=1, + page_length=25, +): + page = max(int(page or 1), 1) + page_length = min(max(int(page_length or 25), 1), 100) + offset = (page - 1) * page_length + + params = { + "event_edition": event_edition, + "inactive": INACTIVE_REGISTRATION_STATUSES, + "confirmed": CONFIRMED_PAYMENT_STATUSES, + "limit": page_length, + "offset": offset, + } + + edition_sql = "" + if event_edition: + edition_sql = " and r.event_edition = %(event_edition)s" + + rows = frappe.db.sql( + f""" + select + r.name as registration, + r.event_edition, + r.amount_due, + coalesce(pay.paid, 0) as paid, + (coalesce(r.amount_due, 0) - coalesce(pay.paid, 0)) as difference, + r.payment_status, + part.full_name as participant_name, + e.edition_name + from `tabRegistration` r + left join ( + select registration, sum(amount) as paid + from `tabPayment Transaction` + where status in %(confirmed)s + group by registration + ) pay on pay.registration = r.name + left join `tabParticipant` part on part.name = r.participant + left join `tabEvent Edition` e on e.name = r.event_edition + where r.registration_status not in %(inactive)s + {edition_sql} + and abs(coalesce(r.amount_due, 0) - coalesce(pay.paid, 0)) > 0.01 + order by abs(coalesce(r.amount_due, 0) - coalesce(pay.paid, 0)) desc + limit %(limit)s offset %(offset)s + """, + params, + as_dict=True, + ) + + return rows diff --git a/lukenya_marathon/lukenya_marathon/public/css/lukenya_navigation.css b/lukenya_marathon/lukenya_marathon/public/css/lukenya_navigation.css new file mode 100644 index 0000000..29203e8 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/public/css/lukenya_navigation.css @@ -0,0 +1,61 @@ +.lm-shared-nav { + display: flex !important; + flex-direction: column !important; + gap: 4px !important; +} + +.lm-shared-nav__item { + display: flex !important; + align-items: center !important; + width: 100% !important; + min-height: 42px !important; + gap: 11px !important; + padding: 10px 12px !important; + border: 0 !important; + border-radius: 8px !important; + background: transparent !important; + color: inherit !important; + font: inherit !important; + font-size: 14px !important; + font-weight: 500 !important; + line-height: 1.2 !important; + text-align: left !important; + cursor: pointer !important; +} + +.lm-shared-nav__item:hover { + background: rgba(255, 255, 255, .08) !important; + color: #fff !important; +} + +.lm-shared-nav__item.is-active { + background: #2f6bff !important; + color: #fff !important; + box-shadow: 0 6px 16px rgba(47, 107, 255, .30) !important; +} + +.lm-shared-nav__icon { + display: inline-flex !important; + align-items: center !important; + justify-content: center !important; + width: 18px !important; + height: 18px !important; + flex: 0 0 18px !important; +} + +.lm-shared-nav__icon svg { + display: block !important; + width: 18px !important; + height: 18px !important; + fill: none !important; + stroke: currentColor !important; + stroke-width: 1.8 !important; + stroke-linecap: round !important; + stroke-linejoin: round !important; +} + +.lm-shared-nav__label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} diff --git a/lukenya_marathon/lukenya_marathon/public/js/lukenya_navigation.js b/lukenya_marathon/lukenya_marathon/public/js/lukenya_navigation.js new file mode 100644 index 0000000..7418310 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/public/js/lukenya_navigation.js @@ -0,0 +1,191 @@ +frappe.provide("lukenya.navigation"); + +(() => { + const ITEMS = [ + { + key: "dashboard", + label: __("Dashboard"), + icon: "grid", + route: "lukenya-dashboard" + }, + { + key: "events", + label: __("Events"), + icon: "calendar", + route: "lukenya-events" + }, + { + key: "participants", + label: __("Participants"), + icon: "users", + route: "lukenya-participants" + }, + { + key: "race-management", + label: __("Race Management"), + icon: "flag", + route: "lukenya-race-management" + }, + { + key: "payments", + label: __("Payments"), + icon: "card", + route: "lukenya-payments" + }, + { + key: "reports", + label: __("Reports"), + icon: "chart", + route: "lukenya-reports" + } + ]; + + const ICONS = { + grid: ` + + + + + + + `, + + calendar: ` + + + + + + + `, + + users: ` + + + + + + + `, + + flag: ` + + + + + + `, + + card: ` + + + + + + `, + + chart: ` + + + + + + + ` + }; + + function getActivePage() { + const url = window.location.href; + + if (url.includes("lukenya-events")) { + return "events"; + } + + if (url.includes("lukenya-participants")) { + return "participants"; + } + + if (url.includes("lukenya-race-management")) { + return "race-management"; + } + + if (url.includes("lukenya-payments")) { + return "payments"; + } + + if (url.includes("lukenya-reports")) { + return "reports"; + } + + return "dashboard"; + } + + function getNavigationContainer() { + return document.querySelector( + "#page-lukenya-events .lm-events-nav, " + + "#page-lukenya-race-management .lm-rm-nav, " + + "#page-lukenya-payments .lm-payments-nav, " + + "#page-lukenya-participants .lm-nav, " + + "#page-lukenya-dashboard .lm-nav, " + + "#page-lukenya-reports .lm-nav" + ); + } + + function mount() { + const container = getNavigationContainer(); + + if (!container) { + return; + } + + const active = getActivePage(); + + container.classList.add("lm-shared-nav"); + container.innerHTML = ""; + + ITEMS.forEach((item) => { + const button = document.createElement("button"); + + button.type = "button"; + button.className = "lm-shared-nav__item"; + + if (item.key === active) { + button.classList.add("is-active"); + } + + button.innerHTML = ` + + ${ICONS[item.icon]} + + + ${item.label} + + `; + + button.addEventListener("click", () => { + frappe.set_route(item.route); + }); + + container.appendChild(button); + }); + } + + frappe.navigation = { + mount: mount + }; + + $(document).on("page-change", () => { + setTimeout(mount, 100); + }); + + const observer = new MutationObserver(() => { + setTimeout(mount, 20); + }); + + observer.observe(document.body, { + childList: true, + subtree: true + }); + + setTimeout(mount, 300); +})(); diff --git a/lukenya_marathon/lukenya_marathon/race_management.py b/lukenya_marathon/lukenya_marathon/race_management.py new file mode 100644 index 0000000..d40f8c0 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/race_management.py @@ -0,0 +1,123 @@ +import frappe +from frappe import _ +from frappe.utils import flt + +INACTIVE_REGISTRATION_STATUSES = ("Cancelled", "Transferred", "Refunded") + + +def _count(sql, params): + row = frappe.db.sql(sql, params, as_dict=True) + return int(row[0].value or 0) if row else 0 + + +def _pct(value, total): + return round((flt(value) / flt(total)) * 100, 1) if total else 0 + + +@frappe.whitelist() +def get_event_editions(): + """Return editions visible to the current user, newest first.""" + return frappe.get_all( + "Event Edition", + fields=["name", "edition_name", "edition_code", "event_date", "venue", "status"], + order_by="event_date desc, modified desc", + limit_page_length=100, + ) + + +@frappe.whitelist() +def get_race_management_summary(event_edition=None): + """Return the operational dashboard summary for one event edition.""" + if not event_edition: + event_edition = frappe.db.get_value( + "Event Edition", {"status": "Open"}, "name", order_by="event_date desc" + ) + if not event_edition: + event_edition = frappe.db.get_value("Event Edition", {}, "name", order_by="event_date desc") + if not event_edition: + return {"edition": None, "kpis": {}, "categories": []} + + edition = frappe.db.get_value( + "Event Edition", + event_edition, + ["name", "edition_name", "edition_code", "event_date", "venue", "status", "registration_capacity"], + as_dict=True, + ) + if not edition: + frappe.throw(_("Event Edition not found.")) + + params = {"edition": event_edition, "inactive": INACTIVE_REGISTRATION_STATUSES} + active = "r.event_edition = %(edition)s and r.registration_status not in %(inactive)s" + + registrations = _count(f"select count(*) value from `tabRegistration` r where {active}", params) + paid = _count( + f"select count(*) value from `tabRegistration` r where {active} and r.payment_status = 'Paid'", + params, + ) + confirmed = _count( + f"select count(*) value from `tabRegistration` r where {active} and r.registration_status in ('Confirmed', 'Completed')", + params, + ) + bibs = _count( + f"select count(*) value from `tabRegistration` r where {active} and ifnull(r.bib_number, '') != ''", + params, + ) + packs = _count( + f"select count(*) value from `tabRegistration` r where {active} and r.race_pack_status = 'Collected'", + params, + ) + checked_in = _count( + f"select count(*) value from `tabRegistration` r where {active} and r.check_in_status = 'Checked In'", + params, + ) + finishers = _count( + """select count(distinct t.registration) value + from `tabTiming Result` t + join `tabRegistration` r on r.name = t.registration + where t.event_edition = %(edition)s + and t.finish_status = 'Finished' + and r.registration_status not in %(inactive)s""", + params, + ) + + categories = frappe.db.sql( + f""" + select c.name, c.category_code, c.category_name, c.distance_km, c.start_time, + c.capacity, c.active, + count(r.name) as registered, + sum(case when r.payment_status = 'Paid' then 1 else 0 end) as paid, + sum(case when ifnull(r.bib_number, '') != '' then 1 else 0 end) as bibs, + sum(case when r.check_in_status = 'Checked In' then 1 else 0 end) as checked_in, + count(distinct case when t.finish_status = 'Finished' then t.registration end) as finishers + from `tabRace Category` c + left join `tabRegistration` r + on r.race_category = c.name + and r.event_edition = %(edition)s + and r.registration_status not in %(inactive)s + left join `tabTiming Result` t on t.registration = r.name + where c.event_edition = %(edition)s + group by c.name, c.category_code, c.category_name, c.distance_km, c.start_time, c.capacity, c.active + order by c.start_time asc, c.category_name asc + """, + params, + as_dict=True, + ) + + return { + "edition": edition, + "kpis": { + "registrations": registrations, + "paid": paid, + "confirmed": confirmed, + "bibs": bibs, + "packs": packs, + "checked_in": checked_in, + "finishers": finishers, + "paid_percent": _pct(paid, registrations), + "bibs_percent": _pct(bibs, registrations), + "packs_percent": _pct(packs, registrations), + "checked_in_percent": _pct(checked_in, registrations), + "finishers_percent": _pct(finishers, registrations), + }, + "categories": categories, + } diff --git a/lukenya_marathon/lukenya_marathon/race_sync.py b/lukenya_marathon/lukenya_marathon/race_sync.py new file mode 100644 index 0000000..6c82a33 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/race_sync.py @@ -0,0 +1,95 @@ +"""Keeps the copies of race-day status on Registration in step with the records that own them. + +Registration shows bib, chip, race pack and check-in status so lists and reports stay simple, but the +Bib Allocation, Race Pack Issue, Check-in and Timing Result records are the source of truth. Each +change re-derives only its own group of fields, so unrelated data is never overwritten. +""" + +import frappe + +from lukenya_marathon.lukenya_marathon.doctype.bib_allocation.bib_allocation import LIVE + +PACK_STATUS = {"Ready": "Ready", "Issued": "Issued", "Collected": "Collected", "Uncollected": "Uncollected", "Cancelled": "Not Issued"} +CHECKIN_STATUS = {"Checked In": "Checked In", "Did Not Start": "Did Not Start", "Withdrawn": "Did Not Start"} + + +def _set(registration, values): + if registration and frappe.db.exists("Registration", registration): + frappe.db.set_value("Registration", registration, values, update_modified=False) + + +def sync_bib(registration, exclude=None): + rows = frappe.db.sql( + """select bib_number, chip_number from `tabBib Allocation` + where registration = %(r)s and status in %(live)s and name != %(x)s + order by allocated_on desc, creation desc limit 1""", + {"r": registration, "live": LIVE, "x": exclude or ""}, as_dict=True, + ) + _set(registration, {"bib_number": rows[0].bib_number if rows else "", "chip_number": (rows[0].chip_number or "") if rows else ""}) + + +def sync_pack(registration, exclude=None): + rows = frappe.db.sql( + "select issue_status from `tabRace Pack Issue` where registration = %(r)s and name != %(x)s order by creation desc limit 1", + {"r": registration, "x": exclude or ""}, as_dict=True, + ) + _set(registration, {"race_pack_status": PACK_STATUS.get(rows[0].issue_status, "Not Issued") if rows else "Not Issued"}) + + +def sync_checkin(registration, exclude=None): + if frappe.db.sql( + "select 1 from `tabTiming Result` where registration = %(r)s and finish_status = 'Finished' and name != %(x)s limit 1", + {"r": registration, "x": exclude or ""}, + ): + return _set(registration, {"check_in_status": "Finished"}) + rows = frappe.db.sql( + "select status from `tabCheck-in` where registration = %(r)s and name != %(x)s order by check_in_time desc, creation desc limit 1", + {"r": registration, "x": exclude or ""}, as_dict=True, + ) + _set(registration, {"check_in_status": CHECKIN_STATUS.get(rows[0].status, "Not Checked In") if rows else "Not Checked In"}) + + +# ---- hook entry points (registered in hooks.py) ----------------------------------------------- + + +def _both(doc, sync): + """Resync the entry the record points at now and the one it pointed at before, if it moved.""" + sync(doc.registration) + before = doc.get_doc_before_save() + if before and before.registration and before.registration != doc.registration: + sync(before.registration) + + +def on_bib_change(doc, method=None): + _both(doc, sync_bib) + + +def on_pack_change(doc, method=None): + _both(doc, sync_pack) + + +def on_checkin_change(doc, method=None): + _both(doc, sync_checkin) + + +def on_result_change(doc, method=None): + _both(doc, sync_checkin) + + +def on_trash(doc, method=None): + """Deleting a record: derive from everything except the record going away.""" + sync = {"Bib Allocation": sync_bib, "Race Pack Issue": sync_pack, "Check-in": sync_checkin, "Timing Result": sync_checkin}[doc.doctype] + sync(doc.registration, exclude=doc.name) + + +def resync_all(): + """Backfill: derive each group only for entries that have records of that kind.""" + counts = {"bib": 0, "pack": 0, "checkin": 0} + for table, key, fn in ( + ("Bib Allocation", "bib", sync_bib), ("Race Pack Issue", "pack", sync_pack), + ("Check-in", "checkin", sync_checkin), ("Timing Result", "checkin", sync_checkin), + ): + for row in frappe.db.sql(f"select distinct registration from `tab{table}`", as_dict=True): + fn(row.registration) + counts[key] += 1 + return counts diff --git a/lukenya_marathon/lukenya_marathon/registration_rules.py b/lukenya_marathon/lukenya_marathon/registration_rules.py new file mode 100644 index 0000000..8dd1d2d --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/registration_rules.py @@ -0,0 +1,172 @@ +"""Rules about who may register, when, and at what price. One place, used by the public sign-up, +the Registration controller and the Event Edition controller.""" + +import frappe +from frappe import _ +from frappe.utils import cint, flt, get_datetime, getdate, now_datetime + +from lukenya_marathon.lukenya_marathon import settings + +RESIDENT_NATIONALITIES = ("kenya", "kenyan") +PUBLIC_TIERS = ("Standard", "Early Bird", "Non-resident") +HOLDING_STATUSES = ("Pending", "Confirmed") + + +def is_resident(nationality): + return (nationality or "").strip().lower() in RESIDENT_NATIONALITIES + + +# ---- the public registration window and price ---------------------------------------------- + + +def check_window(edition, now=None): + """Refuse online registration before it opens or after it closes.""" + now = get_datetime(now or now_datetime()) + opens, closes = edition.get("registration_open"), edition.get("registration_close") + if opens and now < get_datetime(opens): + frappe.throw(_("Registration for this edition opens on {0}.").format(get_datetime(opens).strftime("%d %b %Y, %H:%M"))) + if closes and now > get_datetime(closes): + frappe.throw(_("Registration for this edition closed on {0}.").format(get_datetime(closes).strftime("%d %b %Y, %H:%M"))) + + +def early_bird_active(edition, category, now=None): + ends = edition.get("early_bird_ends") + if not ends or flt(category.get("early_bird_price")) <= 0: + return False + return get_datetime(now or now_datetime()) <= get_datetime(ends) + + +def resolve_price_tier(edition, category, requested, nationality, now=None): + """The tier the runner actually gets, decided here rather than by their browser. + + - Only Standard, Early Bird and Non-resident can be chosen online. Corporate and complimentary + entries are created by staff. + - Non-residents always get the non-resident price. + - Early bird applies to residents while the edition's early-bird window is open, and is then + applied automatically. Asking for it after it ends is refused. + """ + requested = (requested or "").strip() or "Standard" + if requested not in PUBLIC_TIERS: + frappe.throw(_("That price option isn't available for online registration.")) + if not is_resident(nationality): + return "Non-resident" + if early_bird_active(edition, category, now): + return "Early Bird" + if requested == "Early Bird": + frappe.throw(_("Early bird pricing is no longer available.")) + return "Standard" + + +# ---- capacity -------------------------------------------------------------------------------- + + +def edition_entries(edition, exclude=None): + filters = {"event_edition": edition, "registration_status": ["in", list(HOLDING_STATUSES)]} + if exclude: + filters["name"] = ["!=", exclude] + return frappe.db.count("Registration", filters) + + +def check_edition_capacity(edition, exclude=None): + capacity = cint(frappe.db.get_value("Event Edition", edition, "registration_capacity")) + if capacity and edition_entries(edition, exclude) >= capacity: + frappe.throw(_("This event edition is full.")) + + +# ---- readiness (used before opening registration, and by the Events page) -------------------- + + +def check_dates(edition): + """Problems with an edition's dates, as readable sentences. Empty when they make sense.""" + problems = [] + opens, closes, early = edition.get("registration_open"), edition.get("registration_close"), edition.get("early_bird_ends") + event_date = edition.get("event_date") + if opens and closes and get_datetime(opens) >= get_datetime(closes): + problems.append(_("Registration must open before it closes.")) + if closes and event_date and getdate(closes) > getdate(event_date): + problems.append(_("Registration cannot close after the event date.")) + if early: + if opens and get_datetime(early) < get_datetime(opens): + problems.append(_("The early-bird end can't be before registration opens.")) + if closes and get_datetime(early) > get_datetime(closes): + problems.append(_("The early-bird end can't be after registration closes.")) + return problems + + +def _check(key, label, ok, level, detail=None): + return {"key": key, "label": label, "ok": bool(ok), "level": level, "detail": detail or ""} + + +def edition_readiness(edition): + """Checklist for an edition: `block` items stop it opening, `warn` items are worth a look.""" + if isinstance(edition, str): + edition = frappe.db.get_value( + "Event Edition", edition, + ["name", "event_date", "registration_open", "registration_close", "early_bird_ends", "venue"], as_dict=True, + ) + cats = frappe.db.sql( + """select name, category_name, kenyan_price, early_bird_price, capacity, start_time, prize_eligible + from `tabRace Category` where event_edition = %(e)s and active = 1""", + {"e": edition.get("name")}, as_dict=True, + ) + unpriced = [c.category_name for c in cats if flt(c.kenyan_price) <= 0] + no_capacity = [c.category_name for c in cats if not cint(c.capacity)] + no_start = [c.category_name for c in cats if not c.start_time] + early_priced = [c for c in cats if flt(c.early_bird_price) > 0] + prize_cats = [c for c in cats if cint(c.prize_eligible)] + with_rules = set() + if prize_cats: + with_rules = { + r.race_category + for r in frappe.db.sql( + "select distinct race_category from `tabPrize Rule` where event_edition = %(e)s and active = 1", + {"e": edition.get("name")}, as_dict=True, + ) + } + no_rules = [c.category_name for c in prize_cats if c.name not in with_rules] + dates = check_dates(edition) + + return { + "checks": [ + _check("dates", _("Dates make sense"), not dates, "block", " ".join(dates)), + _check("categories", _("At least one active race category"), bool(cats), "block"), + _check("prices", _("Every category has a price"), not unpriced, "block", ", ".join(unpriced)), + _check("window", _("Registration open and close times are set"), edition.get("registration_open") and edition.get("registration_close"), "warn"), + _check("early_bird", _("Early-bird prices have an end date"), not early_priced or edition.get("early_bird_ends"), "warn", + _("An early-bird price is set but no end date, so it will never be offered.") if early_priced and not edition.get("early_bird_ends") else ""), + _check("capacity", _("Every category has a capacity"), not no_capacity, "warn", ", ".join(no_capacity)), + _check("start_times", _("Every category has a start time"), not no_start, "warn", ", ".join(no_start)), + _check("prizes", _("Prize-eligible categories have prize rules"), not no_rules, "warn", ", ".join(no_rules)), + ], + } + + +def blockers(readiness): + return [c for c in readiness["checks"] if not c["ok"] and c["level"] == "block"] + + +@frappe.whitelist() +def get_readiness(edition): + frappe.only_for(("System Manager", "Race Director")) + result = edition_readiness(edition) + result["ready"] = not blockers(result) + return result + + +# ---- closing registration on time ------------------------------------------------------------ + + +def close_expired_editions(): + """Close editions whose registration close time has passed. Returns the ones it closed.""" + if not cint(settings.get("auto_close_registration")): + return [] + rows = frappe.db.sql( + "select name from `tabEvent Edition` where status = 'Open' and registration_close is not null and registration_close < %(now)s", + {"now": now_datetime()}, as_dict=True, + ) + closed = [] + for row in rows: + frappe.db.set_value("Event Edition", row.name, "status", "Closed") + frappe.get_doc("Event Edition", row.name).add_comment("Info", _("Registration closed automatically: the close time passed.")) + closed.append(row.name) + return closed diff --git a/lukenya_marathon/lukenya_marathon/report/__init__.py b/lukenya_marathon/lukenya_marathon/report/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/__init__.py b/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.js b/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.js new file mode 100644 index 0000000..228d373 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.js @@ -0,0 +1,30 @@ +frappe.query_reports["Payment Reconciliation"] = { + filters: [ + { + fieldname: "event_edition", + label: __("Event Edition"), + fieldtype: "Link", + options: "Event Edition", + }, + { + fieldname: "race_category", + label: __("Race Category"), + fieldtype: "Link", + options: "Race Category", + get_query: () => { + const edition = frappe.query_report.get_filter_value("event_edition"); + return { filters: edition ? { event_edition: edition } : {} }; + }, + }, + { + fieldname: "from_date", + label: __("Registered From"), + fieldtype: "Date", + }, + { + fieldname: "to_date", + label: __("Registered To"), + fieldtype: "Date", + }, + ], +}; diff --git a/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.json b/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.json new file mode 100644 index 0000000..49d5297 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2026-09-20 00:00:00.000000", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2026-09-20 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "Payment Reconciliation", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Registration", + "report_name": "Payment Reconciliation", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ] +} \ No newline at end of file diff --git a/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.py b/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.py new file mode 100644 index 0000000..96db693 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/payment_reconciliation/payment_reconciliation.py @@ -0,0 +1,22 @@ +import frappe +from frappe import _ + +from lukenya_marathon.lukenya_marathon import analytics + + +def execute(filters=None): + rows = analytics.payment_reconciliation(filters) + + columns = [ + {"label": _("Registration"), "fieldname": "registration", "fieldtype": "Link", "options": "Registration", "width": 140}, + {"label": _("Runner"), "fieldname": "participant_name", "fieldtype": "Data", "width": 180}, + {"label": _("Issue"), "fieldname": "issue", "fieldtype": "Data", "width": 300}, + {"label": _("Payment Status"), "fieldname": "payment_status", "fieldtype": "Data", "width": 120}, + {"label": _("Amount Due"), "fieldname": "amount_due", "fieldtype": "Currency", "width": 120}, + {"label": _("Paid"), "fieldname": "paid", "fieldtype": "Currency", "width": 110}, + {"label": _("Difference"), "fieldname": "difference", "fieldtype": "Currency", "width": 120}, + ] + + message = None if rows else _("Payments and registrations agree.") + summary = [{"value": len(rows), "label": _("Records that disagree"), "datatype": "Int", "indicator": "Red" if rows else "Green"}] + return columns, rows, message, None, summary diff --git a/lukenya_marathon/lukenya_marathon/report/registration_summary/__init__.py b/lukenya_marathon/lukenya_marathon/report/registration_summary/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.js b/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.js new file mode 100644 index 0000000..329d6a2 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.js @@ -0,0 +1,30 @@ +frappe.query_reports["Registration Summary"] = { + filters: [ + { + fieldname: "event_edition", + label: __("Event Edition"), + fieldtype: "Link", + options: "Event Edition", + }, + { + fieldname: "race_category", + label: __("Race Category"), + fieldtype: "Link", + options: "Race Category", + get_query: () => { + const edition = frappe.query_report.get_filter_value("event_edition"); + return { filters: edition ? { event_edition: edition } : {} }; + }, + }, + { + fieldname: "from_date", + label: __("Registered From"), + fieldtype: "Date", + }, + { + fieldname: "to_date", + label: __("Registered To"), + fieldtype: "Date", + }, + ], +}; diff --git a/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.json b/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.json new file mode 100644 index 0000000..977ed69 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2026-09-20 00:00:00.000000", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2026-09-20 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "Registration Summary", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Registration", + "report_name": "Registration Summary", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ] +} \ No newline at end of file diff --git a/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.py b/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.py new file mode 100644 index 0000000..01a3ba7 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/registration_summary/registration_summary.py @@ -0,0 +1,39 @@ +import frappe +from frappe import _ + +from lukenya_marathon.lukenya_marathon import analytics + + +def execute(filters=None): + rows = analytics.registration_summary(filters) + + columns = [ + {"label": _("Race Category"), "fieldname": "race_category", "fieldtype": "Link", "options": "Race Category", "width": 170}, + {"label": _("Name"), "fieldname": "category_name", "fieldtype": "Data", "width": 200}, + {"label": _("Edition"), "fieldname": "event_edition", "fieldtype": "Link", "options": "Event Edition", "width": 130}, + {"label": _("Capacity"), "fieldname": "capacity", "fieldtype": "Int", "width": 90}, + {"label": _("Registered"), "fieldname": "registered", "fieldtype": "Int", "width": 100}, + {"label": _("Confirmed"), "fieldname": "confirmed", "fieldtype": "Int", "width": 100}, + {"label": _("Paid Entries"), "fieldname": "paid_entries", "fieldtype": "Int", "width": 110}, + {"label": _("Filled %"), "fieldname": "fill_percent", "fieldtype": "Percent", "width": 90}, + {"label": _("Billed"), "fieldname": "billed", "fieldtype": "Currency", "width": 120}, + {"label": _("Revenue"), "fieldname": "revenue", "fieldtype": "Currency", "width": 120}, + ] + + chart = { + "data": { + "labels": [r.category_name for r in rows], + "datasets": [ + {"name": _("Registered"), "values": [r.registered for r in rows]}, + {"name": _("Capacity"), "values": [r.capacity for r in rows]}, + ], + }, + "type": "bar", + } + + summary = [ + {"value": sum(r.registered for r in rows), "label": _("Registered"), "datatype": "Int"}, + {"value": sum(r.confirmed for r in rows), "label": _("Confirmed"), "datatype": "Int"}, + {"value": sum(r.revenue for r in rows), "label": _("Revenue"), "datatype": "Currency", "indicator": "Green"}, + ] + return columns, rows, None, chart, summary diff --git a/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/__init__.py b/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.js b/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.js new file mode 100644 index 0000000..7703020 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.js @@ -0,0 +1,30 @@ +frappe.query_reports["Unpaid Registrations"] = { + filters: [ + { + fieldname: "event_edition", + label: __("Event Edition"), + fieldtype: "Link", + options: "Event Edition", + }, + { + fieldname: "race_category", + label: __("Race Category"), + fieldtype: "Link", + options: "Race Category", + get_query: () => { + const edition = frappe.query_report.get_filter_value("event_edition"); + return { filters: edition ? { event_edition: edition } : {} }; + }, + }, + { + fieldname: "from_date", + label: __("Registered From"), + fieldtype: "Date", + }, + { + fieldname: "to_date", + label: __("Registered To"), + fieldtype: "Date", + }, + ], +}; diff --git a/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.json b/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.json new file mode 100644 index 0000000..a5347e0 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.json @@ -0,0 +1,27 @@ +{ + "add_total_row": 0, + "columns": [], + "creation": "2026-09-20 00:00:00.000000", + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2026-09-20 00:00:00.000000", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "Unpaid Registrations", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "Registration", + "report_name": "Unpaid Registrations", + "report_type": "Script Report", + "roles": [ + { + "role": "System Manager" + } + ] +} \ No newline at end of file diff --git a/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.py b/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.py new file mode 100644 index 0000000..04b7fbf --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/report/unpaid_registrations/unpaid_registrations.py @@ -0,0 +1,26 @@ +import frappe +from frappe import _ + +from lukenya_marathon.lukenya_marathon import analytics + + +def execute(filters=None): + rows = analytics.unpaid_registrations(filters) + + columns = [ + {"label": _("Registration"), "fieldname": "registration", "fieldtype": "Link", "options": "Registration", "width": 140}, + {"label": _("Runner"), "fieldname": "participant_name", "fieldtype": "Data", "width": 180}, + {"label": _("Category"), "fieldname": "category_name", "fieldtype": "Data", "width": 170}, + {"label": _("Registered On"), "fieldname": "registration_date", "fieldtype": "Datetime", "width": 160}, + {"label": _("Payment Status"), "fieldname": "payment_status", "fieldtype": "Data", "width": 120}, + {"label": _("Amount Due"), "fieldname": "amount_due", "fieldtype": "Currency", "width": 120}, + {"label": _("Paid"), "fieldname": "paid", "fieldtype": "Currency", "width": 110}, + {"label": _("Balance"), "fieldname": "balance", "fieldtype": "Currency", "width": 120}, + {"label": _("Days Open"), "fieldname": "days_open", "fieldtype": "Int", "width": 90}, + ] + + summary = [ + {"value": len(rows), "label": _("Entries owing"), "datatype": "Int", "indicator": "Red" if rows else "Green"}, + {"value": sum(r.balance for r in rows), "label": _("Total balance"), "datatype": "Currency", "indicator": "Red" if rows else "Green"}, + ] + return columns, rows, None, None, summary diff --git a/lukenya_marathon/lukenya_marathon/results.py b/lukenya_marathon/lukenya_marathon/results.py new file mode 100644 index 0000000..687f516 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/results.py @@ -0,0 +1,274 @@ +"""Importing finish times and working out ranks.""" + +import csv +import io +import re + +import frappe +from frappe import _ +from frappe.utils import cint, flt, getdate + +ROLES = ("System Manager", "Race Director", "Timing Officer") +LIVE_OR_REPLACED = ("Allocated", "Printed", "Issued", "Replaced") + +# Age groups used for ranking. Prize rules should use the same labels. +RANK_AGE_BANDS = [ + (0, 17, "Under 18"), (18, 34, "18-34"), (35, 39, "35-39"), (40, 44, "40-44"), (45, 49, "45-49"), + (50, 54, "50-54"), (55, 59, "55-59"), (60, 64, "60-64"), (65, 120, "65+"), +] + +STATUS_WORDS = { + "finished": "Finished", "fin": "Finished", "ok": "Finished", "finisher": "Finished", + "dnf": "Did Not Finish", "did not finish": "Did Not Finish", + "dns": "Did Not Start", "did not start": "Did Not Start", + "dsq": "Disqualified", "dq": "Disqualified", "disqualified": "Disqualified", + "pending review": "Pending Review", "review": "Pending Review", +} + +COLUMN_ALIASES = { + "bib": ("bib", "bib number", "bib_number", "bibno", "bib no", "number"), + "chip_time": ("chip time", "chip_time", "chip", "net time", "net", "nettime"), + "gun_time": ("gun time", "gun_time", "gun", "clock time", "clock", "guntime"), + "status": ("status", "finish status", "finish_status", "result status"), +} + + +def age_group_for(date_of_birth, reference): + if not date_of_birth: + return None + dob, ref = getdate(date_of_birth), getdate(reference) + age = ref.year - dob.year - ((ref.month, ref.day) < (dob.month, dob.day)) + for low, high, label in RANK_AGE_BANDS: + if low <= age <= high: + return label + return None + + +# ---- parsing ------------------------------------------------------------------------------- + + +def parse_duration(text): + """'1:23:45', '1:23:45.6', '23:45' or '45.5' to seconds. Raises ValueError on anything else.""" + text = (text or "").strip() + if not text: + return None + if not re.fullmatch(r"\d+(:\d{1,2}){0,2}(\.\d+)?", text): + raise ValueError(text) + parts = [float(p) for p in text.split(":")] + seconds = 0.0 + for p in parts: + seconds = seconds * 60 + p + if len(parts) > 1 and any(p >= 60 for p in parts[1:]): + raise ValueError(text) + return seconds + + +def _read_rows(csv_text): + sample = csv_text[:2048] + try: + dialect = csv.Sniffer().sniff(sample, delimiters=",;\t") + except csv.Error: + dialect = csv.excel + reader = csv.reader(io.StringIO(csv_text.lstrip("\ufeff")), dialect) + rows = [r for r in reader if any((c or "").strip() for c in r)] + if not rows: + frappe.throw(_("The file is empty.")) + header = [re.sub(r"\s+", " ", (h or "").strip().lower()) for h in rows[0]] + index = {} + for key, aliases in COLUMN_ALIASES.items(): + for i, name in enumerate(header): + if name in aliases: + index[key] = i + break + if "bib" not in index: + frappe.throw(_("The file needs a Bib column.")) + if "chip_time" not in index and "gun_time" not in index: + frappe.throw(_("The file needs a Chip Time or Gun Time column.")) + return index, rows[1:] + + +# ---- import -------------------------------------------------------------------------------- + + +def _plan(event_edition, csv_text, overwrite): + index, rows = _read_rows(csv_text) + errors, warnings, plan, seen = [], [], [], set() + + bibs = { + r.bib_number: r + for r in frappe.db.sql( + """select b.bib_number, b.registration, r.race_category from `tabBib Allocation` b + join `tabRegistration` r on r.name = b.registration + where b.event_edition = %(e)s and b.status in %(s)s + order by field(b.status, 'Replaced', 'Allocated', 'Printed', 'Issued')""", + {"e": event_edition, "s": LIVE_OR_REPLACED}, as_dict=True, + ) + } + existing = { + r.registration: r + for r in frappe.db.sql("select name, registration, approved from `tabTiming Result` where event_edition = %(e)s", {"e": event_edition}, as_dict=True) + } + + def cell(row, key): + i = index.get(key) + return row[i].strip() if i is not None and i < len(row) else "" + + for n, row in enumerate(rows, start=2): + bib = cell(row, "bib").upper() + if not bib: + errors.append({"row": n, "bib": "", "message": _("No bib number.")}) + continue + if bib in seen: + errors.append({"row": n, "bib": bib, "message": _("This bib appears more than once in the file.")}) + continue + seen.add(bib) + alloc = bibs.get(bib) + if not alloc: + errors.append({"row": n, "bib": bib, "message": _("Bib {0} has not been allocated in this edition.").format(bib)}) + continue + try: + chip, gun = parse_duration(cell(row, "chip_time")), parse_duration(cell(row, "gun_time")) + except ValueError as e: + errors.append({"row": n, "bib": bib, "message": _("Can't read the time '{0}'.").format(e)}) + continue + + word = cell(row, "status").lower() + if word and word not in STATUS_WORDS: + errors.append({"row": n, "bib": bib, "message": _("Unknown status '{0}'.").format(cell(row, "status"))}) + continue + status = STATUS_WORDS.get(word) or ("Finished" if (chip or gun) else "Pending Review") + if status == "Finished" and not (chip or gun): + errors.append({"row": n, "bib": bib, "message": _("Marked finished but has no time.")}) + continue + if not word and status == "Pending Review": + warnings.append({"row": n, "bib": bib, "message": _("No time and no status, so it was set to Pending Review.")}) + + found = existing.get(alloc.registration) + action = "create" + if found: + if cint(found.approved) and not cint(overwrite): + warnings.append({"row": n, "bib": bib, "message": _("Already approved, so it was skipped. Turn on overwrite to replace it.")}) + action = "skip" + else: + action = "update" + plan.append({ + "row": n, "bib": bib, "registration": alloc.registration, "race_category": alloc.race_category, + "chip_time": chip, "gun_time": gun, "finish_status": status, "action": action, + "result": found.name if found else None, + }) + return plan, errors, warnings + + +@frappe.whitelist() +def import_results(event_edition, csv_text=None, file_url=None, dry_run=1, overwrite=0, source=None): + """Load finish times from the timing company's CSV. Preview first (dry_run=1); a real import only + goes ahead when the file has no errors, so a bad file never half-loads.""" + frappe.only_for(ROLES) + if not csv_text and file_url: + csv_text = frappe.get_doc("File", {"file_url": file_url}).get_content() + if isinstance(csv_text, bytes): + csv_text = csv_text.decode("utf-8-sig") + if not csv_text: + frappe.throw(_("Provide the CSV text or an uploaded file.")) + + plan, errors, warnings = _plan(event_edition, csv_text, cint(overwrite)) + summary = { + "dry_run": bool(cint(dry_run)), + "rows": len(plan) + len(errors), + "will_create": sum(1 for p in plan if p["action"] == "create"), + "will_update": sum(1 for p in plan if p["action"] == "update"), + "skipped": sum(1 for p in plan if p["action"] == "skip"), + "errors": errors, + "warnings": warnings, + "sample": plan[:10], + } + if cint(dry_run): + return summary + if errors: + frappe.throw(_("Fix the {0} problem(s) in the file first. Nothing was imported.").format(len(errors))) + + label = source or _("Import") + for p in plan: + if p["action"] == "skip": + continue + values = { + "gun_time": p["gun_time"], "chip_time": p["chip_time"], "finish_status": p["finish_status"], + "source": label, "approved": 0, + } + if p["action"] == "update": + doc = frappe.get_doc("Timing Result", p["result"]) + doc.update(values) + doc.save(ignore_permissions=True) + else: + frappe.get_doc(dict(values, doctype="Timing Result", registration=p["registration"], bib_number=p["bib"], + event_edition=event_edition, race_category=p["race_category"])).insert(ignore_permissions=True) + summary["dry_run"] = False + summary["imported"] = summary["will_create"] + summary["will_update"] + return summary + + +# ---- ranks --------------------------------------------------------------------------------- + + +def _rank(items, key): + """Competition ranking: equal times share a rank and the next rank skips (1, 2, 2, 4).""" + ranks, previous, position = {}, None, 0 + for i, item in enumerate(sorted(items, key=key), start=1): + if key(item) != previous: + position, previous = i, key(item) + ranks[item["name"]] = position + return ranks + + +@frappe.whitelist() +def compute_ranks(event_edition, race_category=None): + """Set overall, gender and age-group ranks from finish times. Ranks are by chip time (gun time if + there is none) among finishers. Everyone else has their ranks cleared.""" + frappe.only_for(ROLES) + params = {"e": event_edition} + cat_sql = "" + if race_category: + cat_sql = "and t.race_category = %(c)s" + params["c"] = race_category + rows = frappe.db.sql( + f""" + select t.name, t.race_category, t.finish_status, + coalesce(nullif(t.chip_time, 0), nullif(t.gun_time, 0)) as seconds, + t.overall_rank, t.gender_rank, t.age_group_rank, p.gender, p.date_of_birth, e.event_date + from `tabTiming Result` t + join `tabRegistration` r on r.name = t.registration + join `tabParticipant` p on p.name = r.participant + join `tabEvent Edition` e on e.name = t.event_edition + where t.event_edition = %(e)s {cat_sql} + """, + params, as_dict=True, + ) + # Int columns can't hold NULL, so "not ranked" is 0 + wanted = {r.name: {"overall_rank": 0, "gender_rank": 0, "age_group_rank": 0} for r in rows} + finishers = [r for r in rows if r.finish_status == "Finished" and r.seconds is not None and flt(r.seconds) > 0] + + by_cat = {} + for r in finishers: + by_cat.setdefault(r.race_category, []).append(r) + for members in by_cat.values(): + for name, rank in _rank(members, lambda r: flt(r.seconds)).items(): + wanted[name]["overall_rank"] = rank + groups = {} + for r in members: + if r.gender in ("Male", "Female"): + groups.setdefault(("g", r.gender), []).append(r) + band = age_group_for(r.date_of_birth, r.event_date) + if band: + groups.setdefault(("a", r.gender, band), []).append(r) + for key, group in groups.items(): + field = "gender_rank" if key[0] == "g" else "age_group_rank" + for name, rank in _rank(group, lambda r: flt(r.seconds)).items(): + wanted[name][field] = rank + + changed = 0 + for r in rows: + new = wanted[r.name] + if (cint(r.overall_rank), cint(r.gender_rank), cint(r.age_group_rank)) != (new["overall_rank"], new["gender_rank"], new["age_group_rank"]): + frappe.db.set_value("Timing Result", r.name, new, update_modified=False) + changed += 1 + return {"results": len(rows), "ranked": len(finishers), "changed": changed} diff --git a/lukenya_marathon/lukenya_marathon/settings.py b/lukenya_marathon/lukenya_marathon/settings.py new file mode 100644 index 0000000..2c4d726 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/settings.py @@ -0,0 +1,26 @@ +"""Site-wide switches, read from the Lukenya Settings page with safe defaults. + +The defaults apply until someone saves the settings form, and whenever the setting can't be read. +""" + +import frappe + +DEFAULTS = { + "hold_expiry_days": 0, # 0 = unpaid entries hold their place forever + "auto_close_registration": 1, # close an edition when its registration close time passes + "bibs_require_payment": 1, # only paid (or complimentary) confirmed entries can get a bib +} + + +def get(name): + default = DEFAULTS[name] + try: + value = frappe.db.get_single_value("Lukenya Settings", name) + except Exception: + return default + if value in (None, ""): + return default + try: + return int(float(value)) # single values can come back as text + except (TypeError, ValueError): + return default diff --git a/lukenya_marathon/lukenya_marathon/workspace/lukenya_marathon/lukenya_marathon.json b/lukenya_marathon/lukenya_marathon/workspace/lukenya_marathon/lukenya_marathon.json new file mode 100644 index 0000000..121faf9 --- /dev/null +++ b/lukenya_marathon/lukenya_marathon/workspace/lukenya_marathon/lukenya_marathon.json @@ -0,0 +1,279 @@ +{ + "charts": [], + "content": "[{\"id\": \"lukenya-header\", \"type\": \"header\", \"data\": {\"text\": \"Marathon Operations\", \"col\": 12}}, {\"id\": \"e597e3e053\", \"type\": \"header\", \"data\": {\"text\": \"Portal\", \"col\": 12}}, {\"id\": \"bcd51cd267\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Marathon Portal\", \"col\": 4}}, {\"id\": \"0126d714e2\", \"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"id\": \"cat-0\", \"type\": \"header\", \"data\": {\"text\": \"Event Setup\", \"col\": 12}}, {\"id\": \"sc-0-0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Event Editions\", \"col\": 4}}, {\"id\": \"sc-0-1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Race Categories\", \"col\": 4}}, {\"id\": \"sp-0\", \"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"id\": \"cat-1\", \"type\": \"header\", \"data\": {\"text\": \"Participants & Registration\", \"col\": 12}}, {\"id\": \"sc-1-0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Participants\", \"col\": 4}}, {\"id\": \"sc-1-1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Registrations\", \"col\": 4}}, {\"id\": \"sc-1-2\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Payment Transactions\", \"col\": 4}}, {\"id\": \"sp-1\", \"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"id\": \"cat-2\", \"type\": \"header\", \"data\": {\"text\": \"Race Day\", \"col\": 12}}, {\"id\": \"sc-2-0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Bib Allocations\", \"col\": 4}}, {\"id\": \"sc-2-1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Race Pack Issues\", \"col\": 4}}, {\"id\": \"sc-2-2\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Timing Results\", \"col\": 4}}, {\"id\": \"sp-2\", \"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"id\": \"cat-3\", \"type\": \"header\", \"data\": {\"text\": \"Prizes\", \"col\": 12}}, {\"id\": \"sc-3-0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Prize Rules\", \"col\": 4}}, {\"id\": \"sc-3-1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Prize Awards\", \"col\": 4}}, {\"id\": \"sp-3\", \"type\": \"spacer\", \"data\": {\"col\": 12}}, {\"id\": \"cat-4\", \"type\": \"header\", \"data\": {\"text\": \"Volunteers & Impact\", \"col\": 12}}, {\"id\": \"sc-4-0\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Volunteers\", \"col\": 4}}, {\"id\": \"sc-4-1\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Volunteer Shifts\", \"col\": 4}}, {\"id\": \"sc-4-2\", \"type\": \"shortcut\", \"data\": {\"shortcut_name\": \"Tree Impact Records\", \"col\": 4}}, {\"id\": \"sp-4\", \"type\": \"spacer\", \"data\": {\"col\": 12}}]", + "creation": "2026-09-18 01:39:00.213779", + "custom_blocks": [ + { + "custom_block_name": "" + } + ], + "docstatus": 0, + "doctype": "Workspace", + "hide_custom": 0, + "idx": 0, + "indicator_color": "green", + "is_hidden": 0, + "label": "Lukenya Marathon", + "links": [ + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Event Editions", + "link_count": 0, + "link_to": "Event Edition", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Race Categories", + "link_count": 0, + "link_to": "Race Category", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Participants", + "link_count": 0, + "link_to": "Participant", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Registrations", + "link_count": 0, + "link_to": "Registration", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Payment Transactions", + "link_count": 0, + "link_to": "Payment Transaction", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Bib Allocations", + "link_count": 0, + "link_to": "Bib Allocation", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Race Pack Issues", + "link_count": 0, + "link_to": "Race Pack Issue", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Volunteer Shifts", + "link_count": 0, + "link_to": "Volunteer Shift", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Timing Results", + "link_count": 0, + "link_to": "Timing Result", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Prize Rules", + "link_count": 0, + "link_to": "Prize Rule", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Prize Awards", + "link_count": 0, + "link_to": "Prize Award", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Volunteers", + "link_count": 0, + "link_to": "Volunteer", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + }, + { + "dependencies": "", + "hidden": 0, + "is_query_report": 0, + "label": "Tree Impact Records", + "link_count": 0, + "link_to": "Tree Impact Record", + "link_type": "DocType", + "onboard": 0, + "type": "Link" + } + ], + "modified": "2026-09-23 22:46:53.280750", + "modified_by": "Administrator", + "module": "Lukenya Marathon", + "name": "Lukenya Marathon", + "number_cards": [], + "owner": "Administrator", + "public": 1, + "quick_lists": [ + { + "document_type": "Event Edition", + "label": "event editions", + "quick_list_filter": "test" + } + ], + "roles": [], + "sequence_id": 0.0, + "shortcuts": [ + { + "color": "Yellow", + "doc_view": "", + "label": "Marathon Portal", + "link_to": "lukenya-dashboard", + "type": "Page" + }, + { + "color": "Blue", + "doc_view": "List", + "label": "Event Editions", + "link_to": "Event Edition", + "type": "DocType" + }, + { + "color": "Blue", + "doc_view": "List", + "label": "Race Categories", + "link_to": "Race Category", + "type": "DocType" + }, + { + "color": "Green", + "doc_view": "List", + "label": "Participants", + "link_to": "Participant", + "type": "DocType" + }, + { + "color": "Green", + "doc_view": "List", + "label": "Registrations", + "link_to": "Registration", + "type": "DocType" + }, + { + "color": "Green", + "doc_view": "List", + "label": "Payment Transactions", + "link_to": "Payment Transaction", + "type": "DocType" + }, + { + "color": "Orange", + "doc_view": "List", + "label": "Bib Allocations", + "link_to": "Bib Allocation", + "type": "DocType" + }, + { + "color": "Orange", + "doc_view": "List", + "label": "Race Pack Issues", + "link_to": "Race Pack Issue", + "type": "DocType" + }, + { + "color": "Orange", + "doc_view": "List", + "label": "Timing Results", + "link_to": "Timing Result", + "type": "DocType" + }, + { + "color": "Pink", + "doc_view": "List", + "label": "Prize Rules", + "link_to": "Prize Rule", + "type": "DocType" + }, + { + "color": "Pink", + "doc_view": "List", + "label": "Prize Awards", + "link_to": "Prize Award", + "type": "DocType" + }, + { + "color": "Cyan", + "doc_view": "List", + "label": "Volunteers", + "link_to": "Volunteer", + "type": "DocType" + }, + { + "color": "Cyan", + "doc_view": "List", + "label": "Volunteer Shifts", + "link_to": "Volunteer Shift", + "type": "DocType" + }, + { + "color": "Cyan", + "doc_view": "List", + "label": "Tree Impact Records", + "link_to": "Tree Impact Record", + "type": "DocType" + } + ], + "title": "Lukenya Marathon" +} \ No newline at end of file diff --git a/lukenya_marathon/modules.txt b/lukenya_marathon/modules.txt index d9c8863..ac83229 100644 --- a/lukenya_marathon/modules.txt +++ b/lukenya_marathon/modules.txt @@ -1 +1 @@ -Lukenya Marathon \ No newline at end of file +Lukenya Marathon diff --git a/lukenya_marathon/patches.txt b/lukenya_marathon/patches.txt index f15c3a9..67682c2 100644 --- a/lukenya_marathon/patches.txt +++ b/lukenya_marathon/patches.txt @@ -1,6 +1,10 @@ [pre_model_sync] # Patches added in this section will be executed before doctypes are migrated # Read docs to understand patches: https://frappeframework.com/docs/v14/user/en/database-migrations +lukenya_marathon.patches.create_race_roles [post_model_sync] -# Patches added in this section will be executed after doctypes are migrated \ No newline at end of file +# Patches added in this section will be executed after doctypes are migrated +lukenya_marathon.patches.normalize_participants +lukenya_marathon.patches.backfill_bib_edition +lukenya_marathon.patches.sync_registration_race_status diff --git a/lukenya_marathon/patches/backfill_bib_edition.py b/lukenya_marathon/patches/backfill_bib_edition.py new file mode 100644 index 0000000..9c4cf78 --- /dev/null +++ b/lukenya_marathon/patches/backfill_bib_edition.py @@ -0,0 +1,12 @@ +"""Give existing bib allocations their edition, so bib numbers are checked per edition.""" + +import frappe + + +def execute(): + frappe.db.sql( + """update `tabBib Allocation` b + join `tabRegistration` r on r.name = b.registration + set b.event_edition = r.event_edition + where b.event_edition is null or b.event_edition = ''""" + ) diff --git a/lukenya_marathon/patches/create_race_roles.py b/lukenya_marathon/patches/create_race_roles.py new file mode 100644 index 0000000..48a592e --- /dev/null +++ b/lukenya_marathon/patches/create_race_roles.py @@ -0,0 +1,11 @@ +"""Create the roles used by race-day staff. Runs before doctypes sync so permission rows can name them.""" + +import frappe + +ROLES = ("Registration Desk", "Timing Officer", "Race Director") + + +def execute(): + for name in ROLES: + if not frappe.db.exists("Role", name): + frappe.get_doc({"doctype": "Role", "role_name": name, "desk_access": 1}).insert(ignore_permissions=True) diff --git a/lukenya_marathon/patches/normalize_participants.py b/lukenya_marathon/patches/normalize_participants.py new file mode 100644 index 0000000..f402030 --- /dev/null +++ b/lukenya_marathon/patches/normalize_participants.py @@ -0,0 +1,30 @@ +"""Fill in full names and tidy phone numbers, emails and nationalities on existing participants.""" + +import frappe + +from lukenya_marathon.lukenya_marathon.participant_utils import ( + build_full_name, + normalize_nationality, + normalize_phone, +) + + +def execute(): + rows = frappe.db.sql( + """select name, first_name, last_name, full_name, nationality, phone, whatsapp, + emergency_contact_phone, email, id_number from `tabParticipant`""", + as_dict=True, + ) + for row in rows: + wanted = { + "full_name": build_full_name(row.first_name, row.last_name), + "nationality": normalize_nationality(row.nationality), + "phone": normalize_phone(row.phone), + "whatsapp": normalize_phone(row.whatsapp), + "emergency_contact_phone": normalize_phone(row.emergency_contact_phone), + "email": (row.email or "").strip().lower(), + "id_number": (row.id_number or "").strip() or None, + } + changes = {key: value for key, value in wanted.items() if (row.get(key) or None) != (value or None)} + if changes: + frappe.db.set_value("Participant", row.name, changes, update_modified=False) diff --git a/lukenya_marathon/patches/sync_registration_race_status.py b/lukenya_marathon/patches/sync_registration_race_status.py new file mode 100644 index 0000000..1b47684 --- /dev/null +++ b/lukenya_marathon/patches/sync_registration_race_status.py @@ -0,0 +1,8 @@ +"""Fill Registration's bib, chip, race pack and check-in status from the records that own them. +Only entries that already have such records are touched, so nothing typed by hand is wiped.""" + +from lukenya_marathon.lukenya_marathon.race_sync import resync_all + + +def execute(): + resync_all() diff --git a/lukenya_marathon/public/css/lukenya_navigation.css b/lukenya_marathon/public/css/lukenya_navigation.css new file mode 100644 index 0000000..88628b3 --- /dev/null +++ b/lukenya_marathon/public/css/lukenya_navigation.css @@ -0,0 +1,175 @@ +/* Lukenya Marathon — shared left sidebar. + * Loaded once on every Desk page (see hooks.py: app_include_css). + * Matches the markup from public/js/lukenya_navigation.js (lukenya.nav.render). + * Do not copy these rules into individual page .css files — if the sidebar + * needs to change, change it here and every page picks it up. + */ + +.lm-side, +.lm-side * { + box-sizing: border-box; +} +.lm-side :where(button) { + font: inherit; + color: inherit; + cursor: pointer; +} +.lm-side .lm-ico { + flex: none; +} + +.lm-side { + --lm-primary: #0f9818; + --lm-primary-ink: #ffffff; + --lm-side-top: #06381c; + --lm-side-bottom: #0a5c2a; + --lm-side-ink: #c9d4ea; + --lm-radius: 14px; + --lm-radius-sm: 10px; + + position: sticky; + top: var(--navbar-height, 48px); + height: calc(100vh - var(--navbar-height, 48px)); + width: 248px; + flex: none; + display: flex; + flex-direction: column; + gap: 8px; + padding: 20px 14px; + background: linear-gradient(180deg, var(--lm-side-top), var(--lm-side-bottom)); + color: var(--lm-side-ink); +} + +.lm-brand { + display: flex; + align-items: center; + gap: 10px; + padding: 0 8px 18px; + color: #fff; +} +.lm-brand__mark { + display: grid; + place-items: center; + width: 38px; + height: 38px; + border-radius: 10px; + background: transparent; + color: #fff; +} +.lm-brand__mark img { + width: 100%; + height: 100%; + object-fit: contain; +} +.lm-brand__name { + font-size: 15px; + white-space: nowrap; + font-weight: 700; + letter-spacing: 0.1px; +} + +.lm-nav { + display: flex; + flex-direction: column; + gap: 4px; +} +.lm-nav__item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 11px 14px; + border: 0; + border-radius: var(--lm-radius-sm); + background: transparent; + color: var(--lm-side-ink); + font-size: 14px; + font-weight: 500; + text-align: left; + transition: background-color 0.15s ease, color 0.15s ease; +} +.lm-nav__item:hover { + background: rgba(255, 255, 255, 0.07); + color: #fff; +} +.lm-nav__item.is-active { + background: var(--lm-primary); + color: var(--lm-primary-ink); + box-shadow: 0 6px 16px rgba(15, 152, 24, 0.35); +} +.lm-nav__item.is-soon { + opacity: 0.55; + cursor: not-allowed; +} +.lm-nav__item.is-soon:hover { + background: transparent; + color: var(--lm-side-ink); +} + +.lm-side__workspace { + margin: auto 0 0; + padding: 9px 14px; + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: var(--lm-radius-sm); + background: transparent; + color: var(--lm-side-ink); + font-size: 13px; + transition: background-color 0.15s ease; +} +.lm-side__workspace:hover { + background: rgba(255, 255, 255, 0.07); +} + +.lm-side__promo { + display: flex; + align-items: flex-end; + min-height: 120px; + padding: 14px; + border-radius: var(--lm-radius); + background: + linear-gradient(180deg, rgba(6, 56, 28, 0) 30%, rgba(6, 56, 28, 0.9)), + linear-gradient(160deg, #1c7a3a 0%, #0a3d1e 100%); + color: #fff; +} +.lm-side__promo p { + margin: 0; + font-size: 14px; + font-weight: 700; + line-height: 1.3; + max-width: 9em; +} + +.lm-side :is(button):focus-visible { + outline: 2px solid #fff; + outline-offset: 2px; +} + +@media (max-width: 900px) { + .lm-side { + position: static; + height: auto; + width: 100%; + flex-direction: row; + flex-wrap: wrap; + align-items: center; + padding: 12px; + } + .lm-brand { + padding: 0 8px 0 0; + } + .lm-nav { + flex-direction: row; + flex-wrap: wrap; + } + .lm-nav__item span { + display: none; + } + .lm-nav__item { + width: auto; + padding: 10px; + } + .lm-side__workspace, + .lm-side__promo { + display: none; + } +} diff --git a/lukenya_marathon/public/images/lukenya-logo.png b/lukenya_marathon/public/images/lukenya-logo.png new file mode 100644 index 0000000..0ef0e1f Binary files /dev/null and b/lukenya_marathon/public/images/lukenya-logo.png differ diff --git a/lukenya_marathon/public/js/lukenya_navigation.js b/lukenya_marathon/public/js/lukenya_navigation.js new file mode 100644 index 0000000..781ccd8 --- /dev/null +++ b/lukenya_marathon/public/js/lukenya_navigation.js @@ -0,0 +1,70 @@ +/* Lukenya Marathon — shared left sidebar. + * Loaded once on every Desk page (see hooks.py: app_include_js). + * Every lukenya-* page should render its sidebar by calling + * lukenya.nav.render(activeRoute) instead of building its own