Files
paliad/frontend/src/client/deadlines.ts
m cf94f0ca25 fix(projects-detail): /projects/{id} notfound + rename German DOM/URL leftovers (t-paliad-038)
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).
2026-04-26 01:04:07 +02:00

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) : "&mdash;";
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()]);
});