Merge: t-paliad-110 PR-3 — mount EventsPage on /deadlines + /appointments

This commit is contained in:
m
2026-05-04 13:48:58 +02:00
8 changed files with 23 additions and 981 deletions

View File

@@ -15,12 +15,10 @@ import { renderCourts } from "./src/courts";
import { renderProjects } from "./src/projects";
import { renderProjectsNew } from "./src/projects-new";
import { renderProjectsDetail } from "./src/projects-detail";
import { renderDeadlines } from "./src/deadlines";
import { renderEvents } from "./src/events";
import { renderDeadlinesNew } from "./src/deadlines-new";
import { renderDeadlinesDetail } from "./src/deadlines-detail";
import { renderDeadlinesCalendar } from "./src/deadlines-calendar";
import { renderAppointments } from "./src/appointments";
import { renderAppointmentsNew } from "./src/appointments-new";
import { renderAppointmentsDetail } from "./src/appointments-detail";
import { renderAppointmentsCalendar } from "./src/appointments-calendar";
@@ -240,12 +238,10 @@ async function build() {
join(import.meta.dir, "src/client/projects.ts"),
join(import.meta.dir, "src/client/projects-new.ts"),
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/deadlines.ts"),
join(import.meta.dir, "src/client/events.ts"),
join(import.meta.dir, "src/client/deadlines-new.ts"),
join(import.meta.dir, "src/client/deadlines-detail.ts"),
join(import.meta.dir, "src/client/deadlines-calendar.ts"),
join(import.meta.dir, "src/client/appointments.ts"),
join(import.meta.dir, "src/client/appointments-new.ts"),
join(import.meta.dir, "src/client/appointments-detail.ts"),
join(import.meta.dir, "src/client/appointments-calendar.ts"),
@@ -350,16 +346,15 @@ async function build() {
await Bun.write(join(DIST, "projects.html"), renderProjects());
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "deadlines.html"), renderDeadlines());
// t-paliad-110 — shared EventsPage shell. Two HTML outputs (events-deadlines
// / events-appointments) so each route gets a Sidebar/BottomNav highlighted
// for the matching nav entry; the Go handler injects defaultType at runtime.
// t-paliad-110 — shared EventsPage shell. Two HTML outputs
// (events-deadlines / events-appointments) so each route gets a
// Sidebar/BottomNav highlighted for the matching nav entry; the
// defaultType is baked into each artefact via inline hydration.
await Bun.write(join(DIST, "events-deadlines.html"), renderEvents("/deadlines"));
await Bun.write(join(DIST, "events-appointments.html"), renderEvents("/appointments"));
await Bun.write(join(DIST, "deadlines-new.html"), renderDeadlinesNew());
await Bun.write(join(DIST, "deadlines-detail.html"), renderDeadlinesDetail());
await Bun.write(join(DIST, "deadlines-calendar.html"), renderDeadlinesCalendar());
await Bun.write(join(DIST, "appointments.html"), renderAppointments());
await Bun.write(join(DIST, "appointments-new.html"), renderAppointmentsNew());
await Bun.write(join(DIST, "appointments-detail.html"), renderAppointmentsDetail());
await Bun.write(join(DIST, "appointments-calendar.html"), renderAppointmentsCalendar());

View File

