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) =>
+ `${ICON[name]} `;
+
+ 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")]))}
+
+
+ ${esc(__("More Runners,"))} ${esc(__("Bigger Impact"))}
+
+ `;
+ }
+
+ kpiRow() {
+ if (this.error) {
+ return `${esc(
+ __("Couldn't load dashboard figures. Open Error Log to see what went wrong.")
+ )}
`;
+ }
+ 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 `
+
+ ${svg(k.icon, 22)}
+
+ ${esc(k.label)}
+ ${valueHtml}
+ ${this.trend(kpi, k)}
+
+ ${svg("right", 16)}
+ `;
+ }).join("");
+ return ``;
+ }
+
+ 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"
+ ? `${esc(__(pill) + "s")} `
+ : `${esc(pill.label)} `
+ )
+ .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"))} ` : ``}
+
+ ${t.soon ? "" : `
${svg("right", 16)} `}
+
`;
+ }).join("");
+
+ const actions = QUICK_ACTIONS.filter((a) => canRead(a.doctype))
+ .map((a) => ``)
+ .join("");
+
+ return `
+ `;
+ }
+
+ 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")}
+
+
+
+
+
+
+ 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("")}
+
+
+
+ `);
+ }
+
+ renderEdition(edition) {
+ const categories = edition.categories || [];
+
+ const categoryRows = categories.length
+ ? categories.map((category) => `
+
+
+
+ ${esc(
+ category.category_name ||
+ category.name
+ )}
+
+
+ ${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 `
+
+
+
+
+
+ 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.
+
+
+
+
+ Open categories
+
+
+
+
+
+
+
+ Category
+ Discipline
+ Distance
+ Start
+ Capacity
+ Kenyan price
+ Status
+
+
+
+
+ ${categoryRows}
+
+
+
+
+ `;
+ }
+
+ 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) =>
+ `${ICON[name]} `;
+
+ 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."))}
+
+
+ ${svg("merge", 16)}${esc(__("Possible duplicates"))}
+ ${svg("download", 16)}${esc(__("Export CSV"))}
+ ${svg("userplus", 16)}${esc(__("New participant"))}
+
+
+
+ ${svg("search", 18)}
+
+
+
+
+
+
+
+
+
+
+
`);
+ 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)}
+ ${tag}>`;
+ }).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 = [`${esc(blank)} `]
+ .concat(values.map((v) => {
+ const value = typeof v === "string" ? v : v.value;
+ const text = typeof v === "string" ? v : v.label;
+ return `${esc(text)} `;
+ }))
+ .join("");
+ return `${esc(label)} ${opts} `;
+ }
+
+ 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")) +
+ `${esc(__("Reset"))} `
+ );
+ }
+
+ renderChips() {
+ const s = this.state;
+ this.$root.find(".lm-chips").html(
+ CHIPS.map((c) => {
+ const off = c.edition && !s.edition;
+ return `${esc(c.label)} `;
+ }).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(`${esc(__("Couldn't load participants. Open Error Log to see what went wrong."))}
`);
+ 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
+ ? `
+ ${esc(__("Runner"))} ${esc(__("Age / gender"))} ${esc(__("Nationality"))} ${esc(__("Contact"))} ${esc(__("Latest entry"))} ${esc(__("Status"))} ${esc(__("Bib"))} ${esc(__("Flags"))}
+ ${rows}
`
+ : `${esc(__("No participants match these filters."))}
`
+ }
+
+ `);
+ }
+
+ 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."))}
`
+ : `${svg("eye", 15)}${esc(__("Reveal ID and medical notes"))} `
+ }
+ `;
+
+ 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(" ")}
+
+ ${svg("x", 18)}
+
+
+ ${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}
+ ${esc(__("Open registration"))} ${svg("external", 13)}
+ `;
+ }
+
+ 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 `${esc(c.category_name + note)} `;
+ })
+ .join("");
+ const tiers = this.options.price_tiers
+ .map((t) => `${esc(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 `
+ `;
+ }
+
+ 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 = ``;
+ 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) => `
+
+
+
+ ${esc(m.full_name)}
+ ${esc(m.code)}
+ ${esc(m.phone) || "–"}
+ ${esc(m.email)}
+ ${esc(m.date_of_birth ? fmtDate(m.date_of_birth) : __("No birth date"))} · ${esc(__("{0} entries", [m.entries]))}
+ ${esc(__("View profile"))}
+
+ ${m.name === keep ? `${esc(__("Keep"))} ` : ""}
+ `
+ )
+ .join("");
+ const others = g.members.filter((m) => m.name !== keep);
+ const buttons = others
+ .map((m) => `${svg("merge", 15)}${esc(__("Preview merging {0}", [m.full_name + " (" + m.code + ")"]))} `)
+ .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}
+
+ ${esc(__("Merge these two"))}
+ ${esc(__("Cancel"))}
+
+
`;
+ }
+
+ 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")}
+
+
+
+
+
+
+
+
+ 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 = [
+ `All editions `,
+ ...this.editions.map(
+ (edition) => `
+
+ ${esc(
+ edition.edition_name ||
+ edition.edition_code ||
+ edition.name
+ )}
+
+ `
+ )
+ ].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.name)}
+
+
+
+
+ ${esc(row.registration)}
+
+ ${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.registration)}
+
+ ${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.
+
+
+
+
+
+
+
+ Status
+ Transactions
+ Amount
+
+
+
+ ${statusRows}
+
+
+
+
+
+
+
+
+
Gateway summary
+
Collections by payment channel.
+
+
+
+
+
+
+
+ Gateway
+ Transactions
+ Amount
+ Paid
+ Pending
+ Failed
+
+
+
+ ${gatewayRows}
+
+
+
+
+
+
+
+
+
+
Payment transactions
+
Recent payment activity for the selected scope.
+
+
+
+ Open payment list
+
+
+
+
+
+
+
+ Date
+ Transaction
+ Registration
+ Edition
+ Gateway
+ Amount
+ Status
+ Reference
+
+
+
+ ${transactionRows}
+
+
+
+
+
+
+
+
+
Reconciliation exceptions
+
+ Registrations where amount due does not match
+ confirmed payment transactions.
+
+
+
+
+ Open reports
+
+
+
+
+
+
+
+ Registration
+ Amount due
+ Paid
+ Difference
+ Registration payment status
+
+
+
+ ${exceptionRows}
+
+
+
+
+ `);
+ }
+
+ 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")}
+
+
+
+
+
+ `);
+ }
+
+ 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) =>
+ `${esc(item.edition_name || item.name)} — ${esc(item.status || "")} `
+ ).join("");
+ this.$root.find("[data-edition]").html(options || `${esc(__("No editions found"))} `);
+ }
+
+ 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)}
+ ${esc(__("Open"))}
+
+ `).join("");
+ this.$root.find("[data-content]").html(`
+
+ ${esc(edition.status || "")} ${esc(edition.edition_name || edition.name)} ${esc(edition.event_date || "")} · ${esc(edition.venue || "")}
+ ${esc(__("Open edition"))}
+
+ ${cards.map((card) => `${esc(card[0])} ${esc(card[1] || 0)} ${esc(card[2])} `).join("")}
+
+ ${esc(__("Race categories"))} ${esc(__("Operational progress by category."))}
${esc(__("Open categories"))}
+ ${esc(__("Category"))} ${esc(__("Start"))} ${esc(__("Capacity"))} ${esc(__("Registered"))} ${esc(__("Paid"))} ${esc(__("Bibs"))} ${esc(__("Checked in"))} ${esc(__("Finished"))} ${rows || `${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) => `${esc(item[1])} ${esc(__("Open list"))} → `).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) =>
+ `${ICON[name]} `;
+
+ 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")}
+
+
+
+
+
+
+
`);
+ this.renderFilters();
+ this.renderTabs();
+ this.renderBody();
+ }
+
+ renderFilters() {
+ const o = this.options || { editions: [], categories: [] };
+ const s = this.state;
+ const editions = [`${esc(__("All editions"))} `]
+ .concat(o.editions.map((e) => `${esc(e.edition_name || e.name)} `))
+ .join("");
+ const cats = [`${esc(__("All categories"))} `]
+ .concat(
+ o.categories
+ .filter((c) => !s.edition || c.event_edition === s.edition)
+ .map((c) => `${esc(c.category_name)} `)
+ )
+ .join("");
+
+ this.$root.find(".lm-filters").html(`
+ ${esc(__("Event edition"))} ${editions}
+ ${esc(__("Race category"))} ${cats}
+ ${esc(__("From"))}
+ ${esc(__("To"))}
+ ${esc(__("Reset"))}
+ ${esc(NOTES[s.tab])}
`);
+ }
+
+ renderTabs() {
+ const tabs = TABS.map(
+ (t) =>
+ `${esc(t.label)} `
+ ).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(`${esc(__("Couldn't load these figures. Open Error Log to see what went wrong."))}
`);
+ 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 ``;
+ }
+
+ attention(d) {
+ if (!d.attention.length) return "";
+ const items = d.attention
+ .map((a, i) => {
+ const actionable = a.report || a.doctype;
+ return `
+ ${svg("alert", 16)}${esc(a.count.toLocaleString())} ${esc(a.text)} ${actionable ? svg("right", 14) : ""}
+ `;
+ })
+ .join("");
+ return `${esc(__("Needs attention"))} `;
+ }
+
+ 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 ? `${esc(__("Open report"))} ${svg("external", 13)} ` : ""}
+
+
+ `
+ )
+ .join("");
+ return ``;
+ }
+
+ 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 ? `${esc(__("Open full report"))} ${svg("external", 13)} ` : ""}` +
+ (t.rows.length ? `${svg("download", 13)} ${esc(__("Export CSV"))} ` : "");
+ return `
+
+ ${esc(t.title)} ${actions}
+ ${
+ t.rows.length
+ ? ``
+ : `${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 .
+ * Styling lives in public/css/lukenya_navigation.css — don't copy it
+ * into individual page .css files.
+ */
+frappe.provide("lukenya.nav");
+
+(function () {
+ const ICON = {
+ grid: ' ',
+ calendar: ' ',
+ users: ' ',
+ flag: ' ',
+ card: ' ',
+ chart: ' ',
+ sliders: ' ',
+ run: ' ',
+ };
+
+ const svg = (name, size) =>
+ `${ICON[name]} `;
+
+ const esc = (s) => frappe.utils.escape_html(s == null ? "" : String(s));
+
+ // Single source of truth for every lukenya-* page's left sidebar.
+ const NAV = [
+ { label: "Dashboard", icon: "grid", route: "lukenya-dashboard" },
+ { label: "Events", icon: "calendar", route: "lukenya-events" },
+ { label: "Participants", icon: "users", route: "lukenya-participants" },
+ { label: "Race Management", icon: "flag", route: "lukenya-race-management" },
+ { label: "Payments", icon: "card", route: "lukenya-payments" },
+ { label: "Reports", icon: "chart", route: "lukenya-reports" },
+ { label: "Settings", icon: "sliders", soon: true },
+ ];
+
+ // activeRoute: the calling page's own route, e.g. "lukenya-payments".
+ lukenya.nav.render = function (activeRoute) {
+ const items = NAV.map((n) => {
+ const attrs =
+ n.route === activeRoute
+ ? 'class="lm-nav__item is-active" aria-current="page"'
+ : n.soon
+ ? 'class="lm-nav__item is-soon" disabled title="' + esc(__("Coming soon")) + '"'
+ : `class="lm-nav__item" data-route="${esc(n.route)}"`;
+ return `${svg(n.icon, 18)}${esc(__(n.label))} `;
+ }).join("");
+
+ return `
+ `;
+ };
+
+ // Bound once, globally, so "Open full workspace" works on every page that
+ // renders the shared sidebar — no per-page wiring needed.
+ $(document).on("click", '.lm-side [data-action="workspace"]', function () {
+ frappe.set_route("Workspaces", "Lukenya Marathon");
+ });
+})();