This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"lukenya_marathon.bundle.js": [
|
||||||
|
"public/js/lukenya_navigation.js"
|
||||||
|
],
|
||||||
|
"lukenya_marathon.bundle.css": [
|
||||||
|
"public/css/lukenya_navigation.css"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
"event_date",
|
"event_date",
|
||||||
"registration_open",
|
"registration_open",
|
||||||
"registration_close",
|
"registration_close",
|
||||||
|
"early_bird_ends",
|
||||||
"status",
|
"status",
|
||||||
"venue",
|
"venue",
|
||||||
"county",
|
"county",
|
||||||
@@ -48,6 +49,12 @@
|
|||||||
"fieldtype": "Datetime",
|
"fieldtype": "Datetime",
|
||||||
"label": "Registration Close"
|
"label": "Registration Close"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"fieldname": "early_bird_ends",
|
||||||
|
"fieldtype": "Datetime",
|
||||||
|
"label": "Early-bird Ends",
|
||||||
|
"description": "Early-bird prices apply to residents until this time. Leave empty to never offer them."
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"default": "Draft",
|
"default": "Draft",
|
||||||
"fieldname": "status",
|
"fieldname": "status",
|
||||||
@@ -100,9 +107,23 @@
|
|||||||
"role": "System Manager",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Registration Desk",
|
||||||
|
"read": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Timing Officer",
|
||||||
|
"read": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Race Director",
|
||||||
|
"read": 1,
|
||||||
|
"write": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"track_changes": 1
|
"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."))
|
||||||
@@ -9,6 +9,7 @@
|
|||||||
"participant_code",
|
"participant_code",
|
||||||
"first_name",
|
"first_name",
|
||||||
"last_name",
|
"last_name",
|
||||||
|
"full_name",
|
||||||
"id_number",
|
"id_number",
|
||||||
"date_of_birth",
|
"date_of_birth",
|
||||||
"gender",
|
"gender",
|
||||||
@@ -19,6 +20,8 @@
|
|||||||
"shirt_size",
|
"shirt_size",
|
||||||
"emergency_contact_name",
|
"emergency_contact_name",
|
||||||
"emergency_contact_phone",
|
"emergency_contact_phone",
|
||||||
|
"guardian_name",
|
||||||
|
"guardian_phone",
|
||||||
"medical_notes",
|
"medical_notes",
|
||||||
"communication_consent"
|
"communication_consent"
|
||||||
],
|
],
|
||||||
@@ -27,8 +30,9 @@
|
|||||||
"fieldname": "participant_code",
|
"fieldname": "participant_code",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Participant Code",
|
"label": "Participant Code",
|
||||||
"reqd": 1,
|
"reqd": 0,
|
||||||
"unique": 1
|
"unique": 1,
|
||||||
|
"description": "Generated automatically when left empty"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "first_name",
|
"fieldname": "first_name",
|
||||||
@@ -42,11 +46,21 @@
|
|||||||
"label": "Last Name",
|
"label": "Last Name",
|
||||||
"reqd": 1
|
"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",
|
"fieldname": "id_number",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "ID / Passport Number",
|
"label": "ID / Passport Number",
|
||||||
"unique": 1
|
"unique": 1,
|
||||||
|
"permlevel": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "date_of_birth",
|
"fieldname": "date_of_birth",
|
||||||
@@ -98,6 +112,17 @@
|
|||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Emergency Contact Phone"
|
"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",
|
"fieldname": "medical_notes",
|
||||||
"fieldtype": "Small Text",
|
"fieldtype": "Small Text",
|
||||||
@@ -125,9 +150,32 @@
|
|||||||
"role": "System Manager",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 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_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"track_changes": 1
|
"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."))
|
||||||
+124
@@ -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"
|
||||||
|
}
|
||||||
+115
@@ -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
|
||||||
@@ -124,9 +124,24 @@
|
|||||||
"role": "System Manager",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 1
|
"write": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Registration Desk",
|
||||||
|
"read": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Timing Officer",
|
||||||
|
"read": 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role": "Race Director",
|
||||||
|
"read": 1,
|
||||||
|
"write": 1,
|
||||||
|
"create": 1
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"sort_field": "modified",
|
"sort_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"track_changes": 1
|
"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
|
||||||
@@ -70,7 +70,8 @@
|
|||||||
"fieldname": "amount_due",
|
"fieldname": "amount_due",
|
||||||
"fieldtype": "Currency",
|
"fieldtype": "Currency",
|
||||||
"label": "Amount Due",
|
"label": "Amount Due",
|
||||||
"reqd": 1
|
"reqd": 1,
|
||||||
|
"default": "0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "Unpaid",
|
"default": "Unpaid",
|
||||||
@@ -89,27 +90,31 @@
|
|||||||
"fieldname": "bib_number",
|
"fieldname": "bib_number",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Bib Number",
|
"label": "Bib Number",
|
||||||
"unique": 1
|
"unique": 1,
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "chip_number",
|
"fieldname": "chip_number",
|
||||||
"fieldtype": "Data",
|
"fieldtype": "Data",
|
||||||
"label": "Chip Number",
|
"label": "Chip Number",
|
||||||
"unique": 1
|
"unique": 1,
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "Not Issued",
|
"default": "Not Issued",
|
||||||
"fieldname": "race_pack_status",
|
"fieldname": "race_pack_status",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Race Pack Status",
|
"label": "Race Pack Status",
|
||||||
"options": "Not Issued\nReady\nIssued\nCollected\nUncollected"
|
"options": "Not Issued\nReady\nIssued\nCollected\nUncollected",
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"default": "Not Checked In",
|
"default": "Not Checked In",
|
||||||
"fieldname": "check_in_status",
|
"fieldname": "check_in_status",
|
||||||
"fieldtype": "Select",
|
"fieldtype": "Select",
|
||||||
"label": "Check-in Status",
|
"label": "Check-in Status",
|
||||||
"options": "Not Checked In\nChecked In\nDid Not Start\nFinished"
|
"options": "Not Checked In\nChecked In\nDid Not Start\nFinished",
|
||||||
|
"read_only": 1
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"fieldname": "indemnity_accepted",
|
"fieldname": "indemnity_accepted",
|
||||||
@@ -143,9 +148,25 @@
|
|||||||
"role": "System Manager",
|
"role": "System Manager",
|
||||||
"share": 1,
|
"share": 1,
|
||||||
"write": 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_field": "modified",
|
||||||
"sort_order": "DESC",
|
"sort_order": "DESC",
|
||||||
"track_changes": 1
|
"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"
|
||||||
|
}
|
||||||
+101
@@ -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">👋</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())
|
||||||
|
: "–";
|
||||||
|
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"> </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] : "–"}</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"
|
||||||
|
}
|
||||||
+1309
File diff suppressed because it is too large
Load Diff
@@ -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 ? "–" : 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 ? "–" : 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 ? " · " + esc(p.gender) : ""}</td>
|
||||||
|
<td>${esc(p.nationality) || "–"}</td>
|
||||||
|
<td><span>${esc(p.phone) || "–"}</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) : "–"}</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(" · ");
|
||||||
|
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">–</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 ? " · " + esc(e.result.time) : ""}${e.result.rank ? " · " + 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 ? " · " + esc(e.tier) : ""}</p>
|
||||||
|
<p class="lm-sub">${esc(money(e.paid))} ${esc(__("paid of"))} ${esc(money(e.amount_due))}${e.balance ? " · " + esc(__("balance")) + " " + esc(money(e.balance)) : ""}</p>
|
||||||
|
${bits.length ? `<p class="lm-sub">${bits.join(" · ")}</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"> </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) || "–"}</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"))} · ${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(" · ")}</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"
|
||||||
|
}
|
||||||
+275
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
+170
@@ -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();
|
||||||
|
})();
|
||||||
+21
@@ -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 "–";
|
||||||
|
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) : " "}</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
|
||||||
+30
@@ -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
Reference in New Issue
Block a user