@@ -1,130 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderAppointments(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="appointments.list.title">Termine &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/appointments" />
<BottomNav currentPath="/appointments" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="appointments.list.heading">Termine</h1>
<p className="tool-subtitle" data-i18n="appointments.list.subtitle">
Verhandlungen, Besprechungen, Beratungen &mdash; pers&ouml;nlich oder aktenbezogen.
</p>
</div>
<div className="fristen-header-actions">
<a href="/appointments/calendar" className="btn-secondary" data-i18n="appointments.list.calendar">
Kalenderansicht
</a>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">
Neuer Termin
</a>
</div>
</div>
</div>
<div className="frist-summary-cards" id="appointments-summary">
<button type="button" className="frist-summary-card termin-card-today" data-range="today">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-today">0</span>
<span className="frist-summary-label" data-i18n="appointments.summary.today">Heute</span>
</button>
<button type="button" className="frist-summary-card termin-card-week" data-range="this_week">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-week">0</span>
<span className="frist-summary-label" data-i18n="appointments.summary.thisweek">Diese Woche</span>
</button>
<button type="button" className="frist-summary-card termin-card-later" data-range="later">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-later">0</span>
<span className="frist-summary-label" data-i18n="appointments.summary.later">Sp&auml;ter</span>
</button>
</div>
<div className="entity-controls">
<div className="filter-row">
<label className="filter-label" htmlFor="appointment-filter-type" data-i18n="appointments.filter.type">Typ</label>
<select id="appointment-filter-type" className="entity-select">
<option value="" data-i18n="appointments.filter.type.all">Alle Typen</option>
<option value="hearing" data-i18n="appointments.type.hearing">Verhandlung</option>
<option value="meeting" data-i18n="appointments.type.meeting">Besprechung</option>
<option value="consultation" data-i18n="appointments.type.consultation">Beratung</option>
<option value="deadline_hearing" data-i18n="appointments.type.deadline_hearing">Fristverhandlung</option>
</select>
<label className="filter-label" htmlFor="appointment-filter-project" data-i18n="appointments.filter.akte">Projekt</label>
<select id="appointment-filter-project" className="entity-select">
<option value="" data-i18n="appointments.filter.akte.all">Alle Projekte &amp; pers&ouml;nlich</option>
<option value="__personal__" data-i18n="appointments.filter.akte.personal">Nur pers&ouml;nliche</option>
</select>
<label className="filter-label" htmlFor="appointment-filter-from" data-i18n="appointments.filter.from">Von</label>
<input type="date" id="appointment-filter-from" className="entity-select" />
<label className="filter-label" htmlFor="appointment-filter-to" data-i18n="appointments.filter.to">Bis</label>
<input type="date" id="appointment-filter-to" className="entity-select" />
</div>
</div>
<div id="appointments-unavailable" className="entity-unavailable" style="display:none">
<p data-i18n="appointments.unavailable">
Terminverwaltung zurzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div className="entity-table-wrap">
<table className="entity-table fristen-table" id="appointments-table">
<thead>
<tr>
<th />
<th data-i18n="appointments.col.start">Beginn</th>
<th data-i18n="appointments.col.title">Titel</th>
<th data-i18n="appointments.col.akte">Projekt</th>
<th data-i18n="appointments.col.location">Ort</th>
<th data-i18n="appointments.col.type">Typ</th>
</tr>
</thead>
<tbody id="appointments-body" />
</table>
</div>
<div className="entity-empty" id="appointments-empty" style="display:none">
<h2 data-i18n="appointments.empty.title">Keine Termine vorhanden</h2>
<p data-i18n="appointments.empty.hint">
Sobald Termine angelegt werden, erscheinen sie hier.
</p>
<a href="/appointments/new" className="btn-primary btn-cta-lime" data-i18n="appointments.list.new">Neuer Termin</a>
</div>
<div className="entity-empty entity-empty-filtered" id="appointments-empty-filtered" style="display:none">
<p data-i18n="appointments.empty.filtered">Keine Termine mit diesen Filtern.</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/appointments.js"></script>
</body>
</html>
);
}

View File

