Files
paliad/frontend/src/client/appointments.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

270 lines
8.3 KiB
TypeScript

import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Appointment {
id: string;
project_id?: string;
title: string;
description?: string;
start_at: string;
end_at?: string;
location?: string;
appointment_type?: string;
project_reference?: string;
project_title?: string;
projekt_office?: string;
}
interface Project {
id: string;
reference?: string | null;
title: string;
}
interface Summary {
today: number;
this_week: number;
later: number;
total: number;
}
const PERSONAL = "__personal__";
let allAppointments: Appointment[] = [];
let allProjects: Project[] = [];
let typeFilter = "";
let akteFilter = "";
let fromFilter = "";
let toFilter = "";
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 resp = await fetch("/api/appointments/summary");
if (!resp.ok) return;
const sum: Summary = await resp.json();
setCount("sum-today", sum.today);
setCount("sum-week", sum.this_week);
setCount("sum-later", sum.later);
} catch {
/* non-fatal */
}
}
function setCount(id: string, n: number) {
const el = document.getElementById(id);
if (el) el.textContent = String(n);
}
async function loadTermine() {
const unavailable = document.getElementById("termine-unavailable")!;
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
try {
const params = new URLSearchParams();
if (typeFilter) params.set("type", typeFilter);
if (akteFilter && akteFilter !== PERSONAL) params.set("project_id", akteFilter);
if (fromFilter) params.set("from", fromFilter);
if (toFilter) params.set("to", toFilter);
const resp = await fetch(`/api/appointments?${params.toString()}`);
if (resp.status === 503) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
document.getElementById("termine-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
return;
}
const data: Appointment[] = await resp.json();
allAppointments = akteFilter === PERSONAL ? data.filter((x) => !x.project_id) : data;
loadedOK = true;
render();
} catch {
unavailable.style.display = "block";
tableWrap.style.display = "none";
}
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "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("termine-body")!;
const empty = document.getElementById("termine-empty")!;
const emptyFiltered = document.getElementById("termine-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
if (allAppointments.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
if (!typeFilter && !akteFilter && !fromFilter && !toFilter) {
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 = allAppointments
.map((tt) => {
const typeLabel = tt.appointment_type ? t(`termine.type.${tt.appointment_type}`) || tt.appointment_type : "";
const typeClass = tt.appointment_type ? `termin-type-${tt.appointment_type}` : "";
const akteCell = tt.project_id
? `<a class="akten-ref-link" href="/projects/${esc(tt.project_id)}">${esc(tt.project_reference ?? "")}</a>`
+ `<span class="frist-project-title">${esc(tt.project_title ?? "")}</span>`
: `<span class="termin-personal-tag" data-i18n="termine.personal">${esc(t("termine.personal"))}</span>`;
return `<tr class="frist-row" data-id="${esc(tt.id)}">
<td class="frist-col-check"><span class="termin-dot ${typeClass}" /></td>
<td class="frist-col-due">${esc(fmtDateTime(tt.start_at))}</td>
<td class="frist-col-title">${esc(tt.title)}</td>
<td class="frist-col-project">${akteCell}</td>
<td>${esc(tt.location ?? "")}</td>
<td><span class="termin-type-chip ${typeClass}">${esc(typeLabel)}</span></td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest("a")) return;
window.location.href = `/appointments/${id}`;
});
});
}
function initFilters() {
const type = document.getElementById("termin-filter-type") as HTMLSelectElement;
const project = document.getElementById("termin-filter-project") as HTMLSelectElement;
const from = document.getElementById("termin-filter-from") as HTMLInputElement;
const to = document.getElementById("termin-filter-to") as HTMLInputElement;
const params = urlParams();
if (params.has("type")) typeFilter = params.get("type")!;
if (params.has("project_id")) akteFilter = params.get("project_id")!;
if (params.has("from")) fromFilter = params.get("from")!;
if (params.has("to")) toFilter = params.get("to")!;
type.value = typeFilter;
from.value = fromFilter;
to.value = toFilter;
type.addEventListener("change", async () => {
typeFilter = type.value;
await Promise.all([loadTermine(), loadSummary()]);
});
project.addEventListener("change", async () => {
akteFilter = project.value;
await Promise.all([loadTermine(), loadSummary()]);
});
from.addEventListener("change", async () => {
fromFilter = from.value;
await loadTermine();
});
to.addEventListener("change", async () => {
toFilter = to.value;
await loadTermine();
});
}
function populateAkteFilter() {
const sel = document.getElementById("termin-filter-project") as HTMLSelectElement;
const options: string[] = [
`<option value="">${esc(t("termine.filter.akte.all"))}</option>`,
`<option value="${PERSONAL}">${esc(t("termine.filter.akte.personal"))}</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 range = card.dataset.range!;
const today = new Date();
today.setHours(0, 0, 0, 0);
const isoDay = (d: Date) =>
d.toISOString().slice(0, 10);
let from = "", to = "";
if (range === "today") {
from = isoDay(today);
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
to = isoDay(tomorrow);
} else if (range === "this_week") {
const tomorrow = new Date(today);
tomorrow.setDate(tomorrow.getDate() + 1);
const week = new Date(today);
week.setDate(week.getDate() + 7);
from = isoDay(tomorrow);
to = isoDay(week);
} else if (range === "later") {
const week = new Date(today);
week.setDate(week.getDate() + 7);
from = isoDay(week);
}
fromFilter = from;
toFilter = to;
(document.getElementById("termin-filter-from") as HTMLInputElement).value = from;
(document.getElementById("termin-filter-to") as HTMLInputElement).value = to;
await loadTermine();
});
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
initFilters();
initSummaryCards();
onLangChange(render);
await loadAkten();
populateAkteFilter();
await Promise.all([loadTermine(), loadSummary()]);
});