Merge: t-paliad-110 PR-3 — mount EventsPage on /deadlines + /appointments
This commit is contained in:
@@ -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());
|
||||
|
||||
@@ -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 — 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 — persö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ä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 & persönlich</option>
|
||||
<option value="__personal__" data-i18n="appointments.filter.akte.personal">Nur persö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ügbar — 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>
|
||||
);
|
||||
}
|
||||
@@ -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) : "—";
|
||||
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()]);
|
||||
});
|
||||
@@ -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 "—";
|
||||
}
|
||||
|
||||
function eventTypeDisplay(f: Deadline): string {
|
||||
const ids = f.event_type_ids ?? [];
|
||||
if (ids.length === 0) return "—";
|
||||
const labels: string[] = [];
|
||||
for (const id of ids) {
|
||||
const et = eventTypeByID.get(id);
|
||||
if (et) labels.push(eventTypeLabel(et));
|
||||
}
|
||||
if (labels.length === 0) return "—";
|
||||
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()]);
|
||||
});
|
||||
@@ -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 — 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ür Ihre Akten. Überfällig, heute, diese Woche, nächste Woche — 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">Überfä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ä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 & erledigten</option>
|
||||
<option value="pending" data-i18n="deadlines.filter.pending">Alle offenen</option>
|
||||
<option value="overdue" data-i18n="deadlines.filter.overdue">Überfä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ä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ügbar — 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ä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 ü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>
|
||||
);
|
||||
}
|
||||
@@ -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 — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
<script>{hydration}</script>
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath={currentPath} />
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user