@@ -1,270 +0,0 @@
import { initI18n, onLangChange, t, tDyn, 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;
}
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 projectFilter = "";
let fromFilter = "";
let toFilter = "";
let loadedOK = false;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
}
async function loadProjects() {
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 loadAppointments() {
const unavailable = document.getElementById("appointments-unavailable")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
try {
const params = new URLSearchParams();
if (typeFilter) params.set("type", typeFilter);
if (projectFilter && projectFilter !== PERSONAL) params.set("project_id", projectFilter);
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("appointments-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
return;
}
const data: Appointment[] = await resp.json();
allAppointments = projectFilter === 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("appointments-body")!;
const empty = document.getElementById("appointments-empty")!;
const emptyFiltered = document.getElementById("appointments-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
if (allAppointments.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
if (!typeFilter && !projectFilter && !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 ? tDyn(`appointments.type.${tt.appointment_type}`) || tt.appointment_type : "";
const typeClass = tt.appointment_type ? `termin-type-${tt.appointment_type}` : "";
const projectCell = tt.project_id
? `<a class="entity-ref-link" href="/projects/${esc(tt.project_id)}">${esc(tt.project_reference ?? "")}</a>`
+ `<span class="frist-project-title" title="${esc(tt.project_title ?? "")}">${esc(tt.project_title ?? "")}</span>`
: `<span class="termin-personal-tag" data-i18n="appointments.personal">${esc(t("appointments.personal"))}</span>`;
// Empty ORT cell renders an em-dash for placeholder consistency (F-28).
const locationCell = tt.location ? esc(tt.location) : "&mdash;";
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">${projectCell}</td>
<td>${locationCell}</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("appointment-filter-type") as HTMLSelectElement;
const project = document.getElementById("appointment-filter-project") as HTMLSelectElement;
const from = document.getElementById("appointment-filter-from") as HTMLInputElement;
const to = document.getElementById("appointment-filter-to") as HTMLInputElement;
const params = urlParams();
if (params.has("type")) typeFilter = params.get("type")!;
if (params.has("project_id")) projectFilter = 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([loadAppointments(), loadSummary()]);
});
project.addEventListener("change", async () => {
projectFilter = project.value;
await Promise.all([loadAppointments(), loadSummary()]);
});
from.addEventListener("change", async () => {
fromFilter = from.value;
await loadAppointments();
});
to.addEventListener("change", async () => {
toFilter = to.value;
await loadAppointments();
});
}
function populateProjectFilter() {
const sel = document.getElementById("appointment-filter-project") as HTMLSelectElement;
const options: string[] = [
`<option value="">${esc(t("appointments.filter.akte.all"))}</option>`,
`<option value="${PERSONAL}">${esc(t("appointments.filter.akte.personal"))}</option>`,
];
for (const a of allProjects) {
options.push(
`<option value="${esc(a.id)}">${esc(a.reference || "")}${esc(a.title)}</option>`,
);
}
sel.innerHTML = options.join("");
if (projectFilter) sel.value = projectFilter;
}
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("appointment-filter-from") as HTMLInputElement).value = from;
(document.getElementById("appointment-filter-to") as HTMLInputElement).value = to;
await loadAppointments();
});
});
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
initFilters();
initSummaryCards();
onLangChange(render);
await loadProjects();
populateProjectFilter();
await Promise.all([loadAppointments(), loadSummary()]);
});

View File

