1233 lines
50 KiB
JavaScript
1233 lines
50 KiB
JavaScript
(async function () {
|
|
const root = document.getElementById("app");
|
|
const user = await bootstrapAuth({ requireAuth: true, forbidIfMustChange: true });
|
|
if (!user) return;
|
|
|
|
const params = queryParams();
|
|
const competitionId = parseInt(params.id, 10);
|
|
if (!competitionId) { navigate("competitions"); return; }
|
|
|
|
let competition;
|
|
try {
|
|
competition = await API.getCompetition(competitionId);
|
|
} catch (e) {
|
|
navigate("competitions");
|
|
return;
|
|
}
|
|
|
|
const state = {
|
|
competition,
|
|
pilots: [],
|
|
penalties: [],
|
|
members: [],
|
|
users: [],
|
|
rules: [],
|
|
rulesByNumber: {},
|
|
ws: null,
|
|
wsOnline: false,
|
|
tab: params.tab || "penalties",
|
|
sort: { col: "date", dir: "desc" },
|
|
pilotSort: { col: "number", dir: "asc" },
|
|
filterApplied: "all", // "all" | "applied" | "open"
|
|
filterText: "",
|
|
};
|
|
|
|
async function loadAll() {
|
|
await Promise.all([loadPilots(), loadPenalties(), loadMembers(), loadRules()]);
|
|
if (user.is_system_admin) await loadUsers();
|
|
}
|
|
async function loadPilots() { state.pilots = await API.listPilots(competitionId); }
|
|
async function loadPenalties() { state.penalties = await API.listPenalties(competitionId); }
|
|
async function loadMembers() { state.members = await API.listMembers(competitionId); }
|
|
async function loadUsers() { state.users = await API.listUsers(); }
|
|
async function loadRules() {
|
|
state.rules = await API.listRules(user.language);
|
|
state.rulesByNumber = {};
|
|
for (const r of state.rules) state.rulesByNumber[r.number] = r;
|
|
}
|
|
|
|
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 === user.id) return true;
|
|
return !!state.competition.allow_any_scorer_edit;
|
|
}
|
|
|
|
function pilotCountForRule(pilotNumber, ruleNumber, excludeId) {
|
|
if (!pilotNumber || !ruleNumber) return 0;
|
|
let n = 0;
|
|
for (const p of state.penalties) {
|
|
if (p.pilot_number === pilotNumber && p.rule_number === ruleNumber && p.id !== excludeId) n++;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
// ---- Penalties tab -----------------------------------------------------
|
|
|
|
const PENALTY_COLS = [
|
|
{ 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: "applied" },
|
|
];
|
|
|
|
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.filterApplied === "applied") list = list.filter((p) => p.transferred);
|
|
else if (state.filterApplied === "open") list = list.filter((p) => !p.transferred);
|
|
const q = state.filterText.trim().toLowerCase();
|
|
if (q) {
|
|
list = list.filter((p) =>
|
|
(p.flight || "").toLowerCase().includes(q) ||
|
|
(p.date || "").toLowerCase().includes(q) ||
|
|
(p.pilot_number || "").toLowerCase().includes(q) ||
|
|
(p.pilot_name || "").toLowerCase().includes(q) ||
|
|
(p.rule_number || "").toLowerCase().includes(q) ||
|
|
(p.task || "").toLowerCase().includes(q) ||
|
|
(p.penalties_text || "").toLowerCase().includes(q) ||
|
|
(p.description || "").toLowerCase().includes(q) ||
|
|
(p.created_by_name || "").toLowerCase().includes(q)
|
|
);
|
|
}
|
|
return list;
|
|
}
|
|
|
|
function renderPenaltiesTab() {
|
|
const card = el("div");
|
|
|
|
const search = el("input", { type: "search", placeholder: t("search_penalties"),
|
|
style: { minWidth: "240px", flex: "1" },
|
|
value: state.filterText,
|
|
oninput: (e) => { state.filterText = e.target.value; patchPenaltyTable(); }
|
|
});
|
|
|
|
const applyFilter = el("select", {
|
|
onchange: (e) => { state.filterApplied = e.target.value; patchPenaltyTable(); }
|
|
},
|
|
el("option", { value: "all", selected: state.filterApplied === "all" }, t("filter_all")),
|
|
el("option", { value: "open", selected: state.filterApplied === "open" }, t("filter_open")),
|
|
el("option", { value: "applied", selected: state.filterApplied === "applied" }, t("filter_applied")),
|
|
);
|
|
|
|
const toolbar = el("div", { class: "toolbar" },
|
|
el("button", { class: "primary", onclick: () => openPenaltyModal() }, t("add_penalty")),
|
|
search,
|
|
applyFilter,
|
|
el("div", { class: "spacer" }),
|
|
isChief() && el("button", { onclick: openApplyByTaskModal }, t("apply_by_task")),
|
|
isChief() && el("button", { onclick: async () => {
|
|
try {
|
|
const blob = await API.exportPenaltiesCSV(competitionId);
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = `penalties_${competitionId}.csv`;
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
a.remove();
|
|
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
} catch (e) { alert(e.message); }
|
|
} }, 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", {
|
|
title: c.title ? t(c.title) : null,
|
|
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);
|
|
|
|
const status = el("div", { class: "muted", id: "penalty-count", style: { marginTop: "0.5rem" } });
|
|
card.appendChild(status);
|
|
|
|
setTimeout(patchPenaltyTable, 0);
|
|
return card;
|
|
}
|
|
|
|
function patchPenaltyTable() {
|
|
const tbody = document.getElementById("penalty-tbody");
|
|
if (!tbody) return;
|
|
const list = sortPenalties(filterPenalties(state.penalties));
|
|
|
|
const status = document.getElementById("penalty-count");
|
|
if (status) status.textContent = t("showing_n_of_m", { n: list.length, m: state.penalties.length });
|
|
|
|
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(competitionId, 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 = "";
|
|
const summary = document.createElement("button");
|
|
summary.className = "action-btn";
|
|
summary.textContent = t("summary");
|
|
summary.addEventListener("click", () => openPenaltySummary(p));
|
|
actions.appendChild(summary);
|
|
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(competitionId, p.id);
|
|
});
|
|
actions.appendChild(edit);
|
|
actions.appendChild(del);
|
|
}
|
|
}
|
|
|
|
function openPenaltySummary(p) {
|
|
const rule = p.rule_number ? state.rulesByNumber[p.rule_number] : null;
|
|
const count = pilotCountForRule(p.pilot_number, p.rule_number, p.id);
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
|
|
function escalationViz(r) {
|
|
if (!r) return el("span", null, "");
|
|
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" });
|
|
(r.escalation_tiers || []).forEach((tier, i, arr) => {
|
|
wrap.appendChild(el("span", { class: "tier-pill" }, tier));
|
|
if (i < arr.length - 1) wrap.appendChild(el("span", { class: "tier-arrow" }, "→"));
|
|
});
|
|
return wrap;
|
|
}
|
|
return el("span", null, "");
|
|
}
|
|
|
|
const incidentsBox = el("div", { style: { display: "none", marginTop: "0.5rem" } });
|
|
const incidentsBtn = el("button", { class: "action-btn", disabled: count === 0,
|
|
onclick: () => {
|
|
if (incidentsBox.style.display === "none") {
|
|
incidentsBox.innerHTML = "";
|
|
const prior = state.penalties
|
|
.filter((x) => x.pilot_number === p.pilot_number && x.rule_number === p.rule_number && x.id !== p.id)
|
|
.sort((a, b) => naturalCompare(a.date, b.date) || (a.id - b.id));
|
|
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("task")),
|
|
el("th", null, t("penalty_values")),
|
|
el("th", null, t("description")),
|
|
el("th", null, t("applied")),
|
|
)));
|
|
const tb = el("tbody");
|
|
for (const pp of prior) {
|
|
tb.appendChild(el("tr", null,
|
|
el("td", null, pp.flight || ""),
|
|
el("td", null, pp.date || ""),
|
|
el("td", null, pp.task || ""),
|
|
el("td", null, el("span", { class: "badge accent" }, pp.penalties_text || "")),
|
|
el("td", null, pp.description || ""),
|
|
el("td", null, pp.transferred ? t("yes") : t("no")),
|
|
));
|
|
}
|
|
tbl.appendChild(tb);
|
|
incidentsBox.appendChild(tbl);
|
|
incidentsBox.style.display = "block";
|
|
incidentsBtn.textContent = t("hide_incidents");
|
|
} else {
|
|
incidentsBox.style.display = "none";
|
|
incidentsBox.innerHTML = "";
|
|
incidentsBtn.textContent = t("show_incidents") + " (" + count + ")";
|
|
}
|
|
} }, t("show_incidents") + " (" + count + ")");
|
|
|
|
backdrop.appendChild(el("div", { class: "modal" },
|
|
el("h3", null, t("penalty_summary")),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("pilot_number")),
|
|
el("span", null, p.pilot_number + (p.pilot_name ? " — " + p.pilot_name : ""))),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("flight")), el("span", null, p.flight || "—")),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("date")), el("span", null, p.date || "—")),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("task")), el("span", null, p.task || "—")),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("penalty_values")),
|
|
el("span", { class: "badge accent" }, p.penalties_text || "—")),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("description")), el("span", null, p.description || "—")),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("created_by")), el("span", null, p.created_by_name || "—")),
|
|
el("div", { class: "kv" }, el("span", { class: "k" }, t("applied")),
|
|
el("span", { class: p.transferred ? "badge accent" : "badge" }, p.transferred ? t("yes") : t("no"))),
|
|
el("div", { class: "row", style: { marginTop: "0.5rem" } }, incidentsBtn),
|
|
incidentsBox,
|
|
el("h4", { style: { marginTop: "1rem", marginBottom: "0.25rem" } }, t("rule")),
|
|
rule ? el("div", { class: "rule-card" },
|
|
el("div", { class: "rule-head" },
|
|
el("span", { class: "rule-num" }, rule.number),
|
|
el("span", { class: "rule-text" }, rule.text),
|
|
),
|
|
el("div", { class: "kv" },
|
|
el("span", { class: "k" }, t("suggested_penalty")),
|
|
el("span", { class: "badge accent" }, rule.suggested_penalty || "—"),
|
|
),
|
|
el("div", { class: "kv" },
|
|
el("span", { class: "k" }, t("escalation")),
|
|
escalationViz(rule),
|
|
),
|
|
) : el("div", { class: "muted" }, p.rule_number ? (p.rule_number + " — " + t("rule_not_found")) : t("none")),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: () => backdrop.remove() }, t("close")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
// Apply workflow: select task → step through each pilot's open penalties one
|
|
// at a time → review overview → save all reviewed as applied.
|
|
function openApplyByTaskModal() {
|
|
const wiz = {
|
|
task: null, // selected task string ("" for empty-task)
|
|
pilots: [], // [{ pilotNumber, pilotName, penalties: [...] }]
|
|
cursor: 0, // index into wiz.pilots
|
|
reviewedIds: [], // penalty IDs the user has stepped past
|
|
};
|
|
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) attemptClose(); } });
|
|
const modal = el("div", { class: "modal" });
|
|
backdrop.appendChild(modal);
|
|
document.body.appendChild(backdrop);
|
|
|
|
function attemptClose() {
|
|
if (wiz.reviewedIds.length === 0) {
|
|
backdrop.remove();
|
|
return;
|
|
}
|
|
const choice = confirm(t("confirm_save_partial", { n: wiz.reviewedIds.length }));
|
|
if (choice) {
|
|
savePartial();
|
|
} else {
|
|
backdrop.remove();
|
|
}
|
|
}
|
|
|
|
async function savePartial() {
|
|
try {
|
|
await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true });
|
|
backdrop.remove();
|
|
await loadPenalties();
|
|
patchPenaltyTable();
|
|
} catch (e) { alert(e.message); }
|
|
}
|
|
|
|
function pilotLookup(num) {
|
|
return state.pilots.find((p) => p.number === num);
|
|
}
|
|
|
|
function renderTaskSelect() {
|
|
const groups = {};
|
|
for (const p of state.penalties) {
|
|
if (p.transferred) continue;
|
|
const task = p.task || "";
|
|
if (!groups[task]) groups[task] = 0;
|
|
groups[task]++;
|
|
}
|
|
const tasks = Object.keys(groups).sort(naturalCompare);
|
|
|
|
modal.innerHTML = "";
|
|
modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } },
|
|
el("h3", { style: { margin: 0 } }, t("apply_by_task")),
|
|
el("button", { class: "ghost", onclick: attemptClose }, "✕"),
|
|
));
|
|
modal.appendChild(el("p", { class: "muted" }, t("apply_select_task")));
|
|
|
|
if (tasks.length === 0) {
|
|
modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("no_open_penalties")));
|
|
modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end" } },
|
|
el("button", { onclick: attemptClose }, t("close")),
|
|
));
|
|
return;
|
|
}
|
|
|
|
const list = el("div");
|
|
for (const task of tasks) {
|
|
const label = task === "" ? t("no_task") : task;
|
|
list.appendChild(el("div", { class: "row apply-row",
|
|
style: { justifyContent: "space-between", padding: "0.4rem 0", borderBottom: "1px solid var(--border)" } },
|
|
el("div", null, el("strong", null, label), " ",
|
|
el("span", { class: "muted" }, t("open") + ": " + groups[task])),
|
|
el("button", { class: "primary",
|
|
onclick: () => startWalk(task) }, t("start_apply")),
|
|
));
|
|
}
|
|
modal.appendChild(list);
|
|
modal.appendChild(el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: attemptClose }, t("close")),
|
|
));
|
|
}
|
|
|
|
function startWalk(task) {
|
|
wiz.task = task;
|
|
// Group open penalties for this task by pilot, sorted by pilot number.
|
|
const byPilot = new Map();
|
|
for (const p of state.penalties) {
|
|
if (p.transferred) continue;
|
|
if ((p.task || "") !== task) continue;
|
|
const key = p.pilot_number || "";
|
|
if (!byPilot.has(key)) {
|
|
byPilot.set(key, {
|
|
pilotNumber: p.pilot_number || "",
|
|
pilotName: p.pilot_name || "",
|
|
penalties: [],
|
|
});
|
|
}
|
|
byPilot.get(key).penalties.push(p);
|
|
}
|
|
wiz.pilots = Array.from(byPilot.values())
|
|
.sort((a, b) => naturalCompare(a.pilotNumber, b.pilotNumber));
|
|
wiz.cursor = 0;
|
|
wiz.reviewedIds = [];
|
|
if (wiz.pilots.length === 0) renderOverview();
|
|
else renderPilotStep();
|
|
}
|
|
|
|
function renderPilotStep() {
|
|
const entry = wiz.pilots[wiz.cursor];
|
|
const pilot = pilotLookup(entry.pilotNumber);
|
|
const stepNum = wiz.cursor + 1;
|
|
const stepTotal = wiz.pilots.length;
|
|
|
|
modal.innerHTML = "";
|
|
modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } },
|
|
el("h3", { style: { margin: 0 } },
|
|
t("apply_by_task") + " — ",
|
|
wiz.task === "" ? t("no_task") : wiz.task,
|
|
),
|
|
el("button", { class: "ghost", onclick: attemptClose }, "✕"),
|
|
));
|
|
modal.appendChild(el("div", { class: "muted", style: { marginBottom: "0.5rem" } },
|
|
t("step_x_of_y", { x: stepNum, y: stepTotal })));
|
|
|
|
modal.appendChild(el("h4", null,
|
|
t("pilot") + ": ",
|
|
entry.pilotNumber,
|
|
entry.pilotName ? " — " + entry.pilotName : "",
|
|
));
|
|
if (pilot) {
|
|
modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("country")),
|
|
el("span", null, pilot.country || "—")));
|
|
modal.appendChild(el("div", { class: "kv" }, el("span", { class: "k" }, t("balloon_id")),
|
|
el("span", null, pilot.balloon_id || "—")));
|
|
}
|
|
|
|
const tbl = el("table", { class: "mini-table", style: { marginTop: "0.5rem" } });
|
|
tbl.appendChild(el("thead", null, el("tr", null,
|
|
el("th", null, t("flight")),
|
|
el("th", null, t("date")),
|
|
el("th", null, t("rule_number_short")),
|
|
el("th", null, t("penalty_values")),
|
|
el("th", null, t("description")),
|
|
)));
|
|
const tb = el("tbody");
|
|
for (const p of entry.penalties) {
|
|
tb.appendChild(el("tr", null,
|
|
el("td", null, p.flight || ""),
|
|
el("td", null, p.date || ""),
|
|
el("td", null, p.rule_number || ""),
|
|
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
|
el("td", null, p.description || ""),
|
|
));
|
|
}
|
|
tbl.appendChild(tb);
|
|
modal.appendChild(tbl);
|
|
|
|
modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } },
|
|
el("button", { onclick: attemptClose }, t("cancel")),
|
|
el("div", { class: "row" },
|
|
wiz.cursor > 0 && el("button", { onclick: () => {
|
|
// step back: don't remove already-reviewed pilots (they remain
|
|
// in reviewedIds — going back is just preview).
|
|
wiz.cursor--;
|
|
renderPilotStep();
|
|
} }, "← " + t("back")),
|
|
el("button", { class: "primary", onclick: () => {
|
|
for (const p of entry.penalties) {
|
|
if (!wiz.reviewedIds.includes(p.id)) wiz.reviewedIds.push(p.id);
|
|
}
|
|
wiz.cursor++;
|
|
if (wiz.cursor >= wiz.pilots.length) renderOverview();
|
|
else renderPilotStep();
|
|
} }, wiz.cursor + 1 >= wiz.pilots.length ? t("to_overview") + " →" : t("next") + " →"),
|
|
),
|
|
));
|
|
}
|
|
|
|
function renderOverview() {
|
|
modal.innerHTML = "";
|
|
modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", alignItems: "center" } },
|
|
el("h3", { style: { margin: 0 } },
|
|
t("apply_overview") + " — ",
|
|
wiz.task === "" ? t("no_task") : wiz.task,
|
|
),
|
|
el("button", { class: "ghost", onclick: attemptClose }, "✕"),
|
|
));
|
|
|
|
const reviewedSet = new Set(wiz.reviewedIds);
|
|
const items = [];
|
|
for (const entry of wiz.pilots) {
|
|
for (const p of entry.penalties) {
|
|
if (reviewedSet.has(p.id)) items.push(p);
|
|
}
|
|
}
|
|
|
|
modal.appendChild(el("p", { class: "muted" },
|
|
t("apply_overview_explain", { n: items.length })));
|
|
|
|
if (items.length === 0) {
|
|
modal.appendChild(el("div", { class: "muted", style: { padding: "1rem 0" } }, t("nothing_to_apply")));
|
|
} else {
|
|
const tbl = el("table", { class: "mini-table" });
|
|
tbl.appendChild(el("thead", null, el("tr", null,
|
|
el("th", null, t("pilot_number")),
|
|
el("th", null, t("pilot_name")),
|
|
el("th", null, t("flight")),
|
|
el("th", null, t("rule_number_short")),
|
|
el("th", null, t("penalty_values")),
|
|
el("th", null, t("description")),
|
|
)));
|
|
const tb = el("tbody");
|
|
items.sort((a, b) =>
|
|
naturalCompare(a.pilot_number, b.pilot_number) ||
|
|
naturalCompare(a.flight, b.flight) ||
|
|
(a.id - b.id));
|
|
for (const p of items) {
|
|
tb.appendChild(el("tr", null,
|
|
el("td", null, p.pilot_number || ""),
|
|
el("td", null, p.pilot_name || ""),
|
|
el("td", null, p.flight || ""),
|
|
el("td", null, p.rule_number || ""),
|
|
el("td", null, el("span", { class: "badge accent" }, p.penalties_text || "")),
|
|
el("td", null, p.description || ""),
|
|
));
|
|
}
|
|
tbl.appendChild(tb);
|
|
modal.appendChild(el("div", { class: "table-wrap" }, tbl));
|
|
}
|
|
|
|
modal.appendChild(el("div", { class: "row", style: { justifyContent: "space-between", marginTop: "1rem" } },
|
|
el("button", { onclick: () => {
|
|
// go back to last pilot step (preview)
|
|
if (wiz.pilots.length > 0) {
|
|
wiz.cursor = wiz.pilots.length - 1;
|
|
renderPilotStep();
|
|
} else {
|
|
renderTaskSelect();
|
|
}
|
|
} }, "← " + t("back")),
|
|
el("div", { class: "row" },
|
|
el("button", { onclick: attemptClose }, t("cancel")),
|
|
el("button", { class: "primary", disabled: items.length === 0, onclick: async (e) => {
|
|
e.target.disabled = true;
|
|
try {
|
|
await API.applyPenalties(competitionId, { ids: wiz.reviewedIds, applied: true });
|
|
backdrop.remove();
|
|
await loadPenalties();
|
|
patchPenaltyTable();
|
|
} catch (err) { alert(err.message); e.target.disabled = false; }
|
|
} }, t("save")),
|
|
),
|
|
));
|
|
}
|
|
|
|
renderTaskSelect();
|
|
}
|
|
|
|
function openPenaltyModal(penalty) {
|
|
const p = penalty || {};
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
|
|
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" });
|
|
(r.escalation_tiers || []).forEach((tier, i, arr) => {
|
|
wrap.appendChild(el("span", { class: "tier-pill" }, tier));
|
|
if (i < arr.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 });
|
|
|
|
backdrop.appendChild(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),
|
|
isChief() && el("label", { class: "row", style: { marginTop: "0.5rem" } }, transferred, " " + t("applied")),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: () => backdrop.remove() }, 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,
|
|
};
|
|
if (isChief()) body.transferred = transferred.checked;
|
|
try {
|
|
if (p.id) await API.updatePenalty(competitionId, p.id, body);
|
|
else await API.createPenalty(competitionId, body);
|
|
backdrop.remove();
|
|
} catch (e) { alert(e.message); }
|
|
} }, t("save")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
// ---- Pilots tab --------------------------------------------------------
|
|
|
|
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 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");
|
|
for (const pl of sortPilots(state.pilots)) {
|
|
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(competitionId, pl.id);
|
|
await loadPilots();
|
|
render();
|
|
} }, t("delete")),
|
|
),
|
|
));
|
|
}
|
|
table.appendChild(tbody);
|
|
card.appendChild(el("div", { class: "table-wrap" }, table));
|
|
return card;
|
|
}
|
|
|
|
function openPilotModal(pilot) {
|
|
const p = pilot || {};
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
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 || "" });
|
|
backdrop.appendChild(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: () => backdrop.remove() }, 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(competitionId, p.id, body);
|
|
else await API.createPilot(competitionId, body);
|
|
await loadPilots();
|
|
backdrop.remove();
|
|
render();
|
|
} catch (e) { alert(e.message); }
|
|
} }, t("save")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
function openImportModal() {
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
const textarea = el("textarea", { placeholder: t("csv_paste"), style: { width: "100%", minHeight: "180px" } });
|
|
backdrop.appendChild(el("div", { class: "modal" },
|
|
el("h3", null, t("import_csv")),
|
|
textarea,
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: () => backdrop.remove() }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
try {
|
|
await API.importPilots(competitionId, textarea.value);
|
|
await loadPilots();
|
|
backdrop.remove();
|
|
render();
|
|
} catch (e) { alert(e.message); }
|
|
} }, t("import_csv")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
// ---- Rules tab ---------------------------------------------------------
|
|
|
|
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" });
|
|
(r.escalation_tiers || []).forEach((tier, i, arr) => {
|
|
wrap.appendChild(el("span", { class: "tier-pill" }, tier));
|
|
if (i < arr.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;
|
|
}
|
|
|
|
// ---- Members tab -------------------------------------------------------
|
|
|
|
function renderMembersTab() {
|
|
const card = el("div");
|
|
card.appendChild(el("div", { class: "toolbar" },
|
|
el("button", { class: "primary", onclick: openMemberModal }, t("add_member")),
|
|
user.is_system_admin && el("button", { onclick: openUserModalForCompetition }, 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(competitionId, m.user_id);
|
|
await loadMembers();
|
|
render();
|
|
} }, t("remove")),
|
|
),
|
|
));
|
|
}
|
|
table.appendChild(tbody);
|
|
card.appendChild(el("div", { class: "table-wrap" }, table));
|
|
return card;
|
|
}
|
|
|
|
function openMemberModal() {
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
const username = el("input", { type: "text", placeholder: t("username"),
|
|
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
|
|
const roleSelect = el("select", null,
|
|
el("option", { value: "scorer" }, t("scorer")),
|
|
el("option", { value: "chief_scorer" }, t("chief_scorer")),
|
|
);
|
|
backdrop.appendChild(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: () => backdrop.remove() }, t("cancel")),
|
|
el("button", { class: "primary", onclick: async () => {
|
|
let users = [];
|
|
try { users = await API.listUsers(); } catch (e) { alert(t("forbidden")); return; }
|
|
const wanted = username.value.trim().toLowerCase();
|
|
const u = users.find((x) => x.username === wanted);
|
|
if (!u) { alert(t("user_not_found")); return; }
|
|
await API.addMember(competitionId, { user_id: u.id, role: roleSelect.value });
|
|
await loadMembers();
|
|
backdrop.remove();
|
|
render();
|
|
} }, t("save")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
function openUserModalForCompetition() {
|
|
const backdrop = el("div", { class: "modal-backdrop",
|
|
onclick: (e) => { if (e.target === backdrop) backdrop.remove(); } });
|
|
const username = el("input", { type: "text",
|
|
oninput: (e) => { e.target.value = e.target.value.toLowerCase(); } });
|
|
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 roleSelect = el("select", null,
|
|
el("option", { value: "scorer" }, t("scorer")),
|
|
el("option", { value: "chief_scorer" }, t("chief_scorer")),
|
|
);
|
|
backdrop.appendChild(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),
|
|
el("div", { class: "field" }, el("label", null, t("role")), roleSelect),
|
|
el("div", { class: "row", style: { justifyContent: "flex-end", marginTop: "1rem" } },
|
|
el("button", { onclick: () => backdrop.remove() }, 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().toLowerCase(),
|
|
password: password.value,
|
|
display_name: displayName.value,
|
|
language: langSelect.value,
|
|
is_system_admin: false,
|
|
});
|
|
await API.addMember(competitionId, { user_id: created.id, role: roleSelect.value });
|
|
await loadMembers();
|
|
backdrop.remove();
|
|
render();
|
|
} catch (err) { alert(err.message); }
|
|
} }, t("create")),
|
|
),
|
|
));
|
|
document.body.appendChild(backdrop);
|
|
}
|
|
|
|
// ---- Settings tab ------------------------------------------------------
|
|
|
|
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 () => {
|
|
try {
|
|
await API.updateCompetition(competitionId, {
|
|
name: name.value, allow_any_scorer_edit: allow.checked,
|
|
});
|
|
const c = await API.getCompetition(competitionId);
|
|
state.competition = c;
|
|
msg.textContent = t("saved");
|
|
msg.style.display = "block";
|
|
} catch (e) { alert(e.message); }
|
|
} }, t("save_settings")),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ---- Main render -------------------------------------------------------
|
|
|
|
function render() {
|
|
clearNode(root);
|
|
const backBtn = el("button", { class: "ghost",
|
|
onclick: () => { if (state.ws) { state.ws.close(); state.ws = null; } navigate("competitions"); } },
|
|
"← " + t("back"));
|
|
root.appendChild(renderTopbar(user, { extra: 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);
|
|
}
|
|
|
|
function handleWSMessage(msg) {
|
|
if (msg.type === "penalty_created") {
|
|
if (!state.penalties.some((p) => p.id === msg.payload.id)) {
|
|
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 === "penalties_applied") {
|
|
loadPenalties().then(() => 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(competitionId).then((c) => { state.competition = c; render(); });
|
|
}
|
|
}
|
|
|
|
await loadAll();
|
|
state.ws = openCompetitionWS(competitionId, {
|
|
onopen: () => { state.wsOnline = true; const e = document.querySelector(".connection-status"); if (e) { e.classList.add("online"); e.classList.remove("offline"); } },
|
|
onclose: () => { state.wsOnline = false; const e = document.querySelector(".connection-status"); if (e) { e.classList.remove("online"); e.classList.add("offline"); } },
|
|
onmessage: handleWSMessage,
|
|
});
|
|
render();
|
|
})();
|