Root cause of the URGENT bug: parseAkteID() in
frontend/src/client/projects-detail.ts only accepted /projekte/{id} and
/akten/{id} URL prefixes. After t-paliad-025 renamed pages to /projects/{id},
parts[0] === "projects" failed both checks → null id → notfound branch
fired before any /api/projects/{id} fetch. The 200 from curl was real;
the page just never asked.
Fix: parseProjectID() now reads /projects/{id}. Old bookmark tab slugs
(verlauf, parteien, fristen, …) are mapped to their English successors so
deep links don't silently fall back to the default tab.
Bundled cleanup — every per-project subpath the client TS still hit was a
404 because the rename only touched top-level routes. Lockstep rename of
URLs, function names, DOM IDs, and the TabId union in projects-detail.ts
+ projects-detail.tsx:
- /api/projects/{id}/parteien|fristen|termine|notizen|checklisten →
/parties|deadlines|appointments|notes|checklists
- loadParteien/loadFristen/loadTermine/loadAkte/parseAkteID →
loadParties/loadDeadlines/loadAppointments/loadProject/parseProjectID
(the old loadParteien/loadFristen/loadTermine bodies even assigned to
undeclared `parteien`/`fristen`/`termine` — would have thrown
ReferenceError as soon as the catch branch ran)
- DOM IDs: akten-detail-* → project-detail-*, parteien-* → parties-*,
partei-* → party-*, project-fristen-* → project-deadlines-*,
project-termin(e)-* → project-appointment(s)-*,
project-checklisten-* → project-checklists-*, akten-events-* →
project-events-*, kinder-* → children-*, projekt-breadcrumb →
project-breadcrumb, frist-add-link → deadline-add-link,
termin-add-btn → appointment-add-btn
- Tab slugs in URL + data-tab + tab-* IDs: verlauf/kinder/parteien/
fristen/termine/notizen/checklisten →
history/children/parties/deadlines/appointments/notes/checklists
- frist-add-link href: /projects/{id}/fristen/neu →
/projects/{id}/deadlines/new
Sweep across the rest of frontend/src/client/:
- notes.ts: NotizParentType → NotesParentType, "frist"/"termin" →
"deadline"/"appointment", baseURL paths /…/notizen → /…/notes; updated
callers in deadlines-detail.ts and appointments-detail.ts.
- deadlines-new.ts: undeclared `akten` reference (loadAkten was assigning
to a never-declared name) replaced with `projects`; URL /…/fristen →
/…/deadlines; path-parsing of /akten/{id}/fristen/neu rewritten as
/projects/{id}/deadlines/new; preselectedAkteID → preselectedProjectID;
Project.aktenzeichen field (no longer emitted by API) → reference.
- fristenrechner.ts: bulk endpoint /…/fristen/bulk → /…/deadlines/bulk;
request body { fristen } → { deadlines } (server expects "deadlines"
key); ProjectOption interface now uses reference instead of
aktenzeichen.
- deadlines.ts, appointments.ts, deadlines-detail.ts, appointments-detail.ts,
checklists-detail.ts, appointments-new.ts: Project interface field
aktenzeichen → reference (the API returns "reference"; the old field
rendered as undefined in select options and detail headers).
i18n key strings (akten.detail.*, projekte.*, fristen.*, termine.*,
checklisten.*, notizen.*) intentionally kept in German per the
t-paliad-025 convention. CSS class names (frist-row, akten-table-wrap,
termin-dot, etc.) untouched — separate stylistic cleanup.
Verified: go build/vet/test clean, bun run build clean, dist HTML +
bundled JS contain only the new English IDs (remaining German strings
are i18n keys).
266 lines
8.2 KiB
TypeScript
266 lines
8.2 KiB
TypeScript
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
|
import { initSidebar } from "./sidebar";
|
|
|
|
interface Deadline {
|
|
id: string;
|
|
project_id: string;
|
|
title: string;
|
|
due_date: string;
|
|
status: string;
|
|
source: string;
|
|
rule_id?: string;
|
|
project_reference: string;
|
|
project_title: string;
|
|
projekt_office: string;
|
|
rule_code?: string;
|
|
}
|
|
|
|
interface Project {
|
|
id: string;
|
|
reference?: string | null;
|
|
title: string;
|
|
}
|
|
|
|
interface Summary {
|
|
overdue: number;
|
|
this_week: number;
|
|
upcoming: number;
|
|
completed: number;
|
|
total: number;
|
|
}
|
|
|
|
let allDeadlines: Deadline[] = [];
|
|
let allProjects: Project[] = [];
|
|
let statusFilter = "pending";
|
|
let akteFilter = "";
|
|
let loadedOK = false;
|
|
|
|
function urlParams(): URLSearchParams {
|
|
return new URLSearchParams(window.location.search);
|
|
}
|
|
|
|
async function loadAkten() {
|
|
try {
|
|
const resp = await fetch("/api/projects");
|
|
if (resp.ok) allProjects = await resp.json();
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
async function loadSummary() {
|
|
try {
|
|
const url = akteFilter
|
|
? `/api/deadlines/summary?project_id=${encodeURIComponent(akteFilter)}`
|
|
: `/api/deadlines/summary`;
|
|
const resp = await fetch(url);
|
|
if (!resp.ok) return;
|
|
const sum: Summary = await resp.json();
|
|
setCount("sum-overdue", sum.overdue);
|
|
setCount("sum-week", sum.this_week);
|
|
setCount("sum-upcoming", sum.upcoming);
|
|
setCount("sum-completed", sum.completed);
|
|
} catch {
|
|
/* non-fatal */
|
|
}
|
|
}
|
|
|
|
function setCount(id: string, n: number) {
|
|
const el = document.getElementById(id);
|
|
if (el) el.textContent = String(n);
|
|
}
|
|
|
|
async function loadFristen() {
|
|
const unavailable = document.getElementById("fristen-unavailable")!;
|
|
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
|
|
try {
|
|
const params = new URLSearchParams();
|
|
if (statusFilter) params.set("status", statusFilter);
|
|
if (akteFilter) params.set("project_id", akteFilter);
|
|
const resp = await fetch(`/api/deadlines?${params.toString()}`);
|
|
if (resp.status === 503) {
|
|
unavailable.style.display = "block";
|
|
tableWrap.style.display = "none";
|
|
document.getElementById("fristen-empty")!.style.display = "none";
|
|
return;
|
|
}
|
|
if (!resp.ok) {
|
|
unavailable.style.display = "block";
|
|
tableWrap.style.display = "none";
|
|
return;
|
|
}
|
|
allFristen = await resp.json();
|
|
loadedOK = true;
|
|
render();
|
|
} catch {
|
|
unavailable.style.display = "block";
|
|
tableWrap.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function urgencyClass(due: string, status: string): string {
|
|
if (status === "completed") return "frist-urgency-done";
|
|
const today = new Date();
|
|
today.setHours(0, 0, 0, 0);
|
|
const d = new Date(due + "T00:00:00");
|
|
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
|
|
if (diffDays < 0) return "frist-urgency-overdue";
|
|
if (diffDays <= 7) return "frist-urgency-soon";
|
|
return "frist-urgency-later";
|
|
}
|
|
|
|
function fmtDate(iso: string): string {
|
|
try {
|
|
const d = new Date(iso + "T00:00:00");
|
|
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
|
|
year: "numeric",
|
|
month: "2-digit",
|
|
day: "2-digit",
|
|
});
|
|
} catch {
|
|
return iso;
|
|
}
|
|
}
|
|
|
|
function esc(s: string): string {
|
|
const d = document.createElement("div");
|
|
d.textContent = s;
|
|
return d.innerHTML;
|
|
}
|
|
|
|
function render() {
|
|
if (!loadedOK) return;
|
|
const tbody = document.getElementById("fristen-body")!;
|
|
const empty = document.getElementById("fristen-empty")!;
|
|
const emptyFiltered = document.getElementById("fristen-empty-filtered")!;
|
|
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
|
|
|
|
if (allFristen.length === 0) {
|
|
tbody.innerHTML = "";
|
|
tableWrap.style.display = "none";
|
|
if (statusFilter === "all" && !akteFilter) {
|
|
empty.style.display = "block";
|
|
emptyFiltered.style.display = "none";
|
|
} else {
|
|
empty.style.display = "none";
|
|
emptyFiltered.style.display = "block";
|
|
}
|
|
return;
|
|
}
|
|
|
|
tableWrap.style.display = "";
|
|
empty.style.display = "none";
|
|
emptyFiltered.style.display = "none";
|
|
|
|
tbody.innerHTML = allFristen
|
|
.map((f) => {
|
|
const urgency = urgencyClass(f.due_date, f.status);
|
|
const statusLabel = t(`fristen.status.${f.status}`) || f.status;
|
|
const ruleLabel = f.rule_code ? esc(f.rule_code) : "—";
|
|
const checked = f.status === "completed" ? "checked" : "";
|
|
const disabled = f.status === "completed" ? "disabled" : "";
|
|
const titleClass = f.status === "completed" ? "frist-title-done" : "";
|
|
return `<tr class="frist-row" data-id="${esc(f.id)}">
|
|
<td class="frist-col-check">
|
|
<input type="checkbox" class="frist-complete-cb" ${checked} ${disabled}
|
|
aria-label="${esc(t("fristen.complete.action"))}" />
|
|
</td>
|
|
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDate(f.due_date)}</td>
|
|
<td class="frist-col-title ${titleClass}">${esc(f.title)}</td>
|
|
<td class="frist-col-project">
|
|
<a class="akten-ref-link" href="/projects/${esc(f.project_id)}">${esc(f.project_reference)}</a>
|
|
<span class="frist-project-title">${esc(f.project_title)}</span>
|
|
</td>
|
|
<td class="frist-col-rule">${ruleLabel}</td>
|
|
<td><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
|
|
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
|
|
const id = row.dataset.id!;
|
|
row.addEventListener("click", (e) => {
|
|
// Don't navigate if clicking the checkbox or a link
|
|
const target = e.target as HTMLElement;
|
|
if (target.closest(".frist-complete-cb") || target.closest("a")) return;
|
|
window.location.href = `/deadlines/${id}`;
|
|
});
|
|
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
|
|
if (cb) {
|
|
cb.addEventListener("change", async () => {
|
|
if (!cb.checked) return;
|
|
cb.disabled = true;
|
|
try {
|
|
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
|
|
if (resp.ok) {
|
|
await Promise.all([loadFristen(), loadSummary()]);
|
|
} else {
|
|
cb.checked = false;
|
|
cb.disabled = false;
|
|
}
|
|
} catch {
|
|
cb.checked = false;
|
|
cb.disabled = false;
|
|
}
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
function initFilters() {
|
|
const status = document.getElementById("frist-filter-status") as HTMLSelectElement;
|
|
const project = document.getElementById("frist-filter-project") as HTMLSelectElement;
|
|
|
|
// Pre-fill from URL
|
|
const params = urlParams();
|
|
if (params.has("status")) statusFilter = params.get("status")!;
|
|
if (params.has("project_id")) akteFilter = params.get("project_id")!;
|
|
status.value = statusFilter;
|
|
|
|
status.addEventListener("change", async () => {
|
|
statusFilter = status.value;
|
|
await Promise.all([loadFristen(), loadSummary()]);
|
|
});
|
|
project.addEventListener("change", async () => {
|
|
akteFilter = project.value;
|
|
await Promise.all([loadFristen(), loadSummary()]);
|
|
});
|
|
}
|
|
|
|
function populateAkteFilter() {
|
|
const sel = document.getElementById("frist-filter-project") as HTMLSelectElement;
|
|
// Keep the first "all" option, then append sorted Akten.
|
|
const options: string[] = [
|
|
`<option value="" data-i18n="fristen.filter.akte.all">${esc(t("fristen.filter.akte.all"))}</option>`,
|
|
];
|
|
for (const a of allProjects) {
|
|
options.push(
|
|
`<option value="${esc(a.id)}">${esc(a.reference || "")} \u2014 ${esc(a.title)}</option>`,
|
|
);
|
|
}
|
|
sel.innerHTML = options.join("");
|
|
if (akteFilter) sel.value = akteFilter;
|
|
}
|
|
|
|
function initSummaryCards() {
|
|
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
|
|
card.addEventListener("click", async () => {
|
|
const newStatus = card.dataset.status!;
|
|
statusFilter = newStatus;
|
|
(document.getElementById("frist-filter-status") as HTMLSelectElement).value = newStatus;
|
|
await Promise.all([loadFristen(), loadSummary()]);
|
|
});
|
|
});
|
|
}
|
|
|
|
document.addEventListener("DOMContentLoaded", async () => {
|
|
initI18n();
|
|
initSidebar();
|
|
initFilters();
|
|
initSummaryCards();
|
|
onLangChange(render);
|
|
await loadAkten();
|
|
populateAkteFilter();
|
|
await Promise.all([loadFristen(), loadSummary()]);
|
|
});
|