@@ -1,421 +0,0 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import {
attachEventTypeMultiSelectFilter,
fetchEventTypes,
eventTypeLabel,
type EventType,
type FilterHandle,
} from "./event-types";
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;
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
event_type_ids?: string[];
}
let eventTypeFilter: FilterHandle | null = null;
let eventTypeByID: Map<string, EventType> = new Map();
interface Project {
id: string;
reference?: string | null;
title: string;
}
interface Summary {
overdue: number;
today: number;
this_week: number;
next_week: number;
completed: number;
total: number;
}
interface Me {
id: string;
job_title: string | null;
global_role: string;
}
let allDeadlines: Deadline[] = [];
let allProjects: Project[] = [];
let me: Me | null = null;
let statusFilter = "pending";
let projectFilter = "";
let loadedOK = false;
function urlParams(): URLSearchParams {
return new URLSearchParams(window.location.search);
}
async function loadProjects() {
try {
const resp = await fetch("/api/projects");
if (resp.ok) allProjects = await resp.json();
} catch {
/* non-fatal */
}
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* non-fatal */
}
}
function canReopen(): boolean {
// Server enforces global-admin OR project-lead. Client mirrors a subset:
// global admins/partners see the inline reopen icon. Project leads without
// a global admin/partner role can still reopen via the detail page.
return !!me && (me.global_role === "global_admin");
}
async function loadSummary() {
try {
const url = projectFilter
? `/api/deadlines/summary?project_id=${encodeURIComponent(projectFilter)}`
: `/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-today", sum.today);
setCount("sum-week", sum.this_week);
setCount("sum-next-week", sum.next_week);
setCount("sum-completed", sum.completed);
applyOverdueState(sum.overdue);
} catch {
/* non-fatal */
}
}
// Überfällig is an emergency category — hide the card on a clean slate (the
// .frist-summary-cards grid uses auto-fit so the row re-flows to 3 cards) and
// trip the alarm styling when there's anything overdue. Mirrors the Dashboard
// logic in client/dashboard.ts. See t-paliad-105.
function applyOverdueState(overdue: number) {
const card = document.querySelector<HTMLElement>(
'.frist-summary-card[data-status="overdue"]',
);
if (!card) return;
card.classList.toggle("frist-card-overdue-hidden", overdue === 0);
card.classList.toggle("frist-card-alarm", overdue > 0);
}
function setCount(id: string, n: number) {
const el = document.getElementById(id);
if (el) el.textContent = String(n);
}
async function loadDeadlines() {
const unavailable = document.getElementById("deadlines-unavailable")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
try {
const params = new URLSearchParams();
if (statusFilter) params.set("status", statusFilter);
if (projectFilter) params.set("project_id", projectFilter);
const eventTypeQuery = eventTypeFilter?.toQueryValue() ?? "";
if (eventTypeQuery) params.set("event_type", eventTypeQuery);
const resp = await fetch(`/api/deadlines?${params.toString()}`);
if (resp.status === 503) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
document.getElementById("deadlines-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
tableWrap.style.display = "none";
return;
}
allDeadlines = 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.slice(0, 10) + "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.length === 10 ? iso + "T00:00:00" : iso);
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;
}
// REGEL cell label. Prefer the localized rule name ("Replik" / "Reply"); fall
// back to em-dash when no rule is attached. We never render the raw machine
// slug ("inf.rejoin") — the audit (F-10) flagged that as implementation leak.
function ruleDisplay(f: Deadline): string {
const lang = getLang();
const localized = lang === "en" ? f.rule_name_en : f.rule_name;
if (localized && localized.trim()) return esc(localized);
return "&mdash;";
}
function eventTypeDisplay(f: Deadline): string {
const ids = f.event_type_ids ?? [];
if (ids.length === 0) return "&mdash;";
const labels: string[] = [];
for (const id of ids) {
const et = eventTypeByID.get(id);
if (et) labels.push(eventTypeLabel(et));
}
if (labels.length === 0) return "&mdash;";
return labels.map((l) => `<span class="entity-event-type-pill">${esc(l)}</span>`).join(" ");
}
function render() {
if (!loadedOK) return;
const tbody = document.getElementById("deadlines-body")!;
const empty = document.getElementById("deadlines-empty")!;
const emptyFiltered = document.getElementById("deadlines-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".entity-table-wrap")!;
if (allDeadlines.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
if (statusFilter === "all" && !projectFilter) {
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";
const showReopen = canReopen();
tbody.innerHTML = allDeadlines
.map((f) => {
const urgency = urgencyClass(f.due_date, f.status);
const statusLabel = tDyn(`deadlines.status.${f.status}`) || f.status;
const ruleLabel = ruleDisplay(f);
const isDone = f.status === "completed";
const titleClass = isDone ? "frist-title-done" : "";
const reopenLabel = esc(t("deadlines.action.reopen"));
const checkCell = isDone
? showReopen
? `<button type="button" class="frist-reopen-btn" aria-label="${reopenLabel}" title="${reopenLabel}">↻</button>`
: `<input type="checkbox" class="frist-complete-cb" checked disabled aria-label="${esc(t("deadlines.complete.action"))}" />`
: `<input type="checkbox" class="frist-complete-cb" aria-label="${esc(t("deadlines.complete.action"))}" />`;
return `<tr class="frist-row" data-id="${esc(f.id)}">
<td class="frist-col-check">${checkCell}</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="entity-ref-link" href="/projects/${esc(f.project_id)}">${esc(f.project_reference)}</a>
<span class="frist-project-title" title="${esc(f.project_title)}">${esc(f.project_title)}</span>
</td>
<td class="frist-col-rule">${ruleLabel}</td>
<td class="entity-col-event-type">${eventTypeDisplay(f)}</td>
<td class="entity-col-status"><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})
.join("");
// F-23: when every visible row carries the same status, hide the column.
// The class is dropped as soon as the user widens the filter and variety
// reappears, so the header is never permanently removed from the DOM.
const statusUnique = new Set(allDeadlines.map((f) => f.status)).size;
const table = document.getElementById("deadlines-table");
table?.classList.toggle("entity-table--hide-status", statusUnique <= 1);
// Hide the Typ column when no row has any event_type attached — keeps
// existing deadlines (pre-t-paliad-088) from showing a noisy empty col.
const anyEventType = allDeadlines.some((f) => (f.event_type_ids ?? []).length > 0);
table?.classList.toggle("entity-table--hide-event-type", !anyEventType);
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(".frist-complete-cb") ||
target.closest(".frist-reopen-btn") ||
target.closest("a")
) return;
window.location.href = `/deadlines/${id}`;
});
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
if (cb && !cb.disabled) {
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([loadDeadlines(), loadSummary()]);
} else {
cb.checked = false;
cb.disabled = false;
}
} catch {
cb.checked = false;
cb.disabled = false;
}
});
}
const reopenBtn = row.querySelector<HTMLButtonElement>(".frist-reopen-btn");
if (reopenBtn) {
reopenBtn.addEventListener("click", async () => {
reopenBtn.disabled = true;
try {
const resp = await fetch(`/api/deadlines/${id}/reopen`, { method: "PATCH" });
if (resp.ok) {
await Promise.all([loadDeadlines(), loadSummary()]);
} else {
reopenBtn.disabled = false;
}
} catch {
reopenBtn.disabled = false;
}
});
}
});
}
function initFilters() {
const status = document.getElementById("deadline-filter-status") as HTMLSelectElement;
const project = document.getElementById("deadline-filter-project") as HTMLSelectElement;
const eventTrigger = document.getElementById("deadline-filter-event-type") as HTMLButtonElement;
const eventPanel = document.getElementById("deadline-filter-event-type-panel") as HTMLElement;
const params = urlParams();
if (params.has("status")) statusFilter = params.get("status")!;
if (params.has("project_id")) projectFilter = params.get("project_id")!;
status.value = statusFilter;
let initialEventIDs: string[] = [];
let initialIncludeUntyped = false;
const initialEventRaw = params.get("event_type") ?? "";
if (initialEventRaw) {
for (const tok of initialEventRaw.split(",")) {
const t = tok.trim();
if (!t) continue;
if (t === "none") initialIncludeUntyped = true;
else initialEventIDs.push(t);
}
}
status.addEventListener("change", async () => {
statusFilter = status.value;
syncURLParams();
await Promise.all([loadDeadlines(), loadSummary()]);
});
project.addEventListener("change", async () => {
projectFilter = project.value;
syncURLParams();
await Promise.all([loadDeadlines(), loadSummary()]);
});
if (eventTrigger && eventPanel) {
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
initialIDs: initialEventIDs,
initialIncludeUntyped: initialIncludeUntyped,
onChange: async () => {
syncURLParams();
await loadDeadlines();
},
});
}
}
function syncURLParams() {
const url = new URL(window.location.href);
url.searchParams.delete("status");
url.searchParams.delete("project_id");
url.searchParams.delete("event_type");
if (statusFilter && statusFilter !== "pending") url.searchParams.set("status", statusFilter);
if (projectFilter) url.searchParams.set("project_id", projectFilter);
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
if (eventQuery) url.searchParams.set("event_type", eventQuery);
window.history.replaceState(null, "", url.toString());
}
function populateProjectFilter() {
const sel = document.getElementById("deadline-filter-project") as HTMLSelectElement;
const options: string[] = [
`<option value="" data-i18n="deadlines.filter.akte.all">${esc(t("deadlines.filter.akte.all"))}</option>`,
];
for (const a of allProjects) {
options.push(
`<option value="${esc(a.id)}">${esc(a.reference || "")}${esc(a.title)}</option>`,
);
}
sel.innerHTML = options.join("");
if (projectFilter) sel.value = projectFilter;
}
function initSummaryCards() {
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
card.addEventListener("click", async () => {
const newStatus = card.dataset.status!;
statusFilter = newStatus;
(document.getElementById("deadline-filter-status") as HTMLSelectElement).value = newStatus;
await Promise.all([loadDeadlines(), loadSummary()]);
});
});
}
async function loadEventTypes() {
try {
const types = await fetchEventTypes();
eventTypeByID = new Map(types.map((et) => [et.id, et]));
} catch {
/* non-fatal */
}
}
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
initFilters();
initSummaryCards();
onLangChange(render);
await Promise.all([loadProjects(), loadMe(), loadEventTypes()]);
populateProjectFilter();
await Promise.all([loadDeadlines(), loadSummary()]);
});

