Files
PenaltyTracker/web/app.js
T
2026-05-16 20:39:27 +02:00

1164 lines
43 KiB
JavaScript

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();