const el = (tag, attrs, ...children) => { const node = document.createElement(tag); if (attrs) { for (const k in attrs) { if (k === "class") node.className = attrs[k]; else if (k === "style") Object.assign(node.style, attrs[k]); else if (k.startsWith("on") && typeof attrs[k] === "function") node.addEventListener(k.slice(2), attrs[k]); else if (k === "checked" || k === "disabled" || k === "selected") { if (attrs[k]) node.setAttribute(k, ""); } else if (attrs[k] !== false && attrs[k] !== null && attrs[k] !== undefined) { node.setAttribute(k, attrs[k]); } } } for (const c of children.flat()) { if (c === null || c === undefined || c === false) continue; node.appendChild(typeof c === "string" || typeof c === "number" ? document.createTextNode(String(c)) : c); } return node; }; const root = document.getElementById("app"); const state = { user: null, competitions: [], competition: null, pilots: [], penalties: [], members: [], users: [], rules: [], rulesByNumber: {}, ws: null, tab: "penalties", sort: { col: "id", dir: "desc" }, pilotSort: { col: "number", dir: "asc" }, filterUntransferred: false, editingPenalty: null, showPenaltyModal: false, showPilotModal: false, showMemberModal: false, showCompetitionModal: false, showUserModal: false, }; function clear(node) { while (node.firstChild) node.removeChild(node.firstChild); } function render() { if (!state.user) { renderLogin(); return; } if (state.competition) { renderCompetition(); } else { renderHome(); } } function renderLogin() { clear(root); const usernameInput = el("input", { type: "text", autocomplete: "username", placeholder: t("username") }); const passwordInput = el("input", { type: "password", autocomplete: "current-password", placeholder: t("password") }); const errorBox = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); const langSelect = el("select", { onchange: (e) => { setLang(e.target.value); renderLogin(); } }, ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === CURRENT_LANG }, I18N_NAMES[l])) ); async function doLogin(e) { e.preventDefault(); errorBox.style.display = "none"; try { const u = await API.login(usernameInput.value, passwordInput.value); state.user = u; setLang(u.language); await loadCompetitions(); render(); } catch (err) { errorBox.textContent = err.status === 401 ? t("invalid_credentials") : (err.message || t("invalid_credentials")); errorBox.style.display = "block"; } } const form = el("form", { onsubmit: doLogin, class: "col" }, el("h1", null, t("login_title")), el("div", { class: "field" }, el("label", null, t("username")), usernameInput), el("div", { class: "field" }, el("label", null, t("password")), passwordInput), errorBox, el("button", { type: "submit", class: "primary" }, t("sign_in")), el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("language")), langSelect ), ); root.appendChild(el("div", { class: "login-wrap" }, el("div", { class: "login-box" }, form))); usernameInput.focus(); } function renderTopbar(extra) { const langSelect = el("select", { onchange: async (e) => { const lang = e.target.value; await API.updateMe({ language: lang }); state.user.language = lang; setLang(lang); await loadRules(); render(); } }, ...I18N_AVAILABLE.map((l) => el("option", { value: l, selected: l === state.user.language }, I18N_NAMES[l])) ); const logoutBtn = el("button", { class: "ghost", onclick: async () => { await API.logout(); if (state.ws) { state.ws.close(); state.ws = null; } state.user = null; state.competition = null; render(); } }, t("logout")); const brand = el("a", { href: "#", onclick: (e) => { e.preventDefault(); if (state.ws) { state.ws.close(); state.ws = null; } state.competition = null; render(); } , class: "brand" }, "Penalty Tracker"); const profileBtn = el("button", { class: "ghost", onclick: () => openProfileModal() }, state.user.display_name || state.user.username); return el("div", { class: "topbar" }, brand, el("div", { class: "nav" }, extra || null, profileBtn, langSelect, logoutBtn, ) ); } function openProfileModal() { state.showProfileModal = true; render(); } function renderProfileModal() { const username = el("input", { type: "text", value: state.user.username }); const displayName = el("input", { type: "text", value: state.user.display_name || "" }); const password = el("input", { type: "password", placeholder: t("leave_blank_keep") }); const err = el("div", { class: "muted", style: { color: "var(--danger)", display: "none" } }); const ok = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, el("div", { class: "modal" }, el("h3", null, t("profile")), el("div", { class: "field" }, el("label", null, t("username")), username), el("div", { class: "field" }, el("label", null, t("display_name")), displayName), el("div", { class: "field" }, el("label", null, t("new_password")), password), err, ok, el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: closeAll }, t("cancel")), el("button", { class: "primary", onclick: async () => { err.style.display = "none"; ok.style.display = "none"; const body = {}; const newUsername = username.value.trim(); if (newUsername && newUsername !== state.user.username) body.username = newUsername; if (displayName.value !== (state.user.display_name || "")) body.display_name = displayName.value; if (password.value) body.password = password.value; try { const u = await API.updateMe(body); state.user = u; ok.textContent = t("saved"); ok.style.display = "block"; setTimeout(() => { closeAll(); }, 700); } catch (e) { err.textContent = e.data && e.data.error === "username_taken" ? t("username_taken") : (e.message || "error"); err.style.display = "block"; } } }, t("save")), ), ) ); } async function loadCompetitions() { state.competitions = await API.listCompetitions(); } async function loadRules() { state.rules = await API.listRules(state.user.language); state.rulesByNumber = {}; for (const r of state.rules) state.rulesByNumber[r.number] = r; } function renderHome() { clear(root); root.appendChild(renderTopbar()); const container = el("div", { class: "container" }); const headerRow = el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.75rem" } }, el("h2", { style: { margin: 0 } }, t("competitions")), state.user.is_system_admin && el("button", { class: "primary", onclick: () => openCompetitionModal() }, t("new_competition")) ); container.appendChild(headerRow); if (state.competitions.length === 0) { container.appendChild(el("div", { class: "muted" }, t("no_competitions"))); } else { const list = el("div", { class: "grid" }); for (const c of state.competitions) { list.appendChild(el("div", { class: "card" }, el("h2", null, c.name), el("div", { class: "muted", style: { marginBottom: "0.5rem" } }, el("span", { class: "badge accent" }, t(c.role)), ), el("button", { class: "primary", onclick: () => openCompetition(c.id) }, t("open")), )); } container.appendChild(list); } if (state.user.is_system_admin) { container.appendChild(renderUsersAdmin()); } root.appendChild(container); if (state.showCompetitionModal) container.appendChild(renderCompetitionModal()); if (state.showUserModal) container.appendChild(renderUserModal()); if (state.showProfileModal) container.appendChild(renderProfileModal()); } function renderUsersAdmin() { const card = el("div", { class: "card" }); card.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, el("h2", { style: { margin: 0 } }, t("users")), el("button", { onclick: () => openUserModal() }, t("add_user")), )); if (state.users.length === 0) { card.appendChild(el("div", { class: "muted" }, "—")); return card; } const table = el("table"); table.appendChild(el("thead", null, el("tr", null, el("th", null, t("username")), el("th", null, t("display_name")), el("th", null, t("language")), el("th", null, t("is_admin")), el("th", null, t("actions")), ) )); const tbody = el("tbody"); for (const u of state.users) { tbody.appendChild(el("tr", null, el("td", null, u.username), el("td", null, u.display_name), el("td", null, I18N_NAMES[u.language] || u.language), el("td", null, u.is_system_admin ? t("yes") : t("no")), el("td", null, u.id !== state.user.id && el("button", { class: "action-btn danger", onclick: async () => { if (!confirm(t("confirm_delete"))) return; await API.deleteUser(u.id); await loadUsers(); render(); } }, t("delete")), ), )); } table.appendChild(tbody); card.appendChild(el("div", { class: "table-wrap" }, table)); return card; } async function loadUsers() { if (state.user.is_system_admin) { state.users = await API.listUsers(); } } function openCompetitionModal() { state.showCompetitionModal = true; render(); } function renderCompetitionModal() { const nameInput = el("input", { type: "text" }); const allowInput = el("input", { type: "checkbox" }); const modal = el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target === modal) closeAll(); } }, el("div", { class: "modal" }, el("h3", null, t("new_competition")), el("div", { class: "field" }, el("label", null, t("competition_name")), nameInput), el("label", { class: "row" }, allowInput, " " + t("allow_any_scorer_edit")), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: closeAll }, t("cancel")), el("button", { class: "primary", onclick: async () => { if (!nameInput.value.trim()) return; await API.createCompetition({ name: nameInput.value.trim(), allow_any_scorer_edit: allowInput.checked }); await loadCompetitions(); closeAll(); } }, t("create")), ), ) ); return modal; } function openUserModal(forCompetition) { state.showUserModal = true; state.userModalForCompetition = forCompetition || null; render(); } function renderUserModal() { const username = el("input", { type: "text" }); const password = el("input", { type: "password" }); const displayName = el("input", { type: "text" }); const langSelect = el("select", null, ...I18N_AVAILABLE.map((l) => el("option", { value: l }, I18N_NAMES[l])) ); const isAdmin = el("input", { type: "checkbox" }); const roleSelect = el("select", null, el("option", { value: "scorer" }, t("scorer")), el("option", { value: "chief_scorer" }, t("chief_scorer")), ); const forComp = state.userModalForCompetition; return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, el("div", { class: "modal" }, el("h3", null, t("add_user")), el("div", { class: "field" }, el("label", null, t("username")), username), el("div", { class: "field" }, el("label", null, t("password")), password), el("div", { class: "field" }, el("label", null, t("display_name")), displayName), el("div", { class: "field" }, el("label", null, t("language")), langSelect), !forComp && state.user.is_system_admin && el("label", { class: "row" }, isAdmin, " " + t("is_admin")), forComp && el("div", { class: "field" }, el("label", null, t("role")), roleSelect), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: closeAll }, t("cancel")), el("button", { class: "primary", onclick: async () => { if (!username.value.trim() || !password.value) return; try { const created = await API.createUser({ username: username.value.trim(), password: password.value, display_name: displayName.value, language: langSelect.value, is_system_admin: !forComp && isAdmin.checked, }); if (forComp) { await API.addMember(forComp, { user_id: created.id, role: roleSelect.value }); await loadMembers(); } await loadUsers(); closeAll(); } catch (err) { alert(err.message); } } }, t("create")), ), ) ); } function closeAll() { state.showCompetitionModal = false; state.showUserModal = false; state.showPilotModal = false; state.showPenaltyModal = false; state.showMemberModal = false; state.showImportModal = false; state.showProfileModal = false; state.editingPenalty = null; state.editingPilot = null; state.userModalForCompetition = null; render(); } async function openCompetition(id) { const c = await API.getCompetition(id); state.competition = c; state.tab = "penalties"; await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]); if (state.user.is_system_admin) await loadUsers(); if (state.ws) state.ws.close(); state.ws = openCompetitionWS(id, { onopen: () => { state.wsOnline = true; updateStatus(); }, onclose: () => { state.wsOnline = false; updateStatus(); }, onmessage: handleWSMessage, }); render(); } function updateStatus() { const el = document.querySelector(".connection-status"); if (!el) return; el.classList.toggle("online", !!state.wsOnline); el.classList.toggle("offline", !state.wsOnline); } function handleWSMessage(msg) { if (msg.type === "penalty_created") { const exists = state.penalties.some((p) => p.id === msg.payload.id); if (!exists) { state.penalties.unshift(msg.payload); patchPenaltyTable(); } } else if (msg.type === "penalty_updated") { const idx = state.penalties.findIndex((p) => p.id === msg.payload.id); if (idx >= 0) { state.penalties[idx] = msg.payload; patchPenaltyTable(); } } else if (msg.type === "penalty_deleted") { state.penalties = state.penalties.filter((p) => p.id !== msg.payload.id); patchPenaltyTable(); } else if (msg.type === "pilot_changed") { Promise.all([loadPilots(), loadPenalties()]).then(() => { if (state.tab === "penalties") patchPenaltyTable(); else render(); }); } else if (msg.type === "competition_updated") { API.getCompetition(state.competition.id).then((c) => { state.competition = c; render(); }); } } async function loadPilots() { state.pilots = await API.listPilots(state.competition.id); } async function loadPenalties() { state.penalties = await API.listPenalties(state.competition.id); } async function loadMembers() { state.members = await API.listMembers(state.competition.id); } function isChief() { const r = state.competition.role; return r === "system_admin" || r === "chief_scorer"; } function canEditPenalty(p) { if (isChief()) return true; if (p.created_by === state.user.id) return true; return !!state.competition.allow_any_scorer_edit; } function renderCompetition() { clear(root); const backBtn = el("button", { class: "ghost", onclick: () => { if (state.ws) { state.ws.close(); state.ws = null; } state.competition = null; render(); } }, "← " + t("back")); root.appendChild(renderTopbar(backBtn)); const container = el("div", { class: "container" }); container.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginBottom: "0.5rem" } }, el("h2", { style: { margin: 0 } }, state.competition.name, " ", el("span", { class: "badge accent" }, t(state.competition.role)), ), el("div", { class: "row" }, el("span", { class: "connection-status " + (state.wsOnline ? "online" : "offline") }), el("span", { class: "muted" }, state.wsOnline ? t("online") : t("offline")), ), )); const tabs = el("div", { class: "tabs" }); const tabDefs = [ ["penalties", t("penalties")], ["pilots", t("pilots")], ["rules", t("rules")], ]; if (isChief()) { tabDefs.push(["members", t("members")]); tabDefs.push(["settings", t("settings_tab")]); } for (const [id, label] of tabDefs) { tabs.appendChild(el("button", { class: state.tab === id ? "active" : "", onclick: () => { state.tab = id; render(); } }, label)); } container.appendChild(tabs); if (state.tab === "penalties") container.appendChild(renderPenaltiesTab()); else if (state.tab === "pilots") container.appendChild(renderPilotsTab()); else if (state.tab === "rules") container.appendChild(renderRulesTab()); else if (state.tab === "members") container.appendChild(renderMembersTab()); else if (state.tab === "settings") container.appendChild(renderSettingsTab()); root.appendChild(container); if (state.showPenaltyModal) container.appendChild(renderPenaltyModal()); if (state.showPilotModal) container.appendChild(renderPilotModal()); if (state.showMemberModal) container.appendChild(renderMemberModal()); if (state.showUserModal) container.appendChild(renderUserModal()); if (state.showImportModal) container.appendChild(renderImportModal()); if (state.showProfileModal) container.appendChild(renderProfileModal()); } const PENALTY_COLS = [ { key: "id", label: "#" }, { key: "flight", label: "flight" }, { key: "date", label: "date" }, { key: "pilot_number", label: "pilot_number" }, { key: "pilot_name", label: "pilot_name" }, { key: "rule_number", label: "rule_number_short" }, { key: "task", label: "task" }, { key: "penalties_text", label: "penalty_values" }, { key: "description", label: "description" }, { key: "created_by_name", label: "created_by" }, { key: "transferred", label: "transferred" }, ]; function sortPenalties(list) { const { col, dir } = state.sort; const sign = dir === "asc" ? 1 : -1; return [...list].sort((a, b) => { let av = a[col], bv = b[col]; if (typeof av === "boolean") av = av ? 1 : 0; if (typeof bv === "boolean") bv = bv ? 1 : 0; if (av == null) av = ""; if (bv == null) bv = ""; if (typeof av === "number" && typeof bv === "number") return (av - bv) * sign; return String(av).localeCompare(String(bv), undefined, { numeric: true }) * sign; }); } function filterPenalties(list) { if (state.filterUntransferred) list = list.filter((p) => !p.transferred); return list; } function renderPenaltiesTab() { const card = el("div"); const toolbar = el("div", { class: "toolbar" }, el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")), el("label", { class: "row" }, el("input", { type: "checkbox", checked: state.filterUntransferred, onchange: (e) => { state.filterUntransferred = e.target.checked; patchPenaltyTable(); } }), " " + t("transferred_only") ), el("div", { class: "spacer" }), isChief() && el("a", { href: API.exportPenaltiesURL(state.competition.id), target: "_blank" }, el("button", null, t("export_csv")) ), ); card.appendChild(toolbar); const tableWrap = el("div", { class: "table-wrap", id: "penalty-table-wrap" }); const table = el("table", { id: "penalty-table" }); const thead = el("thead"); const headRow = el("tr"); for (const c of PENALTY_COLS) { headRow.appendChild(el("th", { onclick: () => { if (state.sort.col === c.key) state.sort.dir = state.sort.dir === "asc" ? "desc" : "asc"; else { state.sort.col = c.key; state.sort.dir = "asc"; } patchPenaltyTable(); } }, t(c.label), el("span", { class: "sort-ind" }, state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""), )); } headRow.appendChild(el("th", null, t("actions"))); thead.appendChild(headRow); table.appendChild(thead); const tbody = el("tbody", { id: "penalty-tbody" }); table.appendChild(tbody); tableWrap.appendChild(table); card.appendChild(tableWrap); setTimeout(patchPenaltyTable, 0); return card; } function patchPenaltyTable() { const tbody = document.getElementById("penalty-tbody"); if (!tbody) return; const list = sortPenalties(filterPenalties(state.penalties)); const headRow = document.querySelector("#penalty-table thead tr"); if (headRow) { const ths = headRow.querySelectorAll("th"); PENALTY_COLS.forEach((c, i) => { const ind = ths[i].querySelector(".sort-ind"); if (ind) ind.textContent = state.sort.col === c.key ? (state.sort.dir === "asc" ? "▲" : "▼") : ""; }); } const existing = new Map(); for (const tr of Array.from(tbody.children)) { existing.set(tr.dataset.id, tr); } const wanted = new Set(); let prev = null; for (const p of list) { const idStr = String(p.id); wanted.add(idStr); let tr = existing.get(idStr); if (!tr) { tr = buildPenaltyRow(p); } else { updatePenaltyRow(tr, p); } if (prev) { if (prev.nextSibling !== tr) tbody.insertBefore(tr, prev.nextSibling); } else { if (tbody.firstChild !== tr) tbody.insertBefore(tr, tbody.firstChild); } prev = tr; } for (const [k, tr] of existing) { if (!wanted.has(k)) tr.remove(); } } function buildPenaltyRow(p) { const tr = document.createElement("tr"); tr.dataset.id = String(p.id); for (const c of PENALTY_COLS) { const td = document.createElement("td"); td.dataset.col = c.key; tr.appendChild(td); } const actions = document.createElement("td"); actions.dataset.col = "_actions"; tr.appendChild(actions); updatePenaltyRow(tr, p); return tr; } function updatePenaltyRow(tr, p) { tr.classList.toggle("transferred", !!p.transferred); for (const c of PENALTY_COLS) { const td = tr.querySelector(`td[data-col="${c.key}"]`); if (!td) continue; if (c.key === "transferred") { td.innerHTML = ""; const lbl = document.createElement("label"); lbl.className = "switch"; const inp = document.createElement("input"); inp.type = "checkbox"; inp.checked = !!p.transferred; inp.disabled = !canEditPenalty(p); inp.addEventListener("change", async () => { try { await API.updatePenalty(state.competition.id, p.id, { transferred: inp.checked }); } catch (e) { inp.checked = !inp.checked; alert(e.message); } }); const slider = document.createElement("span"); slider.className = "slider"; lbl.appendChild(inp); lbl.appendChild(slider); td.appendChild(lbl); } else { const val = p[c.key]; td.textContent = val == null ? "" : String(val); } } const actions = tr.querySelector('td[data-col="_actions"]'); actions.innerHTML = ""; if (canEditPenalty(p)) { const edit = document.createElement("button"); edit.className = "action-btn"; edit.textContent = t("edit"); edit.addEventListener("click", () => openPenaltyModal(p)); const del = document.createElement("button"); del.className = "action-btn danger"; del.textContent = t("delete"); del.addEventListener("click", async () => { if (!confirm(t("confirm_delete"))) return; await API.deletePenalty(state.competition.id, p.id); }); actions.appendChild(edit); actions.appendChild(del); } } function openPenaltyModal(penalty) { state.showPenaltyModal = true; state.editingPenalty = penalty || null; render(); } function renderPenaltyModal() { const p = state.editingPenalty || {}; const flight = el("input", { type: "text", value: p.flight || "" }); const date = el("input", { type: "date", value: p.date || new Date().toISOString().slice(0, 10) }); const pilotSelect = el("select", { onchange: () => refreshPrior() }, el("option", { value: "" }, "—"), ...sortPilots(state.pilots).map((pl) => el("option", { value: pl.number, selected: pl.number === p.pilot_number }, `${pl.number} — ${pl.last_name}, ${pl.first_name}`)) ); const existingRule = p.rule_number ? state.rulesByNumber[p.rule_number] : null; const ruleSearch = el("input", { type: "text", placeholder: t("search_rule"), value: existingRule ? `${existingRule.number} — ${existingRule.text}` : (p.rule_number || "") }); const ruleNumber = el("input", { type: "text", placeholder: t("rule_number_short"), value: p.rule_number || "" }); const suggestionBox = el("div", { class: "rule-card", style: { display: "none" } }); const searchResults = el("div", { class: "results", style: { display: "none" } }); const priorBox = el("div", { class: "prior-box", style: { display: "none" } }); function escalationViz(r) { if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); if (r.escalation_mode === "escalate") { const wrap = el("div", { class: "tier-row" }); const tiers = r.escalation_tiers || []; tiers.forEach((tier, i) => { wrap.appendChild(el("span", { class: "tier-pill" }, tier)); if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); }); return wrap; } return el("span", null, ""); } function refreshSuggestion() { const rNum = ruleNumber.value.trim(); const r = state.rulesByNumber[rNum]; if (!r) { suggestionBox.style.display = "none"; return; } suggestionBox.style.display = "block"; suggestionBox.innerHTML = ""; suggestionBox.appendChild(el("div", { class: "rule-head" }, el("span", { class: "rule-num" }, r.number), el("span", { class: "rule-text" }, r.text), )); suggestionBox.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("suggested_penalty")), el("span", { class: "badge accent" }, r.suggested_penalty), )); suggestionBox.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("escalation")), escalationViz(r), )); } function refreshPrior() { const rNum = ruleNumber.value.trim(); const pNum = pilotSelect.value; if (!rNum || !pNum) { priorBox.style.display = "none"; return; } const prior = state.penalties.filter((x) => x.pilot_number === pNum && x.rule_number === rNum && x.id !== p.id ); priorBox.innerHTML = ""; if (prior.length === 0) { priorBox.style.display = "none"; return; } priorBox.style.display = "block"; priorBox.appendChild(el("div", { class: "prior-title" }, t("prior_penalties") + " (" + prior.length + ")" )); const tbl = el("table", { class: "mini-table" }); tbl.appendChild(el("thead", null, el("tr", null, el("th", null, t("flight")), el("th", null, t("date")), el("th", null, t("penalty_values")), el("th", null, t("description")), ))); const tb = el("tbody"); prior.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id)); for (const pp of prior) { tb.appendChild(el("tr", null, el("td", null, pp.flight), el("td", null, pp.date), el("td", null, el("span", { class: "badge accent" }, pp.penalties_text)), el("td", null, pp.description), )); } tbl.appendChild(tb); priorBox.appendChild(tbl); } refreshSuggestion(); refreshPrior(); ruleNumber.addEventListener("input", () => { refreshSuggestion(); refreshPrior(); }); function searchRules(q) { q = q.trim().toLowerCase(); if (!q) return []; return state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q) ).slice(0, 30); } ruleSearch.addEventListener("input", () => { const items = searchRules(ruleSearch.value); searchResults.innerHTML = ""; if (items.length === 0) { searchResults.style.display = "none"; return; } searchResults.style.display = "block"; for (const r of items) { const it = el("div", { class: "item", onclick: () => { ruleNumber.value = r.number; ruleSearch.value = `${r.number} — ${r.text}`; searchResults.style.display = "none"; refreshSuggestion(); refreshPrior(); } }, el("span", { class: "num" }, r.number), r.text ); searchResults.appendChild(it); } }); ruleSearch.addEventListener("blur", () => setTimeout(() => searchResults.style.display = "none", 200)); const task = el("input", { type: "text", value: p.task || "" }); const penaltiesText = el("input", { type: "text", value: p.penalties_text || "", placeholder: "e.g. 50 CP, Warning, +50m, No Result" }); const description = el("textarea", null, p.description || ""); const transferred = el("input", { type: "checkbox", checked: !!p.transferred }); return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, el("div", { class: "modal" }, el("h3", null, p.id ? t("edit") : t("add_penalty")), el("div", { class: "grid" }, el("div", { class: "field" }, el("label", null, t("flight")), flight), el("div", { class: "field" }, el("label", null, t("date")), date), el("div", { class: "field" }, el("label", null, t("pilot_number")), pilotSelect), ), el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("search_rule")), el("div", { class: "search-box" }, ruleSearch, searchResults), ), el("div", { class: "field" }, el("label", null, t("rule_number_short")), ruleNumber), suggestionBox, priorBox, el("div", { class: "grid", style: { marginTop: "0.5rem" } }, el("div", { class: "field" }, el("label", null, t("task")), task), el("div", { class: "field" }, el("label", null, t("penalty_values")), penaltiesText), ), el("div", { class: "field" }, el("label", null, t("description")), description), el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("transferred")), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: closeAll }, t("cancel")), el("button", { class: "primary", onclick: async () => { const body = { flight: flight.value, date: date.value, pilot_number: pilotSelect.value, rule_number: ruleNumber.value, task: task.value, penalties_text: penaltiesText.value, description: description.value, transferred: transferred.checked, }; try { if (p.id) { await API.updatePenalty(state.competition.id, p.id, body); } else { await API.createPenalty(state.competition.id, body); } closeAll(); } catch (e) { alert(e.message); } } }, t("save")), ), ) ); } const PILOT_COLS = [ { key: "number", label: "number" }, { key: "last_name", label: "last_name" }, { key: "first_name", label: "first_name" }, { key: "country", label: "country" }, { key: "balloon_id", label: "balloon_id" }, ]; function naturalCompare(a, b) { if (a == null) a = ""; if (b == null) b = ""; return String(a).localeCompare(String(b), undefined, { numeric: true, sensitivity: "base" }); } function sortPilots(list) { const { col, dir } = state.pilotSort; const sign = dir === "asc" ? 1 : -1; return [...list].sort((a, b) => naturalCompare(a[col], b[col]) * sign); } function renderPilotsTab() { const card = el("div"); if (isChief()) { card.appendChild(el("div", { class: "toolbar" }, el("button", { class: "primary", onclick: () => openPilotModal() }, t("add_pilot")), el("button", { onclick: () => openImportModal() }, t("import_csv")), )); } if (state.pilots.length === 0) { card.appendChild(el("div", { class: "muted" }, t("no_pilots"))); return card; } const table = el("table"); const headRow = el("tr"); for (const c of PILOT_COLS) { headRow.appendChild(el("th", { onclick: () => { if (state.pilotSort.col === c.key) state.pilotSort.dir = state.pilotSort.dir === "asc" ? "desc" : "asc"; else { state.pilotSort.col = c.key; state.pilotSort.dir = "asc"; } render(); } }, t(c.label), el("span", { class: "sort-ind" }, state.pilotSort.col === c.key ? (state.pilotSort.dir === "asc" ? "▲" : "▼") : ""), )); } if (isChief()) headRow.appendChild(el("th", null, t("actions"))); table.appendChild(el("thead", null, headRow)); const tbody = el("tbody"); const sorted = sortPilots(state.pilots); for (const pl of sorted) { tbody.appendChild(el("tr", null, el("td", null, pl.number), el("td", null, pl.last_name), el("td", null, pl.first_name), el("td", null, pl.country), el("td", null, pl.balloon_id), isChief() && el("td", null, el("button", { class: "action-btn", onclick: () => openPilotModal(pl) }, t("edit")), el("button", { class: "action-btn danger", onclick: async () => { if (!confirm(t("confirm_delete"))) return; await API.deletePilot(state.competition.id, pl.id); await loadPilots(); render(); } }, t("delete")), ), )); } table.appendChild(tbody); card.appendChild(el("div", { class: "table-wrap" }, table)); return card; } function openPilotModal(pilot) { state.showPilotModal = true; state.editingPilot = pilot || null; render(); } function renderPilotModal() { const p = state.editingPilot || {}; const number = el("input", { type: "text", value: p.number || "" }); const lastName = el("input", { type: "text", value: p.last_name || "" }); const firstName = el("input", { type: "text", value: p.first_name || "" }); const country = el("input", { type: "text", value: p.country || "" }); const balloon = el("input", { type: "text", value: p.balloon_id || "" }); return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, el("div", { class: "modal" }, el("h3", null, p.id ? t("edit") : t("add_pilot")), el("div", { class: "grid" }, el("div", { class: "field" }, el("label", null, t("number")), number), el("div", { class: "field" }, el("label", null, t("last_name")), lastName), el("div", { class: "field" }, el("label", null, t("first_name")), firstName), el("div", { class: "field" }, el("label", null, t("country")), country), el("div", { class: "field" }, el("label", null, t("balloon_id")), balloon), ), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: closeAll }, t("cancel")), el("button", { class: "primary", onclick: async () => { const body = { number: number.value.trim(), last_name: lastName.value.trim(), first_name: firstName.value.trim(), country: country.value.trim(), balloon_id: balloon.value.trim(), }; try { if (p.id) await API.updatePilot(state.competition.id, p.id, body); else await API.createPilot(state.competition.id, body); await loadPilots(); closeAll(); } catch (e) { alert(e.message); } } }, t("save")), ), ) ); } function openImportModal() { state.showImportModal = true; render(); } function renderImportModal() { const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } }); return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) { state.showImportModal = false; render(); } } }, el("div", { class: "modal" }, el("h3", null, t("import_csv")), textarea, el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: () => { state.showImportModal = false; render(); } }, t("cancel")), el("button", { class: "primary", onclick: async () => { try { await API.importPilots(state.competition.id, textarea.value); await loadPilots(); state.showImportModal = false; render(); } catch (e) { alert(e.message); } } }, t("import_csv")), ) ) ); } function ruleEscalationViz(r) { if (r.escalation_mode === "same") return el("span", { class: "muted small" }, t("escalation_same")); if (r.escalation_mode === "doubled") return el("span", { class: "muted small" }, t("escalation_doubled")); if (r.escalation_mode === "escalate") { const wrap = el("div", { class: "tier-row" }); const tiers = r.escalation_tiers || []; tiers.forEach((tier, i) => { wrap.appendChild(el("span", { class: "tier-pill" }, tier)); if (i < tiers.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→")); }); return wrap; } return el("span", null, ""); } function renderRulesTab() { const card = el("div"); const search = el("input", { type: "search", placeholder: t("search_rule"), style: { width: "100%" } }); const count = el("div", { class: "muted", style: { margin: "0.5rem 0" } }); const list = el("div", { class: "rules-list" }); function refresh() { list.innerHTML = ""; const q = search.value.trim().toLowerCase(); const filtered = q ? state.rules.filter((r) => r.number.toLowerCase().includes(q) || r.text.toLowerCase().includes(q)) : state.rules; filtered.sort((a, b) => naturalCompare(a.number, b.number)); count.textContent = t("showing_n_of_m", { n: filtered.length, m: state.rules.length }); for (const r of filtered) { list.appendChild(el("div", { class: "rule-card" }, el("div", { class: "rule-head" }, el("span", { class: "rule-num" }, r.number), el("span", { class: "rule-text" }, r.text), ), el("div", { class: "kv" }, el("span", { class: "k" }, t("suggested_penalty")), el("span", { class: "badge accent" }, r.suggested_penalty), ), el("div", { class: "kv" }, el("span", { class: "k" }, t("escalation")), ruleEscalationViz(r), ), )); } if (filtered.length === 0) list.appendChild(el("div", { class: "muted" }, t("none"))); } search.addEventListener("input", refresh); card.appendChild(search); card.appendChild(count); card.appendChild(list); setTimeout(refresh, 0); return card; } function renderMembersTab() { const card = el("div"); card.appendChild(el("div", { class: "toolbar" }, el("button", { class: "primary", onclick: () => openMemberModal() }, t("add_member")), state.user.is_system_admin && el("button", { onclick: () => openUserModal(state.competition.id) }, t("add_user")), )); if (state.members.length === 0) { card.appendChild(el("div", { class: "muted" }, t("no_members"))); return card; } const table = el("table"); table.appendChild(el("thead", null, el("tr", null, el("th", null, t("username")), el("th", null, t("display_name")), el("th", null, t("role")), el("th", null, t("actions")), ))); const tbody = el("tbody"); for (const m of state.members) { tbody.appendChild(el("tr", null, el("td", null, m.username), el("td", null, m.display_name), el("td", null, t(m.role)), el("td", null, el("button", { class: "action-btn danger", onclick: async () => { if (!confirm(t("confirm_delete"))) return; await API.removeMember(state.competition.id, m.user_id); await loadMembers(); render(); } }, t("remove")), ), )); } table.appendChild(tbody); card.appendChild(el("div", { class: "table-wrap" }, table)); return card; } function openMemberModal() { state.showMemberModal = true; render(); } function renderMemberModal() { const username = el("input", { type: "text", placeholder: t("username") }); const roleSelect = el("select", null, el("option", { value: "scorer" }, t("scorer")), el("option", { value: "chief_scorer" }, t("chief_scorer")), ); return el("div", { class: "modal-backdrop", onclick: (e) => { if (e.target.classList.contains("modal-backdrop")) closeAll(); } }, el("div", { class: "modal" }, el("h3", null, t("add_member")), el("p", { class: "muted" }, t("username")), username, el("div", { class: "field", style: { marginTop: "0.5rem" } }, el("label", null, t("role")), roleSelect), el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { onclick: closeAll }, t("cancel")), el("button", { class: "primary", onclick: async () => { let users = []; try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; } const u = users.find((x) => x.username === username.value.trim()); if (!u) { alert("User not found"); return; } await API.addMember(state.competition.id, { user_id: u.id, role: roleSelect.value }); await loadMembers(); closeAll(); } }, t("save")), ), ) ); } function renderSettingsTab() { const name = el("input", { type: "text", value: state.competition.name }); const allow = el("input", { type: "checkbox", checked: !!state.competition.allow_any_scorer_edit }); const msg = el("div", { class: "muted", style: { color: "var(--accent)", display: "none" } }); return el("div", { class: "card" }, el("h2", null, t("settings_tab")), el("div", { class: "field" }, el("label", null, t("competition_name")), name), el("label", { class: "row", style: { marginTop: "0.5rem" } }, allow, " " + t("allow_any_scorer_edit")), msg, el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } }, el("button", { class: "primary", onclick: async () => { await API.updateCompetition(state.competition.id, { name: name.value, allow_any_scorer_edit: allow.checked, }); const c = await API.getCompetition(state.competition.id); state.competition = c; msg.textContent = t("saved"); msg.style.display = "block"; } }, t("save_settings")), ), ); } async function init() { const userLang = navigator.language ? navigator.language.slice(0, 2) : "en"; setLang(I18N_AVAILABLE.includes(userLang) ? userLang : "en"); try { const u = await API.me(); state.user = u; setLang(u.language); await loadCompetitions(); if (u.is_system_admin) await loadUsers(); } catch (e) { state.user = null; } render(); } init();