View File

@@ -1,141 +0,0 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { BottomNav } from "./components/BottomNav";
import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
export function renderDeadlines(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="theme-color" content="#BFF355" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<PWAHead />
<title data-i18n="deadlines.list.title">Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/deadlines" />
<BottomNav currentPath="/deadlines" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="entity-header-row">
<div>
<h1 data-i18n="deadlines.list.heading">Fristen</h1>
<p className="tool-subtitle" data-i18n="deadlines.list.subtitle">
Persistente Fristen f&uuml;r Ihre Akten. &Uuml;berf&auml;llig, heute, diese Woche, n&auml;chste Woche &mdash; auf einen Blick.
</p>
</div>
<div className="fristen-header-actions">
<a href="/deadlines/calendar" className="btn-secondary" data-i18n="deadlines.list.calendar">
Kalenderansicht
</a>
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">
Neue Frist
</a>
</div>
</div>
</div>
<div className="frist-summary-cards" id="deadlines-summary">
<button type="button" className="frist-summary-card frist-card-overdue" data-status="overdue">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-overdue">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.overdue">&Uuml;berf&auml;llig</span>
</button>
<button type="button" className="frist-summary-card frist-card-today" data-status="today">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-today">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.today">Heute</span>
</button>
<button type="button" className="frist-summary-card frist-card-week" data-status="this_week">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-week">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.thisweek">Diese Woche</span>
</button>
<button type="button" className="frist-summary-card frist-card-next-week" data-status="next_week">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-next-week">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.nextweek">N&auml;chste Woche</span>
</button>
<button type="button" className="frist-summary-card frist-card-completed" data-status="completed">
<span className="frist-summary-dot" />
<span className="frist-summary-count" id="sum-completed">0</span>
<span className="frist-summary-label" data-i18n="deadlines.summary.completed">Erledigt</span>
</button>
</div>
<div className="entity-controls">
<div className="filter-row">
<label className="filter-label" htmlFor="deadline-filter-status" data-i18n="deadlines.filter.status">Status</label>
<select id="deadline-filter-status" className="entity-select">
<option value="all" data-i18n="deadlines.filter.all">Alle offenen &amp; erledigten</option>
<option value="pending" data-i18n="deadlines.filter.pending">Alle offenen</option>
<option value="overdue" data-i18n="deadlines.filter.overdue">&Uuml;berf&auml;llig</option>
<option value="today" data-i18n="deadlines.filter.today">Heute</option>
<option value="this_week" data-i18n="deadlines.filter.thisweek">Diese Woche</option>
<option value="next_week" data-i18n="deadlines.filter.nextweek">N&auml;chste Woche</option>
<option value="completed" data-i18n="deadlines.filter.completed">Erledigt</option>
</select>
<label className="filter-label" htmlFor="deadline-filter-project" data-i18n="deadlines.filter.akte">Projekt</label>
<select id="deadline-filter-project" className="entity-select">
<option value="" data-i18n="deadlines.filter.akte.all">Alle Projekte</option>
</select>
<label className="filter-label" htmlFor="deadline-filter-event-type" data-i18n="deadlines.filter.event_type">Typ</label>
<button type="button" id="deadline-filter-event-type" className="entity-select multi-trigger" aria-haspopup="listbox" />
<div id="deadline-filter-event-type-panel" className="multi-panel" hidden />
</div>
</div>
<div id="deadlines-unavailable" className="entity-unavailable" style="display:none">
<p data-i18n="deadlines.unavailable">
Fristenverwaltung zurzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div className="entity-table-wrap">
<table className="entity-table fristen-table" id="deadlines-table">
<thead>
<tr>
<th />
<th data-i18n="deadlines.col.due">F&auml;llig</th>
<th data-i18n="deadlines.col.title">Titel</th>
<th data-i18n="deadlines.col.akte">Projekt</th>
<th data-i18n="deadlines.col.rule">Regel</th>
<th className="entity-col-event-type" data-i18n="deadlines.col.event_type">Typ</th>
<th className="entity-col-status" data-i18n="deadlines.col.status">Status</th>
</tr>
</thead>
<tbody id="deadlines-body" />
</table>
</div>
<div className="entity-empty" id="deadlines-empty" style="display:none">
<h2 data-i18n="deadlines.empty.title">Keine Fristen vorhanden</h2>
<p data-i18n="deadlines.empty.hint">
Sobald Fristen angelegt oder aus dem Fristenrechner &uuml;bernommen werden, erscheinen sie hier.
</p>
<a href="/deadlines/new" className="btn-primary btn-cta-lime" data-i18n="deadlines.list.new">Neue Frist</a>
</div>
<div className="entity-empty entity-empty-filtered" id="deadlines-empty-filtered" style="display:none">
<p data-i18n="deadlines.empty.filtered">Keine Fristen mit diesen Filtern.</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/deadlines.js"></script>
</body>
</html>
);
}

