856 lines
36 KiB
JavaScript
856 lines
36 KiB
JavaScript
import { getDriverProfileGroups, getDriverProfileId } from "./driver_profile_logic.js";
|
|
|
|
function getScheduleDriftSummary(state) {
|
|
const scheduledSessions = state.sessions
|
|
.filter((session) => Number(session.startedAt || 0) > 0)
|
|
.sort((left, right) => Number(left.startedAt || 0) - Number(right.startedAt || 0));
|
|
if (!scheduledSessions.length) {
|
|
return null;
|
|
}
|
|
const nowTs = Date.now();
|
|
const plannedMs = scheduledSessions.reduce(
|
|
(sum, session) => sum + Math.max(1, Number(session.durationMin || 0) || 0) * 60000 + Math.max(0, Number(session.followUpSec || 0) || 0) * 1000,
|
|
0
|
|
);
|
|
const actualMs = scheduledSessions.reduce((sum, session) => {
|
|
const startedAt = Number(session.startedAt || 0) || 0;
|
|
const endedAt = Number(session.endedAt || 0) || (session.status === "finished" ? nowTs : 0);
|
|
if (!startedAt) {
|
|
return sum;
|
|
}
|
|
const effectiveEnd = endedAt || nowTs;
|
|
return sum + Math.max(0, effectiveEnd - startedAt);
|
|
}, 0);
|
|
return {
|
|
plannedMs,
|
|
actualMs,
|
|
driftMs: actualMs - plannedMs,
|
|
};
|
|
}
|
|
|
|
function statCard(label, value, note) {
|
|
return `
|
|
<article class="stat-card">
|
|
<p>${label}</p>
|
|
<h3>${value}</h3>
|
|
<small>${note}</small>
|
|
</article>
|
|
`;
|
|
}
|
|
|
|
export function renderDashboardView(deps) {
|
|
const {
|
|
state,
|
|
dom,
|
|
t,
|
|
backend,
|
|
getActiveSession,
|
|
getStatusLabel,
|
|
getSessionTypeLabel,
|
|
getEventName,
|
|
getModeLabel,
|
|
getBackendUrl,
|
|
formatLap,
|
|
renderSessionsTable,
|
|
setCurrentView,
|
|
updateViewUrl,
|
|
renderNav,
|
|
renderView,
|
|
connectDecoder,
|
|
disconnectDecoder,
|
|
openOverlayWindow,
|
|
ensureAudioContext,
|
|
playPassingBeep,
|
|
playFinishSiren,
|
|
escapeHtml,
|
|
} = deps;
|
|
|
|
const active = getActiveSession();
|
|
const schedule = getScheduleDriftSummary(state);
|
|
const totalPassings = Object.values(state.resultsBySession || {}).reduce((sum, x) => sum + (x?.passings?.length || 0), 0);
|
|
const backendUrl = getBackendUrl();
|
|
const decoderUrl = state.settings.wsUrl || "-";
|
|
const audioProfile =
|
|
state.settings.passingSoundMode === "name"
|
|
? t("settings.passing_sound_name")
|
|
: state.settings.passingSoundMode === "beep"
|
|
? t("settings.passing_sound_beep")
|
|
: t("settings.passing_sound_off");
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="panel dashboard-workflow">
|
|
<div class="panel-header">
|
|
<div>
|
|
<h3>${t("dashboard.get_started")}</h3>
|
|
<p class="hint">${t("dashboard.get_started_hint")}</p>
|
|
</div>
|
|
</div>
|
|
<div class="panel-body dashboard-workflow-grid">
|
|
<article class="dashboard-workflow-card">
|
|
<span class="workflow-number">1</span>
|
|
<div><strong>${t("dashboard.step_system")}</strong><p>${t("dashboard.step_system_hint")}</p></div>
|
|
<button id="workflowSettings" class="btn" type="button">${t("dashboard.open_settings")}</button>
|
|
</article>
|
|
<article class="dashboard-workflow-card">
|
|
<span class="workflow-number">2</span>
|
|
<div><strong>${t("dashboard.step_register")}</strong><p>${t("dashboard.step_register_hint")}</p></div>
|
|
<button id="workflowDrivers" class="btn" type="button">${t("dashboard.open_register")}</button>
|
|
</article>
|
|
<article class="dashboard-workflow-card dashboard-workflow-card-primary">
|
|
<span class="workflow-number">3</span>
|
|
<div><strong>${t("dashboard.step_create")}</strong><p>${t("dashboard.step_create_hint")}</p></div>
|
|
<button id="workflowRaceSetup" class="btn btn-primary" type="button">${t("dashboard.open_race_setup")}</button>
|
|
</article>
|
|
<article class="dashboard-workflow-card">
|
|
<span class="workflow-number">4</span>
|
|
<div><strong>${t("dashboard.step_run")}</strong><p>${t("dashboard.step_run_hint")}</p></div>
|
|
<div class="actions-inline">
|
|
<button id="workflowRaceDay" class="btn btn-primary" type="button">${t("dashboard.open_race_day")}</button>
|
|
<button id="workflowTiming" class="btn" type="button">${t("dashboard.open_timing")}</button>
|
|
<button id="workflowOverlay" class="btn" type="button">${t("dashboard.open_overlay")}</button>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="grid cols-4">
|
|
${statCard(t("dashboard.events"), String(state.events.length), t("dashboard.created"))}
|
|
${statCard(t("dashboard.drivers"), String(state.drivers.length), t("dashboard.registered"))}
|
|
${statCard(t("dashboard.cars"), String(state.cars.length), t("dashboard.track_fleet"))}
|
|
${statCard(t("dashboard.passings"), String(totalPassings), t("dashboard.captured"))}
|
|
</div>
|
|
|
|
<div class="panel-row">
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h3>${t("dashboard.live_session")}</h3>
|
|
<span class="pill ${active ? "pill-green" : ""}">${active ? getStatusLabel(active.status) : t("dashboard.idle")}</span>
|
|
</div>
|
|
<div class="panel-body">
|
|
${
|
|
active
|
|
? `<p><strong>${active.name}</strong> (${getSessionTypeLabel(active.type)})</p>
|
|
<p>${getEventName(active.eventId)} • ${getModeLabel(active.mode)}</p>
|
|
<p>${t("dashboard.duration")}: ${active.type === "open_practice" ? t("dashboard.untimed") : `${active.durationMin} min`}</p>`
|
|
: `<p>${t("dashboard.no_session")}</p>`
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h3>${t("dashboard.live_board")}</h3>
|
|
</div>
|
|
<div class="panel-body dashboard-live-stack">
|
|
<p class="hint">${t("dashboard.live_note")}</p>
|
|
<div class="dashboard-live-grid">
|
|
<article class="dashboard-live-card">
|
|
<span>${t("dashboard.decoder_feed")}</span>
|
|
<strong>${state.decoder.connected ? t("timing.connected") : t("timing.disconnected")}</strong>
|
|
<small>${escapeHtml(decoderUrl)}</small>
|
|
${state.settings.splitDecoderEnabled ? `<small>${t("timing.split_decoder")}: ${state.decoder.splitConnected ? t("timing.connected") : t("timing.disconnected")}</small>` : ""}
|
|
${state.settings.pitDecoderEnabled ? `<small>${t("table.pit")}: ${Number(state.decoder.pitEntryConnected) + Number(state.decoder.pitExitConnected)}/2</small>` : ""}
|
|
</article>
|
|
<article class="dashboard-live-card">
|
|
<span>${t("dashboard.backend_link")}</span>
|
|
<strong>${backend.available ? t("settings.online") : t("settings.offline")}</strong>
|
|
<small>${escapeHtml(backendUrl)}</small>
|
|
</article>
|
|
<article class="dashboard-live-card">
|
|
<span>${t("dashboard.audio_profile")}</span>
|
|
<strong>${state.settings.audioEnabled ? audioProfile : t("settings.passing_sound_off")}</strong>
|
|
<small>${state.settings.finishVoiceEnabled ? t("settings.finish_voice") : "-"}</small>
|
|
</article>
|
|
<article class="dashboard-live-card">
|
|
<span>${t("dashboard.schedule_drift")}</span>
|
|
<strong>${
|
|
schedule
|
|
? `${schedule.driftMs === 0 ? t("dashboard.on_time") : schedule.driftMs < 0 ? t("dashboard.ahead") : t("dashboard.behind")} ${formatLap(Math.abs(schedule.driftMs))}`
|
|
: "-"
|
|
}</strong>
|
|
<small>${
|
|
schedule
|
|
? `${t("dashboard.schedule_plan")}: ${formatLap(schedule.plannedMs)} • ${t("dashboard.schedule_actual")}: ${formatLap(schedule.actualMs)}`
|
|
: "-"
|
|
}</small>
|
|
</article>
|
|
</div>
|
|
<div class="actions">
|
|
<button id="goEvents" class="btn btn-primary">${t("dashboard.create_event")}</button>
|
|
<button id="goRaceDay" class="btn btn-primary">${t("dashboard.open_race_day")}</button>
|
|
<button id="goTiming" class="btn">${t("dashboard.open_timing")}</button>
|
|
<button id="openDashboardOverlay" class="btn">${t("timing.open_overlay")}</button>
|
|
<button id="connectNow" class="btn">${t("dashboard.connect_decoder")}</button>
|
|
<button id="disconnectNow" class="btn">${t("timing.disconnect")}</button>
|
|
<button id="dashboardTestAudio" class="btn">${t("settings.test_audio")}</button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header">
|
|
<h3>${t("dashboard.recent_sessions")}</h3>
|
|
</div>
|
|
<div class="panel-body">
|
|
${renderSessionsTable(state.sessions.slice(-8).reverse())}
|
|
</div>
|
|
</section>
|
|
`;
|
|
|
|
const navigateTo = (view) => {
|
|
setCurrentView(view);
|
|
updateViewUrl(view);
|
|
renderNav();
|
|
renderView();
|
|
};
|
|
|
|
document.getElementById("workflowSettings")?.addEventListener("click", () => navigateTo("settings"));
|
|
document.getElementById("workflowDrivers")?.addEventListener("click", () => navigateTo("drivers"));
|
|
document.getElementById("workflowRaceSetup")?.addEventListener("click", () => navigateTo("race_setup"));
|
|
document.getElementById("workflowRaceDay")?.addEventListener("click", () => navigateTo("race_day"));
|
|
document.getElementById("workflowTiming")?.addEventListener("click", () => navigateTo("timing"));
|
|
document.getElementById("workflowOverlay")?.addEventListener("click", () => navigateTo("overlay"));
|
|
|
|
document.getElementById("goEvents")?.addEventListener("click", () => navigateTo("events"));
|
|
|
|
document.getElementById("goRaceDay")?.addEventListener("click", () => navigateTo("race_day"));
|
|
document.getElementById("goTiming")?.addEventListener("click", () => navigateTo("timing"));
|
|
|
|
document.getElementById("connectNow")?.addEventListener("click", connectDecoder);
|
|
document.getElementById("disconnectNow")?.addEventListener("click", disconnectDecoder);
|
|
document.getElementById("openDashboardOverlay")?.addEventListener("click", openOverlayWindow);
|
|
document.getElementById("dashboardTestAudio")?.addEventListener("click", () => {
|
|
ensureAudioContext();
|
|
playPassingBeep();
|
|
if (state.settings.finishVoiceEnabled) {
|
|
setTimeout(playFinishSiren, 220);
|
|
}
|
|
});
|
|
}
|
|
|
|
export function renderClassesView(deps) {
|
|
const { state, dom, t, selectedClassEditId, setSelectedClassEditId, uid, saveState, renderView, renderTable, escapeHtml, setFormError, bindModalShell } = deps;
|
|
const editingClass = state.classes.find((item) => item.id === selectedClassEditId()) || null;
|
|
dom.view.innerHTML = `
|
|
<div class="panel-row">
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("classes.create")}</h3></div>
|
|
<form id="classForm" class="form-row panel-body">
|
|
<input required name="name" placeholder="${t("classes.placeholder")}" />
|
|
<button class="btn btn-primary" type="submit">${t("classes.add")}</button>
|
|
</form>
|
|
</section>
|
|
</div>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("classes.title")}</h3></div>
|
|
<div class="panel-body">
|
|
${renderTable(
|
|
[t("table.name"), t("events.actions")],
|
|
state.classes.map(
|
|
(c) => `
|
|
<tr>
|
|
<td>${escapeHtml(c.name)}</td>
|
|
<td class="actions-inline">
|
|
<button id="class-edit-${c.id}" class="btn">${t("common.edit")}</button>
|
|
<button id="class-delete-${c.id}" class="btn btn-danger">${t("common.delete")}</button>
|
|
</td>
|
|
</tr>
|
|
`
|
|
)
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
${
|
|
editingClass
|
|
? `
|
|
<div class="modal-overlay" id="classEditModalOverlay">
|
|
<div class="modal-card">
|
|
<div class="panel-header">
|
|
<h3>${t("common.edit")}</h3>
|
|
<button class="btn" id="classEditCancel" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
<form id="classEditForm" class="panel-body form-grid cols-2">
|
|
<input name="name" required value="${escapeHtml(editingClass.name)}" placeholder="${t("classes.placeholder")}" />
|
|
<p class="form-error" id="classEditError" hidden></p>
|
|
<div class="actions-inline">
|
|
<button class="btn btn-primary" id="classEditSave" type="button">${t("common.save")}</button>
|
|
<button class="btn" id="classEditCancelFooter" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
`;
|
|
|
|
document.getElementById("classForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
state.classes.push({ id: uid("class"), name: String(form.get("name")).trim() });
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
state.classes.forEach((item) => {
|
|
document.getElementById(`class-edit-${item.id}`)?.addEventListener("click", () => {
|
|
setSelectedClassEditId(item.id);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById(`class-delete-${item.id}`)?.addEventListener("click", () => {
|
|
state.classes = state.classes.filter((x) => x.id !== item.id);
|
|
saveState();
|
|
renderView();
|
|
});
|
|
});
|
|
|
|
document.getElementById("classEditCancel")?.addEventListener("click", () => {
|
|
setSelectedClassEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("classEditCancelFooter")?.addEventListener("click", () => {
|
|
setSelectedClassEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("classEditModalOverlay")?.addEventListener("click", (event) => {
|
|
if (event.target?.id === "classEditModalOverlay") {
|
|
setSelectedClassEditId(null);
|
|
renderView();
|
|
}
|
|
});
|
|
|
|
bindModalShell("classEditModalOverlay", () => {
|
|
setSelectedClassEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
const commitClassEdit = () => {
|
|
if (!editingClass) {
|
|
return;
|
|
}
|
|
const formNode = document.getElementById("classEditForm");
|
|
if (!(formNode instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const form = new FormData(formNode);
|
|
const cleaned = String(form.get("name") || "").trim();
|
|
if (!cleaned) {
|
|
setFormError("classEditError", t("validation.required_name"));
|
|
return;
|
|
}
|
|
setFormError("classEditError", "");
|
|
state.classes = state.classes.map((item) => (item.id === editingClass.id ? { ...item, name: cleaned } : item));
|
|
setSelectedClassEditId(null);
|
|
saveState();
|
|
renderView();
|
|
};
|
|
|
|
document.getElementById("classEditForm")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
commitClassEdit();
|
|
});
|
|
|
|
document.getElementById("classEditSave")?.addEventListener("click", commitClassEdit);
|
|
}
|
|
|
|
export function renderDriversView(deps) {
|
|
const {
|
|
state,
|
|
dom,
|
|
t,
|
|
driverBrandFilter,
|
|
setDriverBrandFilter,
|
|
selectedDriverEditId,
|
|
setSelectedDriverEditId,
|
|
uid,
|
|
saveState,
|
|
renderView,
|
|
renderTable,
|
|
escapeHtml,
|
|
setFormError,
|
|
bindModalShell,
|
|
normalizeDriver,
|
|
getClassName,
|
|
} = deps;
|
|
const classOptions = state.classes.map((c) => `<option value="${c.id}">${escapeHtml(c.name)}</option>`).join("");
|
|
const driverSearch = driverBrandFilter().trim().toLowerCase();
|
|
const filteredDrivers = state.drivers.filter((driver) =>
|
|
!driverSearch || [driver.name, driver.speechName, driver.transponder, driver.brand, driver.club, driver.country].map((value) => String(value || "").toLowerCase()).some((value) => value.includes(driverSearch))
|
|
);
|
|
const editingDriver = state.drivers.find((driver) => driver.id === selectedDriverEditId()) || null;
|
|
const profileGroups = getDriverProfileGroups(state);
|
|
const profileOptions = profileGroups
|
|
.map((profile) => {
|
|
const profileName = profile.representative?.name || t("common.unknown_driver");
|
|
const classNames = profile.classIds.map((classId) => getClassName(classId)).filter(Boolean).join(" / ");
|
|
const label = classNames ? `${profileName} - ${classNames}` : profileName;
|
|
return `<option value="${escapeHtml(profile.id)}">${escapeHtml(label)}</option>`;
|
|
})
|
|
.join("");
|
|
const editingProfileId = editingDriver ? getDriverProfileId(editingDriver) : "";
|
|
const passingSoundOptions = (selected = "") => [`<option value="">${t("audio.pass_default")}</option>`, ...Array.from({ length: 8 }, (_item, index) => { const value = `pass_${String(index + 1).padStart(2, "0")}.wav`; return `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`; })].join("");
|
|
const existingClubs = [...new Set(state.drivers.map((driver) => String(driver.club || "").trim()).filter(Boolean))].sort();
|
|
const existingCountries = [...new Set([
|
|
"Sverige", "Norge", "Finland", "Danmark", "Island", "Tyskland", "Nederländerna", "Belgien", "Frankrike", "Storbritannien", "Polen", "Tjeckien", "Österrike", "Schweiz",
|
|
...state.drivers.map((driver) => String(driver.country || "").trim()).filter(Boolean),
|
|
])].sort();
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("drivers.create")}</h3></div>
|
|
<form id="driverForm" class="panel-body form-grid cols-5">
|
|
<input required name="name" placeholder="${t("drivers.name_placeholder")}" />
|
|
<select name="classId">${classOptions}</select>
|
|
<input name="brand" placeholder="${t("drivers.brand_placeholder")}" />
|
|
<input name="transponder" placeholder="${t("drivers.transponder_placeholder")}" />
|
|
<input name="speechName" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
|
<label>${t("audio.passing_sound")}<select name="passSound">${passingSoundOptions()}</select></label>
|
|
<input name="club" list="driverClubOptions" placeholder="${t("drivers.club_placeholder")}" />
|
|
<input name="country" list="driverCountryOptions" placeholder="${t("drivers.country_placeholder")}" />
|
|
<label>${t("drivers.training_profile")}<select name="profileId" title="${t("drivers.training_profile_hint")}">
|
|
<option value="">${t("drivers.training_profile_auto")}</option>
|
|
${profileOptions}
|
|
<option value="__new__">${t("drivers.training_profile_new")}</option>
|
|
</select></label>
|
|
<datalist id="driverClubOptions">${existingClubs.map((club) => `<option value="${escapeHtml(club)}"></option>`).join("")}</datalist>
|
|
<datalist id="driverCountryOptions">${existingCountries.map((country) => `<option value="${escapeHtml(country)}"></option>`).join("")}</datalist>
|
|
<button class="btn btn-primary" type="submit">${t("drivers.add")}</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("drivers.title")}</h3></div>
|
|
<div class="panel-body">
|
|
<input id="driverBrandFilter" value="${escapeHtml(driverBrandFilter())}" placeholder="${t("drivers.brand_filter_placeholder")}" />
|
|
</div>
|
|
<div class="panel-body">
|
|
${renderTable(
|
|
[t("table.name"), t("table.class"), t("drivers.club"), t("drivers.country"), t("table.brand"), t("table.transponder"), t("drivers.training_profile"), t("drivers.speech_name"), t("events.actions")],
|
|
filteredDrivers.map(
|
|
(d) => {
|
|
const profile = profileGroups.find((item) => item.id === getDriverProfileId(d));
|
|
return `
|
|
<tr>
|
|
<td>${escapeHtml(d.name)}</td>
|
|
<td>${escapeHtml(getClassName(d.classId))}</td>
|
|
<td>${escapeHtml(profile?.club || d.club || "-")}</td>
|
|
<td>${escapeHtml(profile?.country || d.country || "-")}</td>
|
|
<td>${escapeHtml(d.brand || "-")}</td>
|
|
<td>${escapeHtml(d.transponder || "-")}</td>
|
|
<td>${escapeHtml(profile?.representative?.name || d.name)}${profile?.drivers.length > 1 ? ` · ${profile.drivers.length}` : ""}</td>
|
|
<td>${escapeHtml(d.speechName || d.name)}</td>
|
|
<td class="actions-inline">
|
|
<button id="driver-edit-${d.id}" class="btn">${t("common.edit")}</button>
|
|
<button id="driver-delete-${d.id}" class="btn btn-danger">${t("common.delete")}</button>
|
|
</td>
|
|
</tr>
|
|
`;
|
|
}
|
|
)
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
${
|
|
editingDriver
|
|
? `
|
|
<div class="modal-overlay" id="driverEditModalOverlay">
|
|
<div class="modal-card">
|
|
<div class="panel-header">
|
|
<h3>${t("common.edit")}</h3>
|
|
<button class="btn" id="driverEditCancel" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
<form id="driverEditForm" class="panel-body form-grid cols-5">
|
|
<input name="name" required value="${escapeHtml(editingDriver.name)}" placeholder="${t("drivers.name_placeholder")}" />
|
|
<select name="classId">
|
|
${state.classes
|
|
.map((item) => `<option value="${item.id}" ${item.id === editingDriver.classId ? "selected" : ""}>${escapeHtml(item.name)}</option>`)
|
|
.join("")}
|
|
</select>
|
|
<input name="brand" value="${escapeHtml(editingDriver.brand || "")}" placeholder="${t("drivers.brand_placeholder")}" />
|
|
<input name="transponder" value="${escapeHtml(editingDriver.transponder || "")}" placeholder="${t("drivers.transponder_placeholder")}" />
|
|
<input name="speechName" value="${escapeHtml(editingDriver.speechName || "")}" placeholder="${t("drivers.speech_name_placeholder")}" title="${t("drivers.speech_name_hint")}" />
|
|
<label>${t("audio.passing_sound")}<select name="passSound">${passingSoundOptions(editingDriver.passSound)}</select></label>
|
|
<input name="club" list="driverClubOptions" value="${escapeHtml(profileGroups.find((profile) => profile.id === editingProfileId)?.club || editingDriver.club || "")}" placeholder="${t("drivers.club_placeholder")}" />
|
|
<input name="country" list="driverCountryOptions" value="${escapeHtml(profileGroups.find((profile) => profile.id === editingProfileId)?.country || editingDriver.country || "")}" placeholder="${t("drivers.country_placeholder")}" />
|
|
<label>${t("drivers.training_profile")}<select name="profileId" title="${t("drivers.training_profile_hint")}">
|
|
${profileGroups.map((profile) => {
|
|
const profileName = profile.representative?.name || t("common.unknown_driver");
|
|
const classNames = profile.classIds.map((classId) => getClassName(classId)).filter(Boolean).join(" / ");
|
|
return `<option value="${escapeHtml(profile.id)}" ${profile.id === editingProfileId ? "selected" : ""}>${escapeHtml(classNames ? `${profileName} - ${classNames}` : profileName)}</option>`;
|
|
}).join("")}
|
|
<option value="__new__">${t("drivers.training_profile_separate")}</option>
|
|
</select></label>
|
|
<p class="form-error" id="driverEditError" hidden></p>
|
|
<div class="actions-inline">
|
|
<button class="btn btn-primary" id="driverEditSave" type="button">${t("common.save")}</button>
|
|
<button class="btn" id="driverEditCancelFooter" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
`;
|
|
|
|
document.getElementById("driverForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
const driverId = uid("driver");
|
|
const driverName = String(form.get("name") || "").trim();
|
|
const requestedProfileId = String(form.get("profileId") || "").trim();
|
|
const sameNameProfile = profileGroups.find((profile) => (
|
|
String(profile.representative?.name || "").trim().toLocaleLowerCase().replace(/\s+/g, " ") === driverName.toLocaleLowerCase().replace(/\s+/g, " ")
|
|
));
|
|
const profileId = requestedProfileId && requestedProfileId !== "__new__"
|
|
? requestedProfileId
|
|
: requestedProfileId === "__new__"
|
|
? uid("profile")
|
|
: sameNameProfile?.id || uid("profile");
|
|
const linkedProfile = profileGroups.find((profile) => profile.id === profileId);
|
|
state.drivers.push(
|
|
normalizeDriver({
|
|
id: driverId,
|
|
name: driverName,
|
|
classId: String(form.get("classId")),
|
|
brand: String(form.get("brand") || "").trim(),
|
|
transponder: String(form.get("transponder") || "").trim(),
|
|
speechName: String(form.get("speechName") || "").trim(),
|
|
passSound: String(form.get("passSound") || "").trim(),
|
|
profileId,
|
|
club: String(form.get("club") || linkedProfile?.club || "").trim(),
|
|
country: String(form.get("country") || linkedProfile?.country || "").trim(),
|
|
})
|
|
);
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("driverBrandFilter")?.addEventListener("input", (event) => {
|
|
const input = event.currentTarget;
|
|
if (!(input instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
setDriverBrandFilter(input.value);
|
|
renderDriversView(deps);
|
|
});
|
|
|
|
const bindProfileMetadataFill = (formId) => {
|
|
const formNode = document.getElementById(formId);
|
|
const profileSelect = formNode?.querySelector('select[name="profileId"]');
|
|
profileSelect?.addEventListener("change", () => {
|
|
const profile = profileGroups.find((item) => item.id === profileSelect.value);
|
|
if (!profile) return;
|
|
const clubInput = formNode.querySelector('input[name="club"]');
|
|
const countryInput = formNode.querySelector('input[name="country"]');
|
|
if (clubInput instanceof HTMLInputElement) clubInput.value = profile.club || "";
|
|
if (countryInput instanceof HTMLInputElement) countryInput.value = profile.country || "";
|
|
});
|
|
};
|
|
bindProfileMetadataFill("driverForm");
|
|
bindProfileMetadataFill("driverEditForm");
|
|
|
|
state.drivers.forEach((d) => {
|
|
document.getElementById(`driver-edit-${d.id}`)?.addEventListener("click", () => {
|
|
setSelectedDriverEditId(d.id);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById(`driver-delete-${d.id}`)?.addEventListener("click", () => {
|
|
state.drivers = state.drivers.filter((x) => x.id !== d.id);
|
|
state.sessions.forEach((s) => {
|
|
s.assignments = (s.assignments || []).filter((a) => a.driverId !== d.id);
|
|
});
|
|
saveState();
|
|
renderView();
|
|
});
|
|
});
|
|
|
|
document.getElementById("driverEditCancel")?.addEventListener("click", () => {
|
|
setSelectedDriverEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("driverEditCancelFooter")?.addEventListener("click", () => {
|
|
setSelectedDriverEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("driverEditModalOverlay")?.addEventListener("click", (event) => {
|
|
if (event.target?.id === "driverEditModalOverlay") {
|
|
setSelectedDriverEditId(null);
|
|
renderView();
|
|
}
|
|
});
|
|
|
|
bindModalShell("driverEditModalOverlay", () => {
|
|
setSelectedDriverEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
const commitDriverEdit = () => {
|
|
if (!editingDriver) {
|
|
return;
|
|
}
|
|
const formNode = document.getElementById("driverEditForm");
|
|
if (!(formNode instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const form = new FormData(formNode);
|
|
const cleanedName = String(form.get("name") || "").trim();
|
|
const cleanedClassId = String(form.get("classId") || "").trim();
|
|
const cleanedBrand = String(form.get("brand") || "").trim();
|
|
const cleanedTp = String(form.get("transponder") || "").trim();
|
|
const cleanedSpeechName = String(form.get("speechName") || "").trim();
|
|
const cleanedPassSound = String(form.get("passSound") || "").trim();
|
|
const requestedProfileId = String(form.get("profileId") || "").trim();
|
|
const cleanedProfileId = requestedProfileId === "__new__" ? uid("profile") : requestedProfileId || editingProfileId;
|
|
const cleanedClub = String(form.get("club") || "").trim();
|
|
const cleanedCountry = String(form.get("country") || "").trim();
|
|
if (!cleanedName) {
|
|
setFormError("driverEditError", t("validation.required_name"));
|
|
return;
|
|
}
|
|
if (cleanedClassId && !state.classes.some((item) => item.id === cleanedClassId)) {
|
|
setFormError("driverEditError", t("validation.invalid_selection"));
|
|
return;
|
|
}
|
|
setFormError("driverEditError", "");
|
|
state.drivers = state.drivers.map((item) => {
|
|
if (item.id === editingDriver.id) {
|
|
return normalizeDriver({
|
|
...item,
|
|
name: cleanedName,
|
|
classId: cleanedClassId || item.classId,
|
|
brand: cleanedBrand,
|
|
transponder: cleanedTp,
|
|
speechName: cleanedSpeechName,
|
|
passSound: cleanedPassSound,
|
|
profileId: cleanedProfileId,
|
|
club: cleanedClub,
|
|
country: cleanedCountry,
|
|
});
|
|
}
|
|
if (getDriverProfileId(item) === cleanedProfileId) {
|
|
return normalizeDriver({ ...item, club: cleanedClub, country: cleanedCountry });
|
|
}
|
|
return item;
|
|
});
|
|
setSelectedDriverEditId(null);
|
|
saveState();
|
|
renderView();
|
|
};
|
|
|
|
document.getElementById("driverEditForm")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
commitDriverEdit();
|
|
});
|
|
|
|
document.getElementById("driverEditSave")?.addEventListener("click", commitDriverEdit);
|
|
}
|
|
|
|
export function renderCarsView(deps) {
|
|
const {
|
|
state,
|
|
dom,
|
|
t,
|
|
carBrandFilter,
|
|
setCarBrandFilter,
|
|
selectedCarEditId,
|
|
setSelectedCarEditId,
|
|
uid,
|
|
saveState,
|
|
renderView,
|
|
renderTable,
|
|
escapeHtml,
|
|
setFormError,
|
|
bindModalShell,
|
|
normalizeCar,
|
|
} = deps;
|
|
const carSearch = carBrandFilter().trim().toLowerCase();
|
|
const filteredCars = state.cars.filter((car) =>
|
|
!carSearch || [car.name, car.transponder, car.brand].map((value) => String(value || "").toLowerCase()).some((value) => value.includes(carSearch))
|
|
);
|
|
const editingCar = state.cars.find((car) => car.id === selectedCarEditId()) || null;
|
|
const passingSoundOptions = (selected = "") => [`<option value="">${t("audio.pass_default")}</option>`, ...Array.from({ length: 8 }, (_item, index) => { const value = `pass_${String(index + 1).padStart(2, "0")}.wav`; return `<option value="${value}" ${selected === value ? "selected" : ""}>${value}</option>`; })].join("");
|
|
|
|
dom.view.innerHTML = `
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("cars.create")}</h3></div>
|
|
<form id="carForm" class="panel-body form-grid cols-4">
|
|
<input required name="name" placeholder="${t("cars.name_placeholder")}" />
|
|
<input name="brand" placeholder="${t("cars.brand_placeholder")}" />
|
|
<input required name="transponder" placeholder="${t("cars.transponder_placeholder")}" />
|
|
<label>${t("audio.passing_sound")}<select name="passSound">${passingSoundOptions()}</select></label>
|
|
<button class="btn btn-primary" type="submit">${t("cars.add")}</button>
|
|
</form>
|
|
</section>
|
|
|
|
<section class="panel">
|
|
<div class="panel-header"><h3>${t("cars.title")}</h3></div>
|
|
<div class="panel-body">
|
|
<input id="carBrandFilter" value="${escapeHtml(carBrandFilter())}" placeholder="${t("cars.brand_filter_placeholder")}" />
|
|
</div>
|
|
<div class="panel-body">
|
|
${renderTable(
|
|
[t("table.car"), t("table.brand"), t("table.transponder"), t("events.actions")],
|
|
filteredCars.map(
|
|
(c) => `
|
|
<tr>
|
|
<td>${escapeHtml(c.name)}</td>
|
|
<td>${escapeHtml(c.brand || "-")}</td>
|
|
<td>${escapeHtml(c.transponder)}</td>
|
|
<td class="actions-inline">
|
|
<button id="car-edit-${c.id}" class="btn">${t("common.edit")}</button>
|
|
<button id="car-delete-${c.id}" class="btn btn-danger">${t("common.delete")}</button>
|
|
</td>
|
|
</tr>
|
|
`
|
|
)
|
|
)}
|
|
</div>
|
|
</section>
|
|
|
|
${
|
|
editingCar
|
|
? `
|
|
<div class="modal-overlay" id="carEditModalOverlay">
|
|
<div class="modal-card">
|
|
<div class="panel-header">
|
|
<h3>${t("common.edit")}</h3>
|
|
<button class="btn" id="carEditCancel" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
<form id="carEditForm" class="panel-body form-grid cols-4">
|
|
<input name="name" required value="${escapeHtml(editingCar.name)}" placeholder="${t("cars.name_placeholder")}" />
|
|
<input name="brand" value="${escapeHtml(editingCar.brand || "")}" placeholder="${t("cars.brand_placeholder")}" />
|
|
<input name="transponder" required value="${escapeHtml(editingCar.transponder || "")}" placeholder="${t("cars.transponder_placeholder")}" />
|
|
<label>${t("audio.passing_sound")}<select name="passSound">${passingSoundOptions(editingCar.passSound)}</select></label>
|
|
<p class="form-error" id="carEditError" hidden></p>
|
|
<div class="actions-inline">
|
|
<button class="btn btn-primary" id="carEditSave" type="button">${t("common.save")}</button>
|
|
<button class="btn" id="carEditCancelFooter" type="button">${t("common.cancel")}</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
`
|
|
: ""
|
|
}
|
|
`;
|
|
|
|
document.getElementById("carForm")?.addEventListener("submit", (e) => {
|
|
e.preventDefault();
|
|
const form = new FormData(e.currentTarget);
|
|
state.cars.push(
|
|
normalizeCar({
|
|
id: uid("car"),
|
|
name: String(form.get("name")).trim(),
|
|
brand: String(form.get("brand") || "").trim(),
|
|
transponder: String(form.get("transponder")).trim(),
|
|
passSound: String(form.get("passSound") || "").trim(),
|
|
})
|
|
);
|
|
saveState();
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("carBrandFilter")?.addEventListener("input", (event) => {
|
|
const input = event.currentTarget;
|
|
if (!(input instanceof HTMLInputElement)) {
|
|
return;
|
|
}
|
|
setCarBrandFilter(input.value);
|
|
renderCarsView(deps);
|
|
});
|
|
|
|
state.cars.forEach((c) => {
|
|
document.getElementById(`car-edit-${c.id}`)?.addEventListener("click", () => {
|
|
setSelectedCarEditId(c.id);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById(`car-delete-${c.id}`)?.addEventListener("click", () => {
|
|
state.cars = state.cars.filter((x) => x.id !== c.id);
|
|
state.sessions.forEach((s) => {
|
|
s.assignments = (s.assignments || []).filter((a) => a.carId !== c.id);
|
|
});
|
|
saveState();
|
|
renderView();
|
|
});
|
|
});
|
|
|
|
document.getElementById("carEditCancel")?.addEventListener("click", () => {
|
|
setSelectedCarEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("carEditCancelFooter")?.addEventListener("click", () => {
|
|
setSelectedCarEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
document.getElementById("carEditModalOverlay")?.addEventListener("click", (event) => {
|
|
if (event.target?.id === "carEditModalOverlay") {
|
|
setSelectedCarEditId(null);
|
|
renderView();
|
|
}
|
|
});
|
|
|
|
bindModalShell("carEditModalOverlay", () => {
|
|
setSelectedCarEditId(null);
|
|
renderView();
|
|
});
|
|
|
|
const commitCarEdit = () => {
|
|
if (!editingCar) {
|
|
return;
|
|
}
|
|
const formNode = document.getElementById("carEditForm");
|
|
if (!(formNode instanceof HTMLFormElement)) {
|
|
return;
|
|
}
|
|
const form = new FormData(formNode);
|
|
const cleanedName = String(form.get("name") || "").trim();
|
|
const cleanedBrand = String(form.get("brand") || "").trim();
|
|
const cleanedTp = String(form.get("transponder") || "").trim();
|
|
const cleanedPassSound = String(form.get("passSound") || "").trim();
|
|
if (!cleanedName) {
|
|
setFormError("carEditError", t("validation.required_name"));
|
|
return;
|
|
}
|
|
if (!cleanedTp) {
|
|
setFormError("carEditError", t("validation.required_transponder"));
|
|
return;
|
|
}
|
|
setFormError("carEditError", "");
|
|
state.cars = state.cars.map((item) =>
|
|
item.id === editingCar.id
|
|
? normalizeCar({
|
|
...item,
|
|
name: cleanedName,
|
|
brand: cleanedBrand,
|
|
transponder: cleanedTp,
|
|
passSound: cleanedPassSound,
|
|
})
|
|
: item
|
|
);
|
|
setSelectedCarEditId(null);
|
|
saveState();
|
|
renderView();
|
|
};
|
|
|
|
document.getElementById("carEditForm")?.addEventListener("submit", (event) => {
|
|
event.preventDefault();
|
|
commitCarEdit();
|
|
});
|
|
|
|
document.getElementById("carEditSave")?.addEventListener("click", commitCarEdit);
|
|
}
|