Initial LuKenya Marathon application
CI / Server (push) Has been cancelled

This commit is contained in:
KabundiNjuki
2026-09-24 00:05:12 +03:00
parent 9c0e1be973
commit 2217484648
122 changed files with 16980 additions and 518 deletions
+8
View File
@@ -0,0 +1,8 @@
{
"lukenya_marathon.bundle.js": [
"public/js/lukenya_navigation.js"
],
"lukenya_marathon.bundle.css": [
"public/css/lukenya_navigation.css"
]
}
+39
View File
@@ -247,3 +247,42 @@ app_license = "mit"
# List of apps whose translatable strings should be excluded from this app's translations. # List of apps whose translatable strings should be excluded from this app's translations.
# ignore_translatable_strings_from = [] # 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"
]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+35
View File
@@ -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}
@@ -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"),
},
}
@@ -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"
}
@@ -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
}
@@ -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."))
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class CheckIn(Document):
pass
@@ -1,108 +1,129 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:edition_code", "autoname": "field:edition_code",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"edition_code", "edition_code",
"edition_name", "edition_name",
"event_date", "event_date",
"registration_open", "registration_open",
"registration_close", "registration_close",
"status", "early_bird_ends",
"venue", "status",
"county", "venue",
"description", "county",
"registration_capacity", "description",
"trees_commitment_per_entry" "registration_capacity",
], "trees_commitment_per_entry"
"fields": [ ],
{ "fields": [
"fieldname": "edition_code", {
"fieldtype": "Data", "fieldname": "edition_code",
"label": "Edition Code", "fieldtype": "Data",
"reqd": 1, "label": "Edition Code",
"unique": 1 "reqd": 1,
}, "unique": 1
{ },
"fieldname": "edition_name", {
"fieldtype": "Data", "fieldname": "edition_name",
"label": "Edition Name", "fieldtype": "Data",
"reqd": 1 "label": "Edition Name",
}, "reqd": 1
{ },
"fieldname": "event_date", {
"fieldtype": "Date", "fieldname": "event_date",
"label": "Event Date", "fieldtype": "Date",
"reqd": 1 "label": "Event Date",
}, "reqd": 1
{ },
"fieldname": "registration_open", {
"fieldtype": "Datetime", "fieldname": "registration_open",
"label": "Registration Open" "fieldtype": "Datetime",
}, "label": "Registration Open"
{ },
"fieldname": "registration_close", {
"fieldtype": "Datetime", "fieldname": "registration_close",
"label": "Registration Close" "fieldtype": "Datetime",
}, "label": "Registration Close"
{ },
"default": "Draft", {
"fieldname": "status", "fieldname": "early_bird_ends",
"fieldtype": "Select", "fieldtype": "Datetime",
"label": "Status", "label": "Early-bird Ends",
"options": "Draft\nOpen\nClosed\nCompleted\nCancelled", "description": "Early-bird prices apply to residents until this time. Leave empty to never offer them."
"reqd": 1 },
}, {
{ "default": "Draft",
"fieldname": "venue", "fieldname": "status",
"fieldtype": "Data", "fieldtype": "Select",
"label": "Venue", "label": "Status",
"reqd": 1 "options": "Draft\nOpen\nClosed\nCompleted\nCancelled",
}, "reqd": 1
{ },
"fieldname": "county", {
"fieldtype": "Data", "fieldname": "venue",
"label": "County" "fieldtype": "Data",
}, "label": "Venue",
{ "reqd": 1
"fieldname": "description", },
"fieldtype": "Text Editor", {
"label": "Public Description" "fieldname": "county",
}, "fieldtype": "Data",
{ "label": "County"
"fieldname": "registration_capacity", },
"fieldtype": "Int", {
"label": "Registration Capacity" "fieldname": "description",
}, "fieldtype": "Text Editor",
{ "label": "Public Description"
"default": "1", },
"fieldname": "trees_commitment_per_entry", {
"fieldtype": "Int", "fieldname": "registration_capacity",
"label": "Trees per Eligible Entry", "fieldtype": "Int",
"reqd": 1 "label": "Registration Capacity"
} },
], {
"links": [], "default": "1",
"module": "Lukenya Marathon", "fieldname": "trees_commitment_per_entry",
"name": "Event Edition", "fieldtype": "Int",
"permissions": [ "label": "Trees per Eligible Entry",
{ "reqd": 1
"create": 1, }
"delete": 1, ],
"email": 1, "links": [],
"export": 1, "module": "Lukenya Marathon",
"print": 1, "name": "Event Edition",
"read": 1, "permissions": [
"report": 1, {
"role": "System Manager", "create": 1,
"share": 1, "delete": 1,
"write": 1 "email": 1,
} "export": 1,
], "print": 1,
"sort_field": "modified", "read": 1,
"sort_order": "DESC", "report": 1,
"track_changes": 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"
} }
@@ -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
}
@@ -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("<br>".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:") + "<br>" + "<br>".join(lines))
@@ -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
}
@@ -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."))
@@ -1,133 +1,181 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:participant_code", "autoname": "field:participant_code",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"participant_code", "participant_code",
"first_name", "first_name",
"last_name", "last_name",
"id_number", "full_name",
"date_of_birth", "id_number",
"gender", "date_of_birth",
"nationality", "gender",
"email", "nationality",
"phone", "email",
"whatsapp", "phone",
"shirt_size", "whatsapp",
"emergency_contact_name", "shirt_size",
"emergency_contact_phone", "emergency_contact_name",
"medical_notes", "emergency_contact_phone",
"communication_consent" "guardian_name",
], "guardian_phone",
"fields": [ "medical_notes",
{ "communication_consent"
"fieldname": "participant_code", ],
"fieldtype": "Data", "fields": [
"label": "Participant Code", {
"reqd": 1, "fieldname": "participant_code",
"unique": 1 "fieldtype": "Data",
}, "label": "Participant Code",
{ "reqd": 0,
"fieldname": "first_name", "unique": 1,
"fieldtype": "Data", "description": "Generated automatically when left empty"
"label": "First Name", },
"reqd": 1 {
}, "fieldname": "first_name",
{ "fieldtype": "Data",
"fieldname": "last_name", "label": "First Name",
"fieldtype": "Data", "reqd": 1
"label": "Last Name", },
"reqd": 1 {
}, "fieldname": "last_name",
{ "fieldtype": "Data",
"fieldname": "id_number", "label": "Last Name",
"fieldtype": "Data", "reqd": 1
"label": "ID / Passport Number", },
"unique": 1 {
}, "fieldname": "full_name",
{ "fieldtype": "Data",
"fieldname": "date_of_birth", "label": "Full Name",
"fieldtype": "Date", "read_only": 1,
"label": "Date of Birth" "in_list_view": 1,
}, "bold": 1,
{ "no_copy": 1
"fieldname": "gender", },
"fieldtype": "Select", {
"label": "Gender", "fieldname": "id_number",
"options": "Male\nFemale\nOther\nPrefer not to say" "fieldtype": "Data",
}, "label": "ID / Passport Number",
{ "unique": 1,
"default": "Kenya", "permlevel": 1
"fieldname": "nationality", },
"fieldtype": "Data", {
"label": "Nationality" "fieldname": "date_of_birth",
}, "fieldtype": "Date",
{ "label": "Date of Birth"
"fieldname": "email", },
"fieldtype": "Data", {
"label": "Email", "fieldname": "gender",
"options": "Email" "fieldtype": "Select",
}, "label": "Gender",
{ "options": "Male\nFemale\nOther\nPrefer not to say"
"fieldname": "phone", },
"fieldtype": "Data", {
"label": "Phone", "default": "Kenya",
"reqd": 1 "fieldname": "nationality",
}, "fieldtype": "Data",
{ "label": "Nationality"
"fieldname": "whatsapp", },
"fieldtype": "Data", {
"label": "WhatsApp" "fieldname": "email",
}, "fieldtype": "Data",
{ "label": "Email",
"fieldname": "shirt_size", "options": "Email"
"fieldtype": "Select", },
"label": "Shirt Size", {
"options": "XS\nS\nM\nL\nXL\nXXL" "fieldname": "phone",
}, "fieldtype": "Data",
{ "label": "Phone",
"fieldname": "emergency_contact_name", "reqd": 1
"fieldtype": "Data", },
"label": "Emergency Contact Name" {
}, "fieldname": "whatsapp",
{ "fieldtype": "Data",
"fieldname": "emergency_contact_phone", "label": "WhatsApp"
"fieldtype": "Data", },
"label": "Emergency Contact Phone" {
}, "fieldname": "shirt_size",
{ "fieldtype": "Select",
"fieldname": "medical_notes", "label": "Shirt Size",
"fieldtype": "Small Text", "options": "XS\nS\nM\nL\nXL\nXXL"
"label": "Medical / Accessibility Notes", },
"permlevel": 1 {
}, "fieldname": "emergency_contact_name",
{ "fieldtype": "Data",
"fieldname": "communication_consent", "label": "Emergency Contact Name"
"fieldtype": "Check", },
"label": "Communication Consent" {
} "fieldname": "emergency_contact_phone",
], "fieldtype": "Data",
"links": [], "label": "Emergency Contact Phone"
"module": "Lukenya Marathon", },
"name": "Participant", {
"permissions": [ "fieldname": "guardian_name",
{ "fieldtype": "Data",
"create": 1, "label": "Guardian Name",
"delete": 1, "description": "For runners under 18"
"email": 1, },
"export": 1, {
"print": 1, "fieldname": "guardian_phone",
"read": 1, "fieldtype": "Data",
"report": 1, "label": "Guardian Phone"
"role": "System Manager", },
"share": 1, {
"write": 1 "fieldname": "medical_notes",
} "fieldtype": "Small Text",
], "label": "Medical / Accessibility Notes",
"sort_field": "modified", "permlevel": 1
"sort_order": "DESC", },
"track_changes": 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"
} }
@@ -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"
}
@@ -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."))
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class PaymentTransaction(Document):
pass
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class PrizeAward(Document):
pass
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class PrizeRule(Document):
pass
@@ -1,132 +1,147 @@
{ {
"actions": [], "actions": [],
"allow_rename": 1, "allow_rename": 1,
"autoname": "field:category_code", "autoname": "field:category_code",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"category_code", "category_code",
"category_name", "category_name",
"event_edition", "event_edition",
"discipline", "discipline",
"distance_km", "distance_km",
"start_time", "start_time",
"cutoff_hours", "cutoff_hours",
"eligibility", "eligibility",
"kenyan_price", "kenyan_price",
"non_resident_price", "non_resident_price",
"early_bird_price", "early_bird_price",
"capacity", "capacity",
"active", "active",
"prize_eligible", "prize_eligible",
"inclusions" "inclusions"
], ],
"fields": [ "fields": [
{ {
"fieldname": "category_code", "fieldname": "category_code",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Category Code", "label": "Category Code",
"reqd": 1, "reqd": 1,
"unique": 1 "unique": 1
}, },
{ {
"fieldname": "category_name", "fieldname": "category_name",
"fieldtype": "Data", "fieldtype": "Data",
"label": "Category Name", "label": "Category Name",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "event_edition", "fieldname": "event_edition",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Event Edition", "label": "Event Edition",
"options": "Event Edition", "options": "Event Edition",
"reqd": 1 "reqd": 1
}, },
{ {
"default": "Running", "default": "Running",
"fieldname": "discipline", "fieldname": "discipline",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Discipline", "label": "Discipline",
"options": "Running\nCycling\nVirtual\nRelay", "options": "Running\nCycling\nVirtual\nRelay",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "distance_km", "fieldname": "distance_km",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Distance (KM)" "label": "Distance (KM)"
}, },
{ {
"fieldname": "start_time", "fieldname": "start_time",
"fieldtype": "Time", "fieldtype": "Time",
"label": "Start Time" "label": "Start Time"
}, },
{ {
"fieldname": "cutoff_hours", "fieldname": "cutoff_hours",
"fieldtype": "Float", "fieldtype": "Float",
"label": "Cut-off (Hours)" "label": "Cut-off (Hours)"
}, },
{ {
"fieldname": "eligibility", "fieldname": "eligibility",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Eligibility Rules" "label": "Eligibility Rules"
}, },
{ {
"fieldname": "kenyan_price", "fieldname": "kenyan_price",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Kenyan Price", "label": "Kenyan Price",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "non_resident_price", "fieldname": "non_resident_price",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Non-resident Price" "label": "Non-resident Price"
}, },
{ {
"fieldname": "early_bird_price", "fieldname": "early_bird_price",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Early Bird Price" "label": "Early Bird Price"
}, },
{ {
"fieldname": "capacity", "fieldname": "capacity",
"fieldtype": "Int", "fieldtype": "Int",
"label": "Capacity" "label": "Capacity"
}, },
{ {
"default": "1", "default": "1",
"fieldname": "active", "fieldname": "active",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Active" "label": "Active"
}, },
{ {
"fieldname": "prize_eligible", "fieldname": "prize_eligible",
"fieldtype": "Check", "fieldtype": "Check",
"label": "Prize Eligible" "label": "Prize Eligible"
}, },
{ {
"fieldname": "inclusions", "fieldname": "inclusions",
"fieldtype": "Small Text", "fieldtype": "Small Text",
"label": "Inclusions" "label": "Inclusions"
} }
], ],
"links": [], "links": [],
"module": "Lukenya Marathon", "module": "Lukenya Marathon",
"name": "Race Category", "name": "Race Category",
"permissions": [ "permissions": [
{ {
"create": 1, "create": 1,
"delete": 1, "delete": 1,
"email": 1, "email": 1,
"export": 1, "export": 1,
"print": 1, "print": 1,
"read": 1, "read": 1,
"report": 1, "report": 1,
"role": "System Manager", "role": "System Manager",
"share": 1, "share": 1,
"write": 1 "write": 1
} },
], {
"sort_field": "modified", "role": "Registration Desk",
"sort_order": "DESC", "read": 1
"track_changes": 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"
} }
@@ -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
}
@@ -0,0 +1,6 @@
import frappe
from frappe.model.document import Document
class RaceCategory(Document):
pass
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class RacePackIssue(Document):
pass
@@ -1,151 +1,172 @@
{ {
"actions": [], "actions": [],
"allow_rename": 0, "allow_rename": 0,
"autoname": "format:REG-{####}", "autoname": "format:REG-{####}",
"doctype": "DocType", "doctype": "DocType",
"editable_grid": 1, "editable_grid": 1,
"engine": "InnoDB", "engine": "InnoDB",
"field_order": [ "field_order": [
"participant", "participant",
"event_edition", "event_edition",
"race_category", "race_category",
"registration_status", "registration_status",
"registration_date", "registration_date",
"price_tier", "price_tier",
"amount_due", "amount_due",
"payment_status", "payment_status",
"payment_reference", "payment_reference",
"bib_number", "bib_number",
"chip_number", "chip_number",
"race_pack_status", "race_pack_status",
"check_in_status", "check_in_status",
"indemnity_accepted", "indemnity_accepted",
"consent_timestamp", "consent_timestamp",
"transfer_notes" "transfer_notes"
], ],
"fields": [ "fields": [
{ {
"fieldname": "participant", "fieldname": "participant",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Participant", "label": "Participant",
"options": "Participant", "options": "Participant",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "event_edition", "fieldname": "event_edition",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Event Edition", "label": "Event Edition",
"options": "Event Edition", "options": "Event Edition",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "race_category", "fieldname": "race_category",
"fieldtype": "Link", "fieldtype": "Link",
"label": "Race Category", "label": "Race Category",
"options": "Race Category", "options": "Race Category",
"reqd": 1 "reqd": 1
}, },
{ {
"default": "Pending", "default": "Pending",
"fieldname": "registration_status", "fieldname": "registration_status",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Registration Status", "label": "Registration Status",
"options": "Pending\nConfirmed\nCancelled\nTransferred\nRefunded\nCompleted", "options": "Pending\nConfirmed\nCancelled\nTransferred\nRefunded\nCompleted",
"reqd": 1 "reqd": 1
}, },
{ {
"fieldname": "registration_date", "fieldname": "registration_date",
"fieldtype": "Datetime", "fieldtype": "Datetime",
"label": "Registration Date", "label": "Registration Date",
"default": "Now" "default": "Now"
}, },
{ {
"default": "Standard", "default": "Standard",
"fieldname": "price_tier", "fieldname": "price_tier",
"fieldtype": "Select", "fieldtype": "Select",
"label": "Price Tier", "label": "Price Tier",
"options": "Early Bird\nStandard\nNon-resident\nCorporate\nComplimentary" "options": "Early Bird\nStandard\nNon-resident\nCorporate\nComplimentary"
}, },
{ {
"fieldname": "amount_due", "fieldname": "amount_due",
"fieldtype": "Currency", "fieldtype": "Currency",
"label": "Amount Due", "label": "Amount Due",
"reqd": 1 "reqd": 1,
}, "default": "0"
{ },
"default": "Unpaid", {
"fieldname": "payment_status", "default": "Unpaid",
"fieldtype": "Select", "fieldname": "payment_status",
"label": "Payment Status", "fieldtype": "Select",
"options": "Unpaid\nPending\nPaid\nFailed\nPartially Paid\nRefunded", "label": "Payment Status",
"reqd": 1 "options": "Unpaid\nPending\nPaid\nFailed\nPartially Paid\nRefunded",
}, "reqd": 1
{ },
"fieldname": "payment_reference", {
"fieldtype": "Data", "fieldname": "payment_reference",
"label": "Payment Reference" "fieldtype": "Data",
}, "label": "Payment Reference"
{ },
"fieldname": "bib_number", {
"fieldtype": "Data", "fieldname": "bib_number",
"label": "Bib Number", "fieldtype": "Data",
"unique": 1 "label": "Bib Number",
}, "unique": 1,
{ "read_only": 1
"fieldname": "chip_number", },
"fieldtype": "Data", {
"label": "Chip Number", "fieldname": "chip_number",
"unique": 1 "fieldtype": "Data",
}, "label": "Chip Number",
{ "unique": 1,
"default": "Not Issued", "read_only": 1
"fieldname": "race_pack_status", },
"fieldtype": "Select", {
"label": "Race Pack Status", "default": "Not Issued",
"options": "Not Issued\nReady\nIssued\nCollected\nUncollected" "fieldname": "race_pack_status",
}, "fieldtype": "Select",
{ "label": "Race Pack Status",
"default": "Not Checked In", "options": "Not Issued\nReady\nIssued\nCollected\nUncollected",
"fieldname": "check_in_status", "read_only": 1
"fieldtype": "Select", },
"label": "Check-in Status", {
"options": "Not Checked In\nChecked In\nDid Not Start\nFinished" "default": "Not Checked In",
}, "fieldname": "check_in_status",
{ "fieldtype": "Select",
"fieldname": "indemnity_accepted", "label": "Check-in Status",
"fieldtype": "Check", "options": "Not Checked In\nChecked In\nDid Not Start\nFinished",
"label": "Indemnity Accepted", "read_only": 1
"reqd": 1 },
}, {
{ "fieldname": "indemnity_accepted",
"fieldname": "consent_timestamp", "fieldtype": "Check",
"fieldtype": "Datetime", "label": "Indemnity Accepted",
"label": "Consent Timestamp" "reqd": 1
}, },
{ {
"fieldname": "transfer_notes", "fieldname": "consent_timestamp",
"fieldtype": "Small Text", "fieldtype": "Datetime",
"label": "Transfer / Admin Notes" "label": "Consent Timestamp"
} },
], {
"links": [], "fieldname": "transfer_notes",
"module": "Lukenya Marathon", "fieldtype": "Small Text",
"name": "Registration", "label": "Transfer / Admin Notes"
"permissions": [ }
{ ],
"create": 1, "links": [],
"delete": 1, "module": "Lukenya Marathon",
"email": 1, "name": "Registration",
"export": 1, "permissions": [
"print": 1, {
"read": 1, "create": 1,
"report": 1, "delete": 1,
"role": "System Manager", "email": 1,
"share": 1, "export": 1,
"write": 1 "print": 1,
} "read": 1,
], "report": 1,
"sort_field": "modified", "role": "System Manager",
"sort_order": "DESC", "share": 1,
"track_changes": 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"
} }
@@ -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
}
@@ -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.")
)
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class TimingResult(Document):
pass
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class TreeImpactRecord(Document):
pass
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class Volunteer(Document):
pass
@@ -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"
}
@@ -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
}
@@ -0,0 +1,5 @@
from frappe.model.document import Document
class VolunteerShift(Document):
pass
@@ -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"),
}
@@ -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()
@@ -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;
}
}
@@ -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: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
card: '<rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
chart: '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/>',
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
award: '<circle cx="12" cy="8" r="7"/><polyline points="8.21 13.89 7 23 12 20 17 23 15.79 13.88"/>',
run: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
heart: '<path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/>',
file: '<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/>',
right: '<polyline points="9 18 15 12 9 6"/>',
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
zap: '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
bulb: '<path d="M9 18h6"/><path d="M10 22h4"/><path d="M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.3 1 2.3h6c0-1 .4-1.8 1-2.3A7 7 0 0 0 12 2z"/>',
tool: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
up: '<line x1="12" y1="19" x2="12" y2="5"/><polyline points="5 12 12 5 19 12"/>',
down: '<line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/>',
};
const svg = (name, size) =>
`<svg class="lm-ico" width="${size || 20}" height="${size || 20}" viewBox="0 0 24 24" fill="none" ` +
`stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[name]}</svg>`;
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(
`<div class="lm-app">${this.sidebar()}<main class="lm-main">${this.hero()}${this.kpiRow()}${this.operations()}</main></div>`
);
}
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 `
<section class="lm-hero">
<div class="lm-hero__text">
<img class="lm-hero__logo" src="/assets/lukenya_marathon/images/lukenya-logo.png" alt="Lukenya Marathon">
<h1>${esc(__("Welcome back, {0}", [first]))} <span aria-hidden="true">&#128075;</span></h1>
<p>${esc(__("Here's what's happening with {0}", [edition || __("Lukenya Marathon")]))}</p>
</div>
<div class="lm-hero__art" aria-hidden="true">
<span class="lm-hero__tag">${esc(__("More Runners,"))}<br>${esc(__("Bigger Impact"))}</span>
</div>
</section>`;
}
kpiRow() {
if (this.error) {
return `<div class="lm-notice" role="alert">${esc(
__("Couldn't load dashboard figures. Open Error Log to see what went wrong.")
)}</div>`;
}
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())
: "&ndash;";
return `
<button type="button" class="lm-kpi lm-tone-${k.tone}" ${k.route ? `data-route="${esc(k.route)}"` : `data-doctype="${esc(k.doctype)}"`}>
<span class="lm-chip">${svg(k.icon, 22)}</span>
<span class="lm-kpi__body">
<span class="lm-kpi__label">${esc(k.label)}</span>
<span class="lm-kpi__value">${valueHtml}</span>
${this.trend(kpi, k)}
</span>
<span class="lm-kpi__go">${svg("right", 16)}</span>
</button>`;
}).join("");
return `<section class="lm-kpis">${cards}</section>`;
}
trend(kpi, cfg) {
if (!kpi) return `<span class="lm-trend is-flat">&nbsp;</span>`;
if (kpi.trend == null) {
return `<span class="lm-trend is-flat">${esc(cfg.compare)}</span>`;
}
if (kpi.trend === 0) {
return `<span class="lm-trend is-flat">${esc(__("No change"))} ${esc(cfg.compare)}</span>`;
}
const up = kpi.trend > 0;
return `<span class="lm-trend ${up ? "is-up" : "is-down"}">${svg(up ? "up" : "down", 12)}${Math.abs(kpi.trend)}% <em>${esc(cfg.compare)}</em></span>`;
}
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"
? `<button type="button" class="lm-pill" data-doctype="${esc(pill)}">${esc(__(pill) + "s")}</button>`
: `<button type="button" class="lm-pill" data-route="${esc(pill.route)}">${esc(pill.label)}</button>`
)
.join("");
const count = t.soon || t.nocount ? "" : `<span class="lm-count">${counts[t.id] != null ? counts[t.id] : "&ndash;"}</span>`;
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 `
<div ${attrs}>
<span class="lm-chip lm-tone-${t.tone}">${svg(t.icon, 22)}</span>
<div class="lm-tile__body">
<h3>${esc(t.title)}</h3>
<p>${esc(t.desc)}</p>
${t.soon ? `<span class="lm-soon">${esc(__("Coming soon"))}</span>` : `<div class="lm-tile__foot">${count}${pills}</div>`}
</div>
${t.soon ? "" : `<span class="lm-tile__go">${svg("right", 16)}</span>`}
</div>`;
}).join("");
const actions = QUICK_ACTIONS.filter((a) => canRead(a.doctype))
.map((a) => `<button type="button" class="lm-menu__item" data-new="${esc(a.doctype)}">${esc(a.label)}</button>`)
.join("");
return `
<section class="lm-ops">
<header class="lm-ops__head">
<span class="lm-ops__mark">${svg("tool", 22)}</span>
<div>
<h2>${esc(__("Marathon Operations"))}</h2>
<p>${esc(__("Manage all event-related activities from one place."))}</p>
</div>
<div class="lm-menu">
<button type="button" class="lm-quick" data-action="menu" aria-haspopup="true" aria-expanded="false">
${svg("zap", 16)}<span>${esc(__("Quick Actions"))}</span>
</button>
<div class="lm-menu__list" hidden>${actions}</div>
</div>
</header>
<div class="lm-grid">${tiles}</div>
<footer class="lm-newevent">
<span class="lm-newevent__icon">${svg("bulb", 20)}</span>
<div>
<strong>${esc(__("Need to create a new event?"))}</strong>
<span>${esc(__("Set up a new edition, then add its race categories."))}</span>
</div>
<button type="button" class="lm-cta" data-new="Event Edition">${svg("plus", 16)}<span>${esc(__("New Event"))}</span></button>
</footer>
</section>`;
}
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();
};
})();
@@ -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"
}
@@ -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));
}
}
@@ -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(`
<div class="lm-events-layout">
${lukenya.nav.render("lukenya-events")}
<main class="lm-events-main">
<header class="lm-events-header">
<div>
<p class="lm-events-eyebrow">
Event setup
</p>
<h1>Events</h1>
<p class="lm-events-muted">
Manage marathon editions, dates, venues,
race categories and registration capacity.
</p>
</div>
<div class="lm-events-actions">
<button class="btn btn-primary"
data-new="Event Edition">
New Event Edition
</button>
<button class="btn btn-default"
data-new="Race Category">
New Race Category
</button>
<button class="btn btn-default"
data-action="refresh">
Refresh
</button>
</div>
</header>
<div data-content>
<div class="lm-events-card">
Loading events...
</div>
</div>
</main>
</div>
`);
}
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(`
<div class="lm-events-card lm-events-loading">
Loading events...
</div>
`);
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("")
: `
<div class="lm-events-card lm-events-empty">
No Event Editions found.
Create your first Event Edition to continue.
</div>
`;
this.$root.find("[data-content]").html(`
<section class="lm-events-kpis">
${cards.map((card) => `
<div class="lm-events-card lm-events-kpi">
<span>${esc(card[0])}</span>
<strong>${esc(card[1])}</strong>
</div>
`).join("")}
</section>
<section class="lm-events-list">
${editionCards}
</section>
`);
}
renderEdition(edition) {
const categories = edition.categories || [];
const categoryRows = categories.length
? categories.map((category) => `
<tr>
<td>
<button class="lm-events-link"
data-open-doc="Race Category"
data-name="${esc(category.name)}">
${esc(
category.category_name ||
category.name
)}
</button>
</td>
<td>${esc(category.discipline || "-")}</td>
<td>${esc(category.distance_km || "-")} km</td>
<td>${esc(category.start_time || "-")}</td>
<td>${esc(category.capacity || 0)}</td>
<td>${esc(category.kenyan_price || 0)}</td>
<td>
<span class="${category.active ? "lm-active" : "lm-inactive"}">
${category.active ? "Active" : "Inactive"}
</span>
</td>
</tr>
`).join("")
: `
<tr>
<td colspan="7">
No race categories configured.
</td>
</tr>
`;
return `
<article class="lm-events-card lm-events-edition">
<header class="lm-events-edition-header">
<div>
<div class="lm-events-status">
${esc(edition.status || "")}
</div>
<h2>
${esc(
edition.edition_name ||
edition.name
)}
</h2>
<p class="lm-events-muted">
Code: ${esc(edition.edition_code || "-")}
· Date: ${esc(edition.event_date || "-")}
· Venue: ${esc(edition.venue || "-")}
</p>
</div>
<div class="lm-events-edition-actions">
<button class="btn btn-default"
data-open-doc="Event Edition"
data-name="${esc(edition.name)}">
Open edition
</button>
<button class="btn btn-default"
data-open-list="Registration"
data-filters='${jsonFilters({
event_edition: edition.name
})}'>
View registrations
</button>
</div>
</header>
<div class="lm-events-stats">
<div>
<span>Registrations</span>
<strong>${esc(edition.registration_count || 0)}</strong>
</div>
<div>
<span>Paid entries</span>
<strong>${esc(edition.paid_count || 0)}</strong>
</div>
<div>
<span>Categories</span>
<strong>${esc(edition.category_count || 0)}</strong>
</div>
<div>
<span>Capacity</span>
<strong>${esc(edition.registration_capacity || 0)}</strong>
</div>
</div>
<div class="lm-events-section-title">
<div>
<h3>Race categories</h3>
<p class="lm-events-muted">
Categories configured for this edition.
</p>
</div>
<button class="btn btn-default"
data-open-list="Race Category"
data-filters='${jsonFilters({
event_edition: edition.name
})}'>
Open categories
</button>
</div>
<div class="table-responsive">
<table class="table lm-events-table">
<thead>
<tr>
<th>Category</th>
<th>Discipline</th>
<th>Distance</th>
<th>Start</th>
<th>Capacity</th>
<th>Kenyan price</th>
<th>Status</th>
</tr>
</thead>
<tbody>
${categoryRows}
</tbody>
</table>
</div>
</article>
`;
}
showError(message) {
this.$root.find("[data-content]").html(`
<div class="lm-events-card lm-events-error">
${esc(message)}
</div>
`);
}
}
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();
}
};
})();
@@ -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"
}
@@ -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: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
card: '<rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
chart: '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/>',
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
run: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
right: '<polyline points="9 18 15 12 9 6"/>',
alert: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
search: '<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>',
x: '<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>',
plus: '<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>',
phone: '<path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/>',
mail: '<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"/><polyline points="22,6 12,13 2,6"/>',
chat: '<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
edit: '<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>',
eye: '<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>',
merge: '<circle cx="18" cy="18" r="3"/><circle cx="6" cy="6" r="3"/><path d="M6 21V9a9 9 0 0 0 9 9"/>',
shield: '<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>',
userplus: '<path d="M16 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="8.5" cy="7" r="4"/><line x1="20" y1="8" x2="20" y2="14"/><line x1="23" y1="11" x2="17" y2="11"/>',
};
const svg = (name, size) =>
`<svg class="lm-ico" width="${size || 18}" height="${size || 18}" viewBox="0 0 24 24" fill="none" ` +
`stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[name]}</svg>`;
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) => `<span class="lm-badge lm-badge--${tone || "grey"}">${esc(text)}</span>`;
// ---- 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(`
<div class="lm-app">
${lukenya.nav.render("lukenya-participants")}
<main class="lm-main lm-rep lm-pp">
<header class="lm-pp__head">
<div>
<h1>${esc(__("Participants"))}</h1>
<p>${esc(__("Find a runner, see their history, and register them for a race."))}</p>
</div>
<div class="lm-pp__actions">
<button type="button" class="lm-btn" data-action="duplicates">${svg("merge", 16)}<span class="lm-dupbtn">${esc(__("Possible duplicates"))}</span></button>
<button type="button" class="lm-btn" data-action="export">${svg("download", 16)}<span>${esc(__("Export CSV"))}</span></button>
<button type="button" class="lm-cta" data-action="new">${svg("userplus", 16)}<span>${esc(__("New participant"))}</span></button>
</div>
</header>
<div class="lm-pp__search">
${svg("search", 18)}
<input type="search" class="lm-search" data-search placeholder="${esc(__("Search name, ID, phone, email, code or bib"))}" aria-label="${esc(__("Search participants"))}" autocomplete="off">
</div>
<section class="lm-stats lm-pp__stats" style="--lm-cols:3"></section>
<section class="lm-filters"></section>
<nav class="lm-chips" aria-label="${esc(__("Segments"))}"></nav>
<div class="lm-pp__results"></div>
<div class="lm-pp__dups" hidden></div>
</main>
<div class="lm-scrim" data-action="close" hidden></div>
<aside class="lm-drawer" hidden aria-label="${esc(__("Participant profile"))}"></aside>
</div>`);
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 ? "&ndash;" : 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" : ""}">
<span class="lm-stat__label">${esc(c.label)}</span>
<span class="lm-stat__value">${value}</span>
<span class="lm-stat__hint">${esc(c.hint)}</span>
</${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 = [`<option value="">${esc(blank)}</option>`]
.concat(values.map((v) => {
const value = typeof v === "string" ? v : v.value;
const text = typeof v === "string" ? v : v.label;
return `<option value="${esc(value)}"${value === current ? " selected" : ""}>${esc(text)}</option>`;
}))
.join("");
return `<label class="lm-field"><span>${esc(label)}</span><select data-filter="${key}">${opts}</select></label>`;
}
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")) +
`<button type="button" class="lm-reset" data-action="reset">${esc(__("Reset"))}</button>`
);
}
renderChips() {
const s = this.state;
this.$root.find(".lm-chips").html(
CHIPS.map((c) => {
const off = c.edition && !s.edition;
return `<button type="button" class="lm-chip2${c.key === s.chip ? " is-active" : ""}" data-chip="${c.key}"${off ? ' disabled title="' + esc(__("Choose an edition first")) + '"' : ""}>${esc(c.label)}</button>`;
}).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(`<div class="lm-loading">${esc(__("Loading participants..."))}</div>`);
return;
}
if (this.error || !this.result) {
$box.html(`<div class="lm-notice" role="alert">${esc(__("Couldn't load participants. Open Error Log to see what went wrong."))}</div>`);
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(`
<section class="lm-panel is-wide lm-pp__panel${this.loading ? " is-loading" : ""}">
${
r.rows.length
? `<div class="lm-tablewrap"><table class="lm-table lm-people">
<thead><tr><th>${esc(__("Runner"))}</th><th>${esc(__("Age / gender"))}</th><th>${esc(__("Nationality"))}</th><th>${esc(__("Contact"))}</th><th>${esc(__("Latest entry"))}</th><th>${esc(__("Status"))}</th><th>${esc(__("Bib"))}</th><th>${esc(__("Flags"))}</th></tr></thead>
<tbody>${rows}</tbody></table></div>`
: `<p class="lm-empty">${esc(__("No participants match these filters."))}</p>`
}
<footer class="lm-pager">
<span>${esc(__("{0} to {1} of {2}", [from, to, r.total.toLocaleString()]))}</span>
<div>
<button type="button" class="lm-btn" data-page="-1"${r.page <= 1 ? " disabled" : ""}>${esc(__("Previous"))}</button>
<span class="lm-pager__page">${esc(__("Page {0} of {1}", [r.page, pages]))}</span>
<button type="button" class="lm-btn" data-page="1"${r.page >= pages ? " disabled" : ""}>${esc(__("Next"))}</button>
</div>
</footer>
</section>`);
}
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 ? "&ndash;" : esc(p.age);
return `
<tr class="lm-row" tabindex="0" data-open="${esc(p.name)}">
<td><span class="lm-person">${esc(p.full_name)}</span><span class="lm-sub">${esc(p.code)}</span></td>
<td>${age}${p.gender ? " &middot; " + esc(p.gender) : ""}</td>
<td>${esc(p.nationality) || "&ndash;"}</td>
<td><span>${esc(p.phone) || "&ndash;"}</span><span class="lm-sub">${esc(p.email)}</span></td>
<td>${e ? `<span>${esc(e.category || "")}</span><span class="lm-sub">${esc(e.edition)}</span>` : `<span class="lm-sub">${esc(__("No entry"))}</span>`}</td>
<td>${e ? badge(e.status, STATUS_TONE[e.status]) + " " + badge(e.payment, PAYMENT_TONE[e.payment]) : ""}</td>
<td>${e && e.bib ? esc(e.bib) : "&ndash;"}</td>
<td class="lm-flags">${flags}</td>
</tr>`;
}
// -- 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(`<div class="lm-loading">${esc(__("Loading profile..."))}</div>`);
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(" &middot; ");
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) =>
`<div class="lm-kv"><dt>${esc(label)}</dt><dd>${value || '<span class="lm-sub">&ndash;</span>'}</dd></div>`;
const link = (href, text) => (text ? `<a class="lm-link" href="${esc(href)}">${esc(text)}</a>` : "");
const guardian = p.minor || p.guardian_name || p.guardian_phone
? `<section class="lm-sec"><h3>${esc(__("Guardian"))}</h3><dl>${line(__("Name"), esc(p.guardian_name))}${line(__("Phone"), link("tel:" + p.guardian_phone, p.guardian_phone))}</dl>
${p.minor && !p.guardian_name ? `<p class="lm-warn">${svg("alert", 14)} ${esc(__("No guardian details on file for this runner under 18."))}</p>` : ""}</section>`
: "";
const sensitive = `
<section class="lm-sec">
<h3>${svg("shield", 15)} ${esc(__("Restricted details"))}</h3>
<dl>
${line(__("ID / passport"), p.id_number ? esc(p.id_number) : "")}
${line(__("Medical notes"), p.revealed ? (p.medical_notes ? `<span class="lm-note">${esc(p.medical_notes)}</span>` : esc(__("None on file"))) : p.has_medical_notes ? esc(__("On file")) : esc(__("None on file")))}
</dl>
${
p.revealed
? `<p class="lm-sub">${esc(__("Viewing these details was recorded on the runner's timeline."))}</p>`
: `<button type="button" class="lm-btn" data-action="reveal">${svg("eye", 15)}<span>${esc(__("Reveal ID and medical notes"))}</span></button>`
}
</section>`;
const entryCards = entries.length
? entries.map((e) => this.entryCard(e)).join("")
: `<p class="lm-empty lm-empty--tight">${esc(__("No entries yet."))}</p>`;
const canRegister = !!(this.state.edition || open_edition);
$d.html(`
<header class="lm-drawer__head">
<div>
<h2>${esc(p.full_name)}</h2>
<p>${meta}</p>
<div class="lm-drawer__flags">${flags.join(" ")}</div>
</div>
<button type="button" class="lm-iconbtn" data-action="close" aria-label="${esc(__("Close"))}">${svg("x", 18)}</button>
</header>
<div class="lm-drawer__actions">
<button type="button" class="lm-btn" data-action="edit">${svg("edit", 15)}<span>${esc(__("Edit"))}</span></button>
${canRegister ? `<button type="button" class="lm-cta" data-action="register">${svg("plus", 15)}<span>${esc(__("Register for a race"))}</span></button>` : ""}
${p.phone ? `<a class="lm-btn" href="tel:${esc(p.phone)}">${svg("phone", 15)}<span>${esc(__("Call"))}</span></a>` : ""}
${digits ? `<a class="lm-btn" href="https://wa.me/${esc(digits)}" target="_blank" rel="noopener">${svg("chat", 15)}<span>${esc(__("WhatsApp"))}</span></a>` : ""}
</div>
${this.registerForm()}
<section class="lm-sec"><h3>${esc(__("Contact"))}</h3><dl>
${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 ")))}
</dl></section>
${guardian}
<section class="lm-sec"><h3>${esc(__("Details"))}</h3><dl>
${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)))}
</dl></section>
${sensitive}
<section class="lm-sec"><h3>${esc(__("Entries"))}</h3>${entryCards}</section>`);
}
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
? `<p class="lm-entry__result">${esc(e.result.status)}${e.result.time ? " &middot; " + esc(e.result.time) : ""}${e.result.rank ? " &middot; " + esc(__("Overall {0}", [e.result.rank])) : ""}</p>`
: "";
return `
<article class="lm-entry">
<header>
<strong>${esc(e.edition)}</strong>
<span>${badge(e.status, STATUS_TONE[e.status])} ${badge(e.payment, PAYMENT_TONE[e.payment])}</span>
</header>
<p>${esc(e.category)}${e.tier ? " &middot; " + esc(e.tier) : ""}</p>
<p class="lm-sub">${esc(money(e.paid))} ${esc(__("paid of"))} ${esc(money(e.amount_due))}${e.balance ? " &middot; " + esc(__("balance")) + " " + esc(money(e.balance)) : ""}</p>
${bits.length ? `<p class="lm-sub">${bits.join(" &middot; ")}</p>` : ""}
${result}
<button type="button" class="lm-linkbtn" data-doc="Registration" data-name="${esc(e.registration)}">${esc(__("Open registration"))} ${svg("external", 13)}</button>
</article>`;
}
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 `<option value="${esc(c.name)}"${c.name === r.category ? " selected" : ""}${full ? " disabled" : ""}>${esc(c.category_name + note)}</option>`;
})
.join("");
const tiers = this.options.price_tiers
.map((t) => `<option value="${esc(t)}"${t === r.tier ? " selected" : ""}>${esc(t)}</option>`)
.join("");
const p = r.preview;
const price = p ? `<p class="lm-price">${esc(this.money(p.amount))}${p.already_entered ? ` <span class="lm-warn">${esc(__("Already entered in this edition"))}</span>` : ""}</p>` : `<p class="lm-price lm-sub">&nbsp;</p>`;
const disabled = !r.category || !r.indemnity || r.busy || (p && p.already_entered) ? " disabled" : "";
return `
<section class="lm-sec lm-register">
<h3>${esc(__("Register for {0}", [r.editionName]))}</h3>
<label class="lm-field"><span>${esc(__("Race category"))}</span><select data-reg="category"><option value="">${esc(__("Choose a category"))}</option>${cats}</select></label>
<label class="lm-field"><span>${esc(__("Price tier"))}</span><select data-reg="tier">${tiers}</select></label>
${price}
<label class="lm-check"><input type="checkbox" data-reg="indemnity"${r.indemnity ? " checked" : ""}> ${esc(__("The runner has accepted the indemnity"))}</label>
<div class="lm-register__actions">
<button type="button" class="lm-cta" data-action="confirm-register"${disabled}>${esc(__("Create entry"))}</button>
<button type="button" class="lm-btn" data-action="cancel-register">${esc(__("Cancel"))}</button>
</div>
<p class="lm-sub">${esc(__("Creates a pending, unpaid entry. Record payment and confirm it from the registration."))}</p>
</section>`;
}
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(`<div class="lm-loading">${esc(__("Looking for duplicates..."))}</div>`);
return;
}
const head = `<header class="lm-dups__head"><div><h2>${esc(__("Possible duplicates"))}</h2><p>${esc(__("Runners who share a name with a phone number, email or date of birth. Choose which record to keep, preview, then merge."))}</p></div>
<button type="button" class="lm-btn" data-action="back">${esc(__("Back to participants"))}</button></header>`;
if (!this.dups.length) {
$box.html(head + `<p class="lm-empty">${esc(__("No likely duplicates found."))}</p>`);
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) => `
<label class="lm-dup${m.name === keep ? " is-keep" : ""}">
<input type="radio" name="keep-${gi}" value="${esc(m.name)}" data-keep="${gi}"${m.name === keep ? " checked" : ""}>
<span class="lm-dup__body">
<strong>${esc(m.full_name)}</strong>
<span class="lm-sub">${esc(m.code)}</span>
<span>${esc(m.phone) || "&ndash;"}</span>
<span class="lm-sub">${esc(m.email)}</span>
<span class="lm-sub">${esc(m.date_of_birth ? fmtDate(m.date_of_birth) : __("No birth date"))} &middot; ${esc(__("{0} entries", [m.entries]))}</span>
<button type="button" class="lm-linkbtn" data-open="${esc(m.name)}">${esc(__("View profile"))}</button>
</span>
${m.name === keep ? `<em class="lm-dup__tag">${esc(__("Keep"))}</em>` : ""}
</label>`
)
.join("");
const others = g.members.filter((m) => m.name !== keep);
const buttons = others
.map((m) => `<button type="button" class="lm-btn" data-merge="${gi}" data-remove="${esc(m.name)}">${svg("merge", 15)}<span>${esc(__("Preview merging {0}", [m.full_name + " (" + m.code + ")"]))}</span></button>`)
.join(" ");
const m = this.merge && this.merge.group === gi ? this.mergePreview() : "";
return `<section class="lm-panel is-wide lm-dupgroup">
<header><h3>${g.reasons.map((r) => esc(r)).join(" &middot; ")}</h3></header>
<div class="lm-dups__members">${members}</div>
<div class="lm-dupgroup__actions">${buttons}</div>
${m}
</section>`;
}
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
? `<p class="lm-warn">${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(", ")]))}</p>`
: "";
return `<div class="lm-merge">
<h4>${esc(__("Merging {0} into {1}", [who(plan.remove), who(plan.keep)]))}</h4>
<ul>
<li>${esc(__("Moves: {0}", [moves]))}</li>
<li>${esc(__("Fills empty fields on the kept record: {0}", [fills.length ? fieldLabels(fills) : __("none")]))}.${plan.medical_notes_merged ? " " + esc(__("Medical notes will be combined.")) : ""}</li>
${plan.kept_values.length ? `<li>${esc(__("Different values stay as on the kept record: {0}", [fieldLabels(plan.kept_values.map((k) => k.field))]))}</li>` : ""}
<li>${esc(__("The other record is deleted. Frappe keeps deleted documents, so an administrator can restore it."))}</li>
</ul>
${conflicts}
<div class="lm-register__actions">
<button type="button" class="lm-cta" data-action="confirm-merge"${plan.conflicts.length ? " disabled" : ""}>${esc(__("Merge these two"))}</button>
<button type="button" class="lm-btn" data-action="cancel-merge">${esc(__("Cancel"))}</button>
</div>
</div>`;
}
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();
};
})();
@@ -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"
}
@@ -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));
}
}
@@ -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(`
<div class="lm-payments-layout">
${lukenya.nav.render("lukenya-payments")}
<main class="lm-payments-main">
<header class="lm-payments-header">
<div>
<p class="lm-payments-eyebrow">
Financial operations
</p>
<h1>Payments</h1>
<p class="lm-payments-muted">
Monitor collections, outstanding balances,
payment failures and reconciliation.
</p>
</div>
<div class="lm-payments-actions">
<button class="btn btn-primary"
data-new="Payment Transaction">
New Payment
</button>
<button class="btn btn-default"
data-action="refresh">
Refresh
</button>
</div>
</header>
<section class="lm-payments-filters lm-payments-card">
<label>
<span>Event Edition</span>
<select data-filter="edition">
<option value="">
Loading editions...
</option>
</select>
</label>
<label>
<span>Gateway</span>
<select data-filter="gateway">
<option value="">All gateways</option>
<option value="M-Pesa">M-Pesa</option>
<option value="Card">Card</option>
<option value="Bank">Bank</option>
<option value="Cash">Cash</option>
<option value="Other">Other</option>
</select>
</label>
<label>
<span>Status</span>
<select data-filter="status">
<option value="">All statuses</option>
<option value="Initiated">Initiated</option>
<option value="Pending">Pending</option>
<option value="Paid">Paid</option>
<option value="Failed">Failed</option>
<option value="Refunded">Refunded</option>
<option value="Reconciled">Reconciled</option>
</select>
</label>
<label class="lm-payments-search">
<span>Search</span>
<input type="search"
data-filter="search"
placeholder="Transaction, registration or participant">
</label>
</section>
<div data-content>
<div class="lm-payments-card lm-payments-loading">
Loading payment data...
</div>
</div>
</main>
</div>
`);
}
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 = [
`<option value="">All editions</option>`,
...this.editions.map(
(edition) => `
<option value="${esc(edition.name)}"
${
edition.name === this.edition
? "selected"
: ""
}>
${esc(
edition.edition_name ||
edition.edition_code ||
edition.name
)}
</option>
`
)
].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(`
<div class="lm-payments-card lm-payments-loading">
Loading payment data...
</div>
`);
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) => `
<tr>
<td>${esc(row.status || "-")}</td>
<td>${esc(row.transaction_count || 0)}</td>
<td>${money(row.amount, "KES")}</td>
</tr>
`).join("")
: `
<tr>
<td colspan="3">No payment statuses found.</td>
</tr>
`;
const gatewayRows = (b.gateways || []).length
? b.gateways.map((row) => `
<tr>
<td>${esc(row.gateway || "-")}</td>
<td>${esc(row.transaction_count || 0)}</td>
<td>${money(row.amount, "KES")}</td>
<td>${esc(row.paid_count || 0)}</td>
<td>${esc(row.pending_count || 0)}</td>
<td>${esc(row.failed_count || 0)}</td>
</tr>
`).join("")
: `
<tr>
<td colspan="6">No gateway activity found.</td>
</tr>
`;
const transactionRows = this.transactions.length
? this.transactions.map((row) => `
<tr>
<td>${esc(row.payment_date || "-")}</td>
<td>
<button class="lm-payments-link"
data-form="Payment Transaction"
data-name="${esc(row.name)}">
${esc(row.name)}
</button>
</td>
<td>
<button class="lm-payments-link"
data-form="Registration"
data-name="${esc(row.registration)}">
${esc(row.registration)}
</button>
<small>${esc(row.participant_name || "")}</small>
</td>
<td>${esc(row.edition_name || "-")}</td>
<td>${esc(row.gateway || "-")}</td>
<td>${money(row.amount, row.currency || "KES")}</td>
<td>
<span class="lm-payment-status lm-status-${esc(
String(row.status || "").toLowerCase()
)}">
${esc(row.status || "-")}
</span>
</td>
<td>${esc(row.gateway_transaction_id || "-")}</td>
</tr>
`).join("")
: `
<tr>
<td colspan="8">
No payment transactions found.
</td>
</tr>
`;
const exceptionRows = this.exceptions.length
? this.exceptions.map((row) => `
<tr>
<td>
<button class="lm-payments-link"
data-form="Registration"
data-name="${esc(row.registration)}">
${esc(row.registration)}
</button>
<small>${esc(row.participant_name || "")}</small>
</td>
<td>${money(row.amount_due)}</td>
<td>${money(row.paid)}</td>
<td class="${Number(row.difference) > 0 ? "lm-balance-due" : "lm-credit"}">
${money(row.difference)}
</td>
<td>${esc(row.payment_status || "-")}</td>
</tr>
`).join("")
: `
<tr>
<td colspan="5">
No reconciliation exceptions found.
</td>
</tr>
`;
this.$root.find("[data-content]").html(`
<section class="lm-payments-kpis">
${cards.map((card) => `
<div class="lm-payments-card lm-payments-kpi">
<span>${esc(card[0])}</span>
<strong>${esc(card[1])}</strong>
</div>
`).join("")}
</section>
<section class="lm-payments-grid">
<div class="lm-payments-card lm-payments-panel">
<div class="lm-payments-section-title">
<div>
<h2>Payment status summary</h2>
<p>Transaction totals by status.</p>
</div>
</div>
<div class="table-responsive">
<table class="table lm-payments-table">
<thead>
<tr>
<th>Status</th>
<th>Transactions</th>
<th>Amount</th>
</tr>
</thead>
<tbody>
${statusRows}
</tbody>
</table>
</div>
</div>
<div class="lm-payments-card lm-payments-panel">
<div class="lm-payments-section-title">
<div>
<h2>Gateway summary</h2>
<p>Collections by payment channel.</p>
</div>
</div>
<div class="table-responsive">
<table class="table lm-payments-table">
<thead>
<tr>
<th>Gateway</th>
<th>Transactions</th>
<th>Amount</th>
<th>Paid</th>
<th>Pending</th>
<th>Failed</th>
</tr>
</thead>
<tbody>
${gatewayRows}
</tbody>
</table>
</div>
</div>
</section>
<section class="lm-payments-card lm-payments-panel">
<div class="lm-payments-section-title">
<div>
<h2>Payment transactions</h2>
<p>Recent payment activity for the selected scope.</p>
</div>
<button class="btn btn-default"
data-list="Payment Transaction"
data-filters='${filtersJson({
registration: ""
})}'>
Open payment list
</button>
</div>
<div class="table-responsive">
<table class="table lm-payments-table">
<thead>
<tr>
<th>Date</th>
<th>Transaction</th>
<th>Registration</th>
<th>Edition</th>
<th>Gateway</th>
<th>Amount</th>
<th>Status</th>
<th>Reference</th>
</tr>
</thead>
<tbody>
${transactionRows}
</tbody>
</table>
</div>
</section>
<section class="lm-payments-card lm-payments-panel">
<div class="lm-payments-section-title">
<div>
<h2>Reconciliation exceptions</h2>
<p>
Registrations where amount due does not match
confirmed payment transactions.
</p>
</div>
<button class="btn btn-default"
data-route="lukenya-reports">
Open reports
</button>
</div>
<div class="table-responsive">
<table class="table lm-payments-table">
<thead>
<tr>
<th>Registration</th>
<th>Amount due</th>
<th>Paid</th>
<th>Difference</th>
<th>Registration payment status</th>
</tr>
</thead>
<tbody>
${exceptionRows}
</tbody>
</table>
</div>
</section>
`);
}
showError(message) {
this.$root.find("[data-content]").html(`
<div class="lm-payments-card lm-payments-error">
${esc(message)}
</div>
`);
}
}
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();
}
};
})();
@@ -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"
}
@@ -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));
}
}
@@ -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(`
<div class="lm-rm-layout">
${lukenya.nav.render("lukenya-race-management")}
<main class="lm-rm-main">
<header class="lm-rm-header">
<div>
<p class="lm-rm-eyebrow">${esc(__("Race day operations"))}</p>
<h1>${esc(__("Race Management"))}</h1>
<p class="lm-rm-muted">${esc(__("Monitor registration readiness, bibs, race packs, check-in and results."))}</p>
</div>
<div class="lm-rm-controls">
<select data-edition aria-label="${esc(__("Event edition"))}"></select>
<button class="btn btn-primary" data-action="refresh">${esc(__("Refresh"))}</button>
</div>
</header>
<div data-content></div>
</main>
</div>
`);
}
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) =>
`<option value="${esc(item.name)}" ${item.name === this.state.edition ? "selected" : ""}>${esc(item.edition_name || item.name)} — ${esc(item.status || "")}</option>`
).join("");
this.$root.find("[data-edition]").html(options || `<option value="">${esc(__("No editions found"))}</option>`);
}
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(`<div class="lm-rm-card lm-rm-loading">${esc(__("Loading race management data…"))}</div>`);
}
renderEmpty(message) {
this.$root.find("[data-content]").html(`<div class="lm-rm-card lm-rm-empty">${esc(message)}</div>`);
}
showError(message) {
this.$root.find("[data-content]").html(`<div class="lm-rm-card lm-rm-error">${esc(message)}</div>`);
}
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) => `
<tr>
<td><strong>${esc(row.category_name || row.name)}</strong><small>${esc(row.distance_km || "")} km</small></td>
<td>${esc(row.start_time || "—")}</td>
<td>${esc(row.capacity || "—")}</td>
<td>${esc(row.registered || 0)}</td>
<td>${esc(row.paid || 0)}</td>
<td>${esc(row.bibs || 0)}</td>
<td>${esc(row.checked_in || 0)}</td>
<td>${esc(row.finishers || 0)}</td>
<td><button class="btn btn-xs btn-default" data-list="Registration" data-filters='${esc(JSON.stringify({ event_edition: edition.name, race_category: row.name }))}'>${esc(__("Open"))}</button></td>
</tr>
`).join("");
this.$root.find("[data-content]").html(`
<section class="lm-rm-edition lm-rm-card">
<div><span class="lm-rm-status">${esc(edition.status || "")}</span><h2>${esc(edition.edition_name || edition.name)}</h2><p>${esc(edition.event_date || "")} · ${esc(edition.venue || "")}</p></div>
<button class="btn btn-default" data-list="Event Edition" data-filters='${esc(JSON.stringify({ name: edition.name }))}'>${esc(__("Open edition"))}</button>
</section>
<section class="lm-rm-kpis">${cards.map((card) => `<button class="lm-rm-kpi lm-rm-card" data-list="Registration" data-filters='${esc(filters)}'><span>${esc(card[0])}</span><strong>${esc(card[1] || 0)}</strong><small>${esc(card[2])}</small></button>`).join("")}</section>
<section class="lm-rm-card">
<div class="lm-rm-section-title"><div><h2>${esc(__("Race categories"))}</h2><p>${esc(__("Operational progress by category."))}</p></div><button class="btn btn-default" data-list="Race Category" data-filters='${esc(filters)}'>${esc(__("Open categories"))}</button></div>
<div class="table-responsive"><table class="table lm-rm-table"><thead><tr><th>${esc(__("Category"))}</th><th>${esc(__("Start"))}</th><th>${esc(__("Capacity"))}</th><th>${esc(__("Registered"))}</th><th>${esc(__("Paid"))}</th><th>${esc(__("Bibs"))}</th><th>${esc(__("Checked in"))}</th><th>${esc(__("Finished"))}</th><th></th></tr></thead><tbody>${rows || `<tr><td colspan="9" class="text-muted">${esc(__("No race categories found for this edition."))}</td></tr>`}</tbody></table></div>
</section>
<section class="lm-rm-shortcuts">
${[["Bib Allocation", __("Bib allocations")], ["Race Pack Issue", __("Race packs")], ["Check-in", __("Check-in")], ["Timing Result", __("Timing results")], ["Payment Transaction", __("Payments")]].map((item) => `<button class="lm-rm-shortcut lm-rm-card" data-list="${esc(item[0])}" data-filters='${esc(filters)}'><strong>${esc(item[1])}</strong><span>${esc(__("Open list"))} →</span></button>`).join("")}
</section>
`);
}
}
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();
})();
@@ -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"
}
@@ -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;
}
}
@@ -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: '<rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/>',
calendar: '<rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/>',
users: '<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>',
flag: '<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z"/><line x1="4" y1="22" x2="4" y2="15"/>',
card: '<rect x="1" y="4" width="22" height="16" rx="2"/><line x1="1" y1="10" x2="23" y2="10"/>',
chart: '<line x1="12" y1="20" x2="12" y2="10"/><line x1="18" y1="20" x2="18" y2="4"/><line x1="6" y1="20" x2="6" y2="16"/>',
sliders: '<line x1="4" y1="21" x2="4" y2="14"/><line x1="4" y1="10" x2="4" y2="3"/><line x1="12" y1="21" x2="12" y2="12"/><line x1="12" y1="8" x2="12" y2="3"/><line x1="20" y1="21" x2="20" y2="16"/><line x1="20" y1="12" x2="20" y2="3"/><line x1="1" y1="14" x2="7" y2="14"/><line x1="9" y1="8" x2="15" y2="8"/><line x1="17" y1="16" x2="23" y2="16"/>',
run: '<polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/>',
right: '<polyline points="9 18 15 12 9 6"/>',
alert: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>',
download: '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>',
external: '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>',
};
const svg = (name, size) =>
`<svg class="lm-ico" width="${size || 18}" height="${size || 18}" viewBox="0 0 24 24" fill="none" ` +
`stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${ICON[name]}</svg>`;
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 "&ndash;";
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(`
<div class="lm-app">
${lukenya.nav.render("lukenya-reports")}
<main class="lm-main lm-rep">
<header class="lm-rep__head">
<div>
<h1>${esc(__("Reports & Analytics"))}</h1>
<p>${esc(__("Numbers for entries, money, race day and impact, one edition at a time."))}</p>
</div>
</header>
<section class="lm-filters" aria-label="${esc(__("Filters"))}"></section>
<nav class="lm-tabs" role="tablist"></nav>
<div class="lm-rep__body" aria-live="polite"></div>
</main>
</div>`);
this.renderFilters();
this.renderTabs();
this.renderBody();
}
renderFilters() {
const o = this.options || { editions: [], categories: [] };
const s = this.state;
const editions = [`<option value="">${esc(__("All editions"))}</option>`]
.concat(o.editions.map((e) => `<option value="${esc(e.name)}"${e.name === s.edition ? " selected" : ""}>${esc(e.edition_name || e.name)}</option>`))
.join("");
const cats = [`<option value="">${esc(__("All categories"))}</option>`]
.concat(
o.categories
.filter((c) => !s.edition || c.event_edition === s.edition)
.map((c) => `<option value="${esc(c.name)}"${c.name === s.category ? " selected" : ""}>${esc(c.category_name)}</option>`)
)
.join("");
this.$root.find(".lm-filters").html(`
<label class="lm-field"><span>${esc(__("Event edition"))}</span><select data-filter="edition">${editions}</select></label>
<label class="lm-field"><span>${esc(__("Race category"))}</span><select data-filter="category">${cats}</select></label>
<label class="lm-field"><span>${esc(__("From"))}</span><input type="date" data-filter="from" value="${esc(s.from)}"></label>
<label class="lm-field"><span>${esc(__("To"))}</span><input type="date" data-filter="to" value="${esc(s.to)}"></label>
<button type="button" class="lm-reset" data-action="reset">${esc(__("Reset"))}</button>
<p class="lm-filters__note">${esc(NOTES[s.tab])}</p>`);
}
renderTabs() {
const tabs = TABS.map(
(t) =>
`<button type="button" role="tab" class="lm-tab${t.key === this.state.tab ? " is-active" : ""}" aria-selected="${t.key === this.state.tab}" data-tab="${t.key}">${esc(t.label)}</button>`
).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(`<div class="lm-loading">${esc(__("Loading figures..."))}</div>`);
return;
}
if (this.error || !this.data) {
$body.html(`<div class="lm-notice" role="alert">${esc(__("Couldn't load these figures. Open Error Log to see what went wrong."))}</div>`);
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) => `
<div class="lm-stat">
<span class="lm-stat__label">${esc(k.label)}</span>
<span class="lm-stat__value">${this.format(k.value, k.format)}</span>
<span class="lm-stat__hint">${k.hint ? esc(k.hint) : "&nbsp;"}</span>
</div>`
)
.join("");
const n = d.kpis.length;
const cols = n <= 5 ? n : n === 6 ? 3 : 4;
return `<section class="lm-stats" style="--lm-cols:${cols}">${cards}</section>`;
}
attention(d) {
if (!d.attention.length) return "";
const items = d.attention
.map((a, i) => {
const actionable = a.report || a.doctype;
return `<li><button type="button" class="lm-attn__item${actionable ? "" : " is-static"}" data-attn="${i}"${actionable ? "" : " tabindex=\"-1\""}>
${svg("alert", 16)}<span><strong>${esc(a.count.toLocaleString())}</strong> ${esc(a.text)}</span>${actionable ? svg("right", 14) : ""}
</button></li>`;
})
.join("");
return `<section class="lm-attn"><h2>${esc(__("Needs attention"))}</h2><ul>${items}</ul></section>`;
}
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) => `
<section class="lm-panel${wide[i] ? " is-wide" : ""}">
<header>
<h3>${esc(c.title)}</h3>
${c.report ? `<button type="button" class="lm-linkbtn" data-report="${esc(c.report.name)}">${esc(__("Open report"))} ${svg("external", 13)}</button>` : ""}
</header>
<div class="lm-chart" id="lm-chart-${i}"></div>
</section>`
)
.join("");
return `<section class="lm-charts">${panels}</section>`;
}
tables(d) {
return d.tables
.map((t) => {
const head = t.columns.map((c) => `<th class="is-${c.type}">${esc(c.label)}</th>`).join("");
const body = t.rows
.map(
(r) =>
"<tr>" +
t.columns
.map((c) => {
const v = r[c.field];
if (c.type === "link" && v) {
return `<td><a href="#" class="lm-link" data-doc="${esc(c.doctype)}" data-name="${esc(v)}">${esc(v)}</a></td>`;
}
return `<td class="is-${c.type}">${this.format(v, c.type)}</td>`;
})
.join("") +
"</tr>"
)
.join("");
const actions =
`${t.report ? `<button type="button" class="lm-linkbtn" data-report="${esc(t.report.name)}">${esc(__("Open full report"))} ${svg("external", 13)}</button>` : ""}` +
(t.rows.length ? `<button type="button" class="lm-linkbtn" data-export="${esc(t.id)}">${svg("download", 13)} ${esc(__("Export CSV"))}</button>` : "");
return `
<section class="lm-panel is-wide">
<header><h3>${esc(t.title)}</h3><div class="lm-panel__actions">${actions}</div></header>
${
t.rows.length
? `<div class="lm-tablewrap"><table class="lm-table"><thead><tr>${head}</tr></thead><tbody>${body}</tbody></table></div>`
: `<p class="lm-empty">${esc(t.empty)}</p>`
}
</section>`;
})
.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 = `<p class="lm-empty">${esc(__("No data yet."))}</p>`;
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 = `<p class="lm-empty">${esc(__("This chart couldn't be drawn."))}</p>`;
}
});
}
// -- 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();
};
})();
@@ -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"
}
@@ -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
@@ -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")
@@ -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
@@ -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;
}
@@ -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: `
<svg viewBox="0 0 24 24">
<rect x="3" y="3" width="7" height="7" rx="1"/>
<rect x="14" y="3" width="7" height="7" rx="1"/>
<rect x="3" y="14" width="7" height="7" rx="1"/>
<rect x="14" y="14" width="7" height="7" rx="1"/>
</svg>
`,
calendar: `
<svg viewBox="0 0 24 24">
<rect x="3" y="4" width="18" height="17" rx="2"/>
<line x1="16" y1="2" x2="16" y2="6"/>
<line x1="8" y1="2" x2="8" y2="6"/>
<line x1="3" y1="10" x2="21" y2="10"/>
</svg>
`,
users: `
<svg viewBox="0 0 24 24">
<circle cx="9" cy="8" r="4"/>
<path d="M2.5 21a6.5 6.5 0 0 1 13 0"/>
<path d="M16 4.5a4 4 0 0 1 0 7.5"/>
<path d="M17 14.5a6.5 6.5 0 0 1 4.5 6"/>
</svg>
`,
flag: `
<svg viewBox="0 0 24 24">
<path d="M5 21V3"/>
<path d="M5 4h12l-3 4 3 4H5"/>
<path d="M3 21h4"/>
</svg>
`,
card: `
<svg viewBox="0 0 24 24">
<rect x="2.5" y="5" width="19" height="14" rx="2"/>
<line x1="2.5" y1="10" x2="21.5" y2="10"/>
<line x1="6" y1="15" x2="10" y2="15"/>
</svg>
`,
chart: `
<svg viewBox="0 0 24 24">
<line x1="4" y1="20" x2="4" y2="10"/>
<line x1="10" y1="20" x2="10" y2="4"/>
<line x1="16" y1="20" x2="16" y2="13"/>
<line x1="22" y1="20" x2="22" y2="7"/>
</svg>
`
};
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 = `
<span class="lm-shared-nav__icon">
${ICONS[item.icon]}
</span>
<span class="lm-shared-nav__label">
${item.label}
</span>
`;
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);
})();
@@ -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,
}
@@ -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
@@ -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
@@ -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",
},
],
};

Some files were not shown because too many files have changed in this diff Show More