View File

@@ -5,16 +5,18 @@ import { Footer } from "./components/Footer";
import { PWAHead } from "./components/PWAHead";
// EventsPage is the shared shell rendered on both /deadlines and
// /appointments (t-paliad-110). The current default type ("deadline" /
// "appointment") is injected by the Go handler via
// `window.__PALIAD_EVENTS__` and consumed by client/events.ts on init,
// which then drives the heading, the bucket cards, and the filter row
// without a re-mount.
// /appointments (t-paliad-110). The default type ("deadline" / "appointment")
// determines which Sidebar entry is highlighted AND is inlined into the
// head as `window.__PALIAD_EVENTS__` so client/events.ts paints the right
// heading, bucket cards, and filter row on first frame — no waterfall.
//
// `currentPath` is what powers the Sidebar / BottomNav active highlight —
// "/deadlines" for the Fristen entry, "/appointments" for the Termine
// entry. Both routes share the rest of the markup verbatim.
// We render two separate HTML outputs (one per default type) at build
// time and the Go handler ServeFiles the matching one — that keeps the
// hydration trivial (just a static literal) instead of needing a
// dashboard-style placeholder swap on every request.
export function renderEvents(currentPath: "/deadlines" | "/appointments"): string {
const defaultType = currentPath === "/appointments" ? "appointment" : "deadline";
const hydration = `window.__PALIAD_EVENTS__=${JSON.stringify({ defaultType })};`;
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
@@ -26,6 +28,7 @@ export function renderEvents(currentPath: "/deadlines" | "/appointments"): strin
<PWAHead />
<title id="events-title" data-i18n="deadlines.list.title">Fristen &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
<script>{hydration}</script>
</head>
<body className="has-sidebar">
<Sidebar currentPath={currentPath} />

View File

@@ -7,8 +7,10 @@ import "net/http"
// client TS bundles call /api/appointments* to populate the DOM and read
// id/project_id from window.location.
// /appointments now renders the unified EventsPage shell (t-paliad-110)
// with defaultType="appointment" baked into the build artefact.
func handleAppointmentsListPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/appointments.html")
http.ServeFile(w, r, "dist/events-appointments.html")
}
func handleAppointmentsNewPage(w http.ResponseWriter, r *http.Request) {

View File

@@ -7,8 +7,12 @@ import "net/http"
// client TS bundles call /api/deadlines* to populate the DOM and read
// id/project_id from window.location.
// /deadlines now renders the unified EventsPage shell (t-paliad-110).
// The build emits two HTML outputs from one renderEvents() — this one
// hydrates window.__PALIAD_EVENTS__ with defaultType="deadline" so
// client/events.ts paints the deadline view first frame.
func handleDeadlinesListPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/deadlines.html")
http.ServeFile(w, r, "dist/events-deadlines.html")
}
func handleDeadlinesNewPage(w http.ResponseWriter, r *http.Request) {