t-paliad-258. m's verdict on t-paliad-251's rule UI: "too many options"
(4 'Oral hearings' across courts, etc.). Replace the full deadline_rules
catalog dropdown + sort selector with a binary model and unify the rule
display contract across every surface that prints a rule label.
Binary Rule field on the deadline form
- Auto (default): rule_id is derived from the chosen Type. The resolved
rule renders read-only as 'Auto | <Name · Citation>' next to the
field. No catalog picker, no sort options.
- Custom: free-text input. Stored as deadlines.custom_rule_text (new
nullable column, migration 122). Mutually exclusive with rule_id at
the persistence boundary.
- Toggle link flips between modes. Re-toggling to Auto re-resolves from
the current Type — no stale state.
Schema + service (additive)
- migration 122 adds paliad.deadlines.custom_rule_text (nullable).
Existing rows: empty custom_rule_text + non-null rule_id = Auto-
equivalent. Both NULL = "keine Regel" (consistent with today).
- models.Deadline.CustomRuleText + service SELECTs include the column.
- CreateDeadlineInput accepts custom_rule_text; the service drops it
when rule_id is set (catalog wins; simple invariant at the boundary).
- UpdateDeadlineInput grows a {RuleSet, RuleID, CustomRuleText} triple.
RuleSet=true is the discriminator so absent fields don't overwrite
the row (PATCH semantics). RuleID and CustomRuleText are mutually
exclusive in one request; service rejects "both set".
- EventListItem (the /api/events union) carries CustomRuleText so list
surfaces can render it.
Frontend: deadlines-new
- Drop the rule <select>, the by_proceeding/by_court/alpha sort
dropdown, the override-warning slot, and the collapsed-by-Regel Typ
view. Strip the (Rule→Type) auto-fill machinery — direction is now
one-way (Type → Auto-resolved Rule).
- Keep Type→Rule resolution: resolveAutoRuleForType picks the canonical
rule by project's proceeding, then jurisdiction match, then first
candidate. Same logic, just re-aimed at the read-only display.
- Standardtitel preserves the chain (event type → Auto rule label →
Custom text → proceeding → fallback) so the recipe still produces a
sensible title even when Custom is used.
Frontend: deadlines-detail
- Read-only display: catalog rule → Name · Citation, else
custom_rule_text + Custom badge, else legacy rule_code, else "—".
- Edit mode: mirror the create form with the Auto/Custom toggle.
enterEdit initialises the mode from the persisted deadline; Save
PATCHes with rule_set:true + the chosen rule pointer.
Rule-label addendum (m's 14:31 follow-up)
- Canonical contract everywhere: Name primary, Citation muted secondary
("Notice of Appeal · UPC.RoP.220.1"). Custom rules render the text
with a "Custom" pill.
- New frontend/src/client/rule-label.ts exports formatRuleLabel /
formatRuleLabelHTML / formatCustomRuleLabelHTML — one helper per
shape (plain text vs muted-citation HTML).
- Wired into: deadlines-new Auto display, deadlines-detail read +
Standardtitel, events.ts ruleDisplay (REGEL column on /events),
projects-detail.ts Fristen table, views/shape-list.ts generic
rule column.
- Verfahrensablauf (views/verfahrensablauf-core.ts) already renders
name + citation chip separately and matches the canonical pattern;
no change needed. Schriftsätze table is column-shaped (name + code
in distinct columns) and out of scope per the addendum.
CSS
- New .rule-mode-auto / .rule-mode-custom / .rule-label-* family.
- Drop the dead .rule-sort-select rule and the .event-type-collapsed*
family (retired with the catalog dropdown).
i18n
- DE+EN. Remove 10 stale keys (rule.none, autofill, autofill_inline,
mismatch, override, override_warn, sort.*). Add 6 (auto_no_match,
auto_pick_type, custom_badge, custom_placeholder,
mode.toggle_to_auto, mode.toggle_to_custom).
Build hygiene
- go build + go test ./internal/... clean.
- frontend bun build clean (2803 keys, scan clean).
Out of scope (per issue)
- Promoting Custom entries back to the catalog ("save as new rule").
- Filtering/searching custom_rule_text in deadline lists.
- Touching the event-type browse modal (Part 1 of #82 — that stays).
Files
- internal/db/migrations/122_deadlines_custom_rule_text.{up,down}.sql
- internal/models/models.go
- internal/services/deadline_service.go (Create+Update+SELECT)
- internal/services/event_service.go (union projection)
- frontend/src/client/rule-label.ts (new helper)
- frontend/src/client/deadlines-new.ts (rewrite)
- frontend/src/client/deadlines-detail.ts (Auto/Custom editor + display)
- frontend/src/client/events.ts (REGEL column)
- frontend/src/client/projects-detail.ts (Fristen table cell)
- frontend/src/client/views/shape-list.ts (generic rule column)
- frontend/src/client/i18n.ts + i18n-keys.ts (DE+EN delta)
- frontend/src/deadlines-new.tsx (strip dropdown+sort, add toggle)
- frontend/src/deadlines-detail.tsx (Auto/Custom edit slots)
- frontend/src/styles/global.css (rule-mode + rule-label families)
1017 lines
38 KiB
TypeScript
1017 lines
38 KiB
TypeScript
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
|
||
import { initSidebar } from "./sidebar";
|
||
import {
|
||
attachEventTypeMultiSelectFilter,
|
||
fetchEventTypes,
|
||
eventTypeLabel,
|
||
type EventType,
|
||
type FilterHandle,
|
||
} from "./event-types";
|
||
import { projectIndent } from "./project-indent";
|
||
import { mountCalendar, type CalendarHandle, type CalendarItem } from "./calendar/mount-calendar";
|
||
import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label";
|
||
|
||
// Two-eyes glyph 👀 inside .approval-pill--icon. m's 2026-05-08 follow-
|
||
// up: "two eyes instead of the one." Emoji rather than SVG keeps the
|
||
// pill markup trivially short and inherits the user's emoji font.
|
||
const APPROVAL_PILL_GLYPH = "👀";
|
||
|
||
// Sparkle glyph ✨ inside .approval-pill--agent (t-paliad-161). Renders
|
||
// next to (not in place of) 👀 when the pending row originated from a
|
||
// Paliadin chat suggestion. The two glyphs are orthogonal: 👀 = "needs
|
||
// approval", ✨ = "Paliadin drafted this". Both can coexist; either can
|
||
// appear alone in future autopilot states.
|
||
const AGENT_PILL_GLYPH = "✨";
|
||
|
||
// EventsPage shared client (t-paliad-110). Drives /deadlines and
|
||
// /appointments off the same shell — the route handler injects
|
||
// `window.__PALIAD_EVENTS__ = { defaultType: "deadline" | "appointment" }`
|
||
// on first paint and we read it here to set the initial chip + filter
|
||
// visibility. The chip toggle on the page lets the user widen to
|
||
// "Beides" without a route change; the URL gets `?type=` for state.
|
||
|
||
declare global {
|
||
interface Window {
|
||
__PALIAD_EVENTS__?: {
|
||
defaultType?: EventTypeChoice;
|
||
};
|
||
}
|
||
}
|
||
|
||
type EventTypeChoice = "deadline" | "appointment" | "all";
|
||
type EventView = "cards" | "list" | "calendar";
|
||
|
||
interface EventListItem {
|
||
type: "deadline" | "appointment";
|
||
id: string;
|
||
title: string;
|
||
description?: string;
|
||
event_date: string;
|
||
project_id?: string;
|
||
project_reference?: string;
|
||
project_title?: string;
|
||
project_type?: string;
|
||
|
||
// Approval workflow (t-paliad-138). "pending" → render the warning pill.
|
||
approval_status?: "approved" | "pending" | "legacy";
|
||
// t-paliad-161: when approval_status='pending', tells us whether the row
|
||
// was drafted by a user or by Paliadin (✨ glyph). NULL when not pending.
|
||
requester_kind?: "user" | "agent";
|
||
|
||
// deadline-only
|
||
due_date?: string;
|
||
status?: string;
|
||
completed_at?: string;
|
||
source?: string;
|
||
rule_id?: string;
|
||
rule_code?: string;
|
||
rule_name?: string;
|
||
rule_name_en?: string;
|
||
// t-paliad-258 — free-text rule label when the deadline was created
|
||
// via the Custom rule path. Mutually exclusive with rule_id.
|
||
custom_rule_text?: string;
|
||
event_type_ids?: string[];
|
||
|
||
// appointment-only
|
||
start_at?: string;
|
||
end_at?: string;
|
||
location?: string;
|
||
appointment_type?: string;
|
||
}
|
||
|
||
interface EventSummary {
|
||
deadlines?: {
|
||
overdue: number;
|
||
today: number;
|
||
this_week: number;
|
||
next_week: number;
|
||
later: number;
|
||
completed: number;
|
||
total: number;
|
||
};
|
||
appointments?: {
|
||
today: number;
|
||
this_week: number;
|
||
next_week: number;
|
||
later: number;
|
||
total: number;
|
||
};
|
||
}
|
||
|
||
interface Project {
|
||
id: string;
|
||
reference?: string | null;
|
||
title: string;
|
||
path: string;
|
||
}
|
||
|
||
interface Me {
|
||
id: string;
|
||
job_title: string | null;
|
||
global_role: string;
|
||
}
|
||
|
||
const PERSONAL = "__personal__";
|
||
|
||
// Status options per Type. Deadlines carry the full set; appointments
|
||
// only get the date-bucket subset (no pending/overdue/completed — those
|
||
// are deadline-only concepts). type=all reuses the deadline set so users
|
||
// keep all deadline-side filters available in Beides mode.
|
||
type StatusOption = { value: string; key: string };
|
||
|
||
const STATUS_OPTIONS_DEADLINE: StatusOption[] = [
|
||
{ value: "all", key: "deadlines.filter.all" },
|
||
{ value: "pending", key: "deadlines.filter.pending" },
|
||
{ value: "overdue", key: "deadlines.filter.overdue" },
|
||
{ value: "today", key: "deadlines.filter.today" },
|
||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||
{ value: "later", key: "deadlines.filter.later" },
|
||
{ value: "completed", key: "deadlines.filter.completed" },
|
||
];
|
||
|
||
// Appointment status options — m/paliad#54: the legacy 'upcoming' /
|
||
// "Ab heute" option was a UI lie (backend never narrowed past events for
|
||
// appointments) and is removed. 'today' is the sane default — matches the
|
||
// dashboard tile. 'all' stays as the explicit opt-in for past events.
|
||
const STATUS_OPTIONS_APPOINTMENT: StatusOption[] = [
|
||
{ value: "today", key: "deadlines.filter.today" },
|
||
{ value: "this_week", key: "deadlines.filter.thisweek" },
|
||
{ value: "next_week", key: "deadlines.filter.nextweek" },
|
||
{ value: "later", key: "deadlines.filter.later" },
|
||
{ value: "all", key: "events.filter.status.all" },
|
||
];
|
||
|
||
function statusOptionsFor(type: EventTypeChoice): StatusOption[] {
|
||
if (type === "appointment") return STATUS_OPTIONS_APPOINTMENT;
|
||
return STATUS_OPTIONS_DEADLINE;
|
||
}
|
||
|
||
function defaultStatusFor(type: EventTypeChoice): string {
|
||
return type === "appointment" ? "today" : "pending";
|
||
}
|
||
|
||
let currentType: EventTypeChoice = "deadline";
|
||
let currentView: EventView = "cards";
|
||
let statusFilter = "pending";
|
||
let projectFilter = "";
|
||
let appointmentTypeFilter = "";
|
||
let allItems: EventListItem[] = [];
|
||
let allProjects: Project[] = [];
|
||
let me: Me | null = null;
|
||
let eventTypeFilter: FilterHandle | null = null;
|
||
let eventTypeByID: Map<string, EventType> = new Map();
|
||
let loadedOK = false;
|
||
// Calendar handle is created lazily when /events first switches into the
|
||
// Kalender view (t-paliad-224). The handle owns its own month/week/day
|
||
// state + ?cal_view / ?cal_date URL contract via mountCalendar.
|
||
let calendar: CalendarHandle | null = null;
|
||
|
||
function urlParams(): URLSearchParams {
|
||
return new URLSearchParams(window.location.search);
|
||
}
|
||
|
||
function defaultType(): EventTypeChoice {
|
||
const injected = window.__PALIAD_EVENTS__?.defaultType;
|
||
if (injected === "deadline" || injected === "appointment" || injected === "all") {
|
||
return injected;
|
||
}
|
||
return "all";
|
||
}
|
||
|
||
function esc(s: string): string {
|
||
const d = document.createElement("div");
|
||
d.textContent = s;
|
||
return d.innerHTML;
|
||
}
|
||
|
||
function setCount(id: string, n: number) {
|
||
const el = document.getElementById(id);
|
||
if (el) el.textContent = String(n);
|
||
}
|
||
|
||
function fmtDateOnly(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 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 fmtTime(iso: string): string {
|
||
try {
|
||
const d = new Date(iso);
|
||
return d.toLocaleTimeString(getLang() === "de" ? "de-DE" : "en-GB", {
|
||
hour: "2-digit",
|
||
minute: "2-digit",
|
||
});
|
||
} catch {
|
||
return "";
|
||
}
|
||
}
|
||
|
||
// formatDateCell yields the per-row date label. Deadlines render as
|
||
// "31.08.2026" (date-only). Appointments render as "15.09.2026 09:00",
|
||
// extended with "–11:00" when end_at is on the same day, or
|
||
// "→ 16.09.2026 11:00" when end_at spills into a later day.
|
||
function formatDateCell(item: EventListItem): string {
|
||
if (item.type === "deadline") {
|
||
return fmtDateOnly(item.due_date ?? item.event_date);
|
||
}
|
||
if (!item.start_at) return "";
|
||
const start = new Date(item.start_at);
|
||
const startLabel = fmtDateTime(item.start_at);
|
||
if (!item.end_at) return startLabel;
|
||
const end = new Date(item.end_at);
|
||
const sameDay =
|
||
start.getFullYear() === end.getFullYear() &&
|
||
start.getMonth() === end.getMonth() &&
|
||
start.getDate() === end.getDate();
|
||
if (sameDay) {
|
||
return `${startLabel}–${fmtTime(item.end_at)}`;
|
||
}
|
||
return `${startLabel} → ${fmtDateTime(item.end_at)}`;
|
||
}
|
||
|
||
function urgencyClass(item: EventListItem): string {
|
||
if (item.type === "deadline") {
|
||
if (item.status === "completed") return "frist-urgency-done";
|
||
}
|
||
const today = new Date();
|
||
today.setHours(0, 0, 0, 0);
|
||
const d = new Date(item.event_date);
|
||
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 ruleDisplay(item: EventListItem): string {
|
||
if (item.type !== "deadline") return "";
|
||
// t-paliad-258 addendum — canonical display contract: Name primary,
|
||
// Citation muted secondary ("Notice of Appeal · UPC.RoP.220.1").
|
||
// Custom rules render the lawyer's free text + a "Custom" badge.
|
||
// Legacy rule-code-only saves (Fristenrechner, no rule_id) still
|
||
// show the bare citation as last-resort fallback.
|
||
const hasName = (item.rule_name && item.rule_name.trim()) ||
|
||
(item.rule_name_en && item.rule_name_en.trim());
|
||
if (hasName || (item.rule_code && item.rule_code.trim())) {
|
||
return formatRuleLabelHTML(
|
||
{
|
||
name: item.rule_name || "",
|
||
name_en: item.rule_name_en,
|
||
rule_code: item.rule_code,
|
||
},
|
||
esc,
|
||
);
|
||
}
|
||
if (item.custom_rule_text && item.custom_rule_text.trim()) {
|
||
return formatCustomRuleLabelHTML(item.custom_rule_text, esc);
|
||
}
|
||
return "—";
|
||
}
|
||
|
||
function eventTypeDisplay(item: EventListItem): string {
|
||
if (item.type !== "deadline") return "";
|
||
const ids = item.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 rowTypeChip(item: EventListItem): string {
|
||
const label = item.type === "deadline" ? t("events.row.type.deadline") : t("events.row.type.appointment");
|
||
const cls = item.type === "deadline" ? "events-row-type-frist" : "events-row-type-termin";
|
||
return `<span class="events-row-type-chip ${cls}">${esc(label)}</span>`;
|
||
}
|
||
|
||
function canReopen(): boolean {
|
||
return !!me && me.global_role === "global_admin";
|
||
}
|
||
|
||
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 */
|
||
}
|
||
}
|
||
|
||
async function loadEventTypes() {
|
||
try {
|
||
const types = await fetchEventTypes();
|
||
eventTypeByID = new Map(types.map((et) => [et.id, et]));
|
||
} catch {
|
||
/* non-fatal */
|
||
}
|
||
}
|
||
|
||
async function loadSummary() {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("type", currentType);
|
||
if (projectFilter === PERSONAL) {
|
||
// "Nur persönliche" → items the caller created. The backend
|
||
// applies this uniformly to both rails (deadlines + appointments)
|
||
// and ignores any project_id we'd send. See t-paliad-128.
|
||
params.set("personal_only", "true");
|
||
} else if (projectFilter) {
|
||
params.set("project_id", projectFilter);
|
||
}
|
||
const resp = await fetch(`/api/events/summary?${params.toString()}`);
|
||
if (!resp.ok) return;
|
||
const sum: EventSummary = await resp.json();
|
||
|
||
// 4 universal cards: pick the deadline value when present, otherwise
|
||
// the appointment value. In Beides mode, sum both rails.
|
||
const today = (sum.deadlines?.today ?? 0) + (sum.appointments?.today ?? 0);
|
||
const thisWeek = (sum.deadlines?.this_week ?? 0) + (sum.appointments?.this_week ?? 0);
|
||
const nextWeek = (sum.deadlines?.next_week ?? 0) + (sum.appointments?.next_week ?? 0);
|
||
const later = (sum.deadlines?.later ?? 0) + (sum.appointments?.later ?? 0);
|
||
|
||
setCount("events-sum-today", today);
|
||
setCount("events-sum-week", thisWeek);
|
||
setCount("events-sum-next-week", nextWeek);
|
||
setCount("events-sum-later", later);
|
||
|
||
// Überfällig is deadline-only and conditional. Hide on appointment view
|
||
// OR when the count is zero. Alarm-style when count > 0 on a view that
|
||
// includes deadlines (mirrors t-paliad-105 / t-paliad-106 behaviour).
|
||
const overdue = sum.deadlines?.overdue ?? 0;
|
||
setCount("events-sum-overdue", overdue);
|
||
applyOverdueState(overdue);
|
||
} catch {
|
||
/* non-fatal */
|
||
}
|
||
}
|
||
|
||
// Überfällig is deadline-view-only per t-paliad-110 spec. It stays hidden
|
||
// in Beides mode even when overdue > 0 — the design treats "overdue" as a
|
||
// deadline-specific concept (appointments don't go overdue, they happen),
|
||
// so showing the card in mixed mode would be a category error.
|
||
function applyOverdueState(overdue: number) {
|
||
const card = document.querySelector<HTMLElement>('.frist-summary-card[data-bucket="overdue"]');
|
||
if (!card) return;
|
||
const isDeadlineView = currentType === "deadline";
|
||
const hidden = !isDeadlineView || overdue === 0;
|
||
card.classList.toggle("frist-card-overdue-hidden", hidden);
|
||
card.classList.toggle("frist-card-alarm", isDeadlineView && overdue > 0);
|
||
}
|
||
|
||
async function loadList() {
|
||
const unavailable = document.getElementById("events-unavailable")!;
|
||
try {
|
||
const params = new URLSearchParams();
|
||
params.set("type", currentType);
|
||
if (statusFilter) {
|
||
// Status carries either a deadline filter (pending/overdue/completed,
|
||
// which only make sense when deadlines are in scope) or a date-bucket
|
||
// (today/this_week/next_week/later) which the backend applies to
|
||
// both rails. EventService.bucketAppointmentWindow turns bucket
|
||
// values into a start_at window; non-bucket values (all/"") are a
|
||
// no-op on the appointment side.
|
||
params.set("status", statusFilter);
|
||
}
|
||
if (projectFilter === PERSONAL) {
|
||
// "Nur persönliche" → items the caller created (t-paliad-128).
|
||
// Applies uniformly to both rails server-side; project_id is
|
||
// intentionally not sent (the two are mutually exclusive).
|
||
params.set("personal_only", "true");
|
||
} else if (projectFilter) {
|
||
params.set("project_id", projectFilter);
|
||
}
|
||
if (currentType !== "appointment") {
|
||
const eventTypeQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||
if (eventTypeQuery) params.set("event_type", eventTypeQuery);
|
||
}
|
||
if (currentType === "appointment" && appointmentTypeFilter) {
|
||
params.set("type_filter", appointmentTypeFilter);
|
||
}
|
||
const resp = await fetch(`/api/events?${params.toString()}`);
|
||
if (resp.status === 503) {
|
||
unavailable.style.display = "block";
|
||
hideTableAndCalendar();
|
||
document.getElementById("events-empty")!.style.display = "none";
|
||
return;
|
||
}
|
||
if (!resp.ok) {
|
||
unavailable.style.display = "block";
|
||
hideTableAndCalendar();
|
||
return;
|
||
}
|
||
const data: EventListItem[] = await resp.json();
|
||
allItems = data;
|
||
loadedOK = true;
|
||
render();
|
||
} catch {
|
||
unavailable.style.display = "block";
|
||
hideTableAndCalendar();
|
||
}
|
||
}
|
||
|
||
function hideTableAndCalendar() {
|
||
const tableWrap = document.getElementById("events-table-wrap");
|
||
const calWrap = document.getElementById("events-calendar-wrap");
|
||
if (tableWrap) tableWrap.style.display = "none";
|
||
if (calWrap) calWrap.hidden = true;
|
||
teardownCalendar();
|
||
}
|
||
|
||
function render() {
|
||
if (!loadedOK) return;
|
||
if (currentView === "calendar") {
|
||
renderCalendarView();
|
||
} else {
|
||
renderTable();
|
||
}
|
||
}
|
||
|
||
function renderTable() {
|
||
const tbody = document.getElementById("events-body")!;
|
||
const empty = document.getElementById("events-empty")!;
|
||
const emptyFiltered = document.getElementById("events-empty-filtered")!;
|
||
const tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
|
||
const table = document.getElementById("events-table");
|
||
|
||
if (allItems.length === 0) {
|
||
tbody.innerHTML = "";
|
||
tableWrap.style.display = "none";
|
||
if (isFilterPristine()) {
|
||
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 = allItems.map((item) => renderRow(item, showReopen)).join("");
|
||
|
||
// Hide-on-uniform pattern (t-paliad-073, generalised in t-088). When all
|
||
// visible rows agree on a column we drop the column entirely so empty
|
||
// cells don't add noise. Each column has its own data signal so we can
|
||
// toggle independently.
|
||
const anyDeadline = allItems.some((x) => x.type === "deadline");
|
||
const anyAppointment = allItems.some((x) => x.type === "appointment");
|
||
const anyEventType = allItems.some((x) => (x.event_type_ids ?? []).length > 0);
|
||
const anyLocation = allItems.some((x) => !!x.location);
|
||
const anyAppointmentType = allItems.some((x) => !!x.appointment_type);
|
||
const anyRule = allItems.some((x) => x.type === "deadline" && (x.rule_code || x.rule_name || x.rule_name_en));
|
||
const anyStatus = anyDeadline; // appointments don't carry a status column
|
||
|
||
table?.classList.toggle("events-table--hide-row-type", !anyDeadline || !anyAppointment);
|
||
table?.classList.toggle("events-table--hide-rule", !anyRule);
|
||
table?.classList.toggle("entity-table--hide-event-type", !anyEventType);
|
||
table?.classList.toggle("events-table--hide-location", !anyLocation);
|
||
table?.classList.toggle("events-table--hide-appointment-type", !anyAppointmentType);
|
||
table?.classList.toggle("entity-table--hide-status", !anyStatus);
|
||
|
||
wireRowHandlers(tbody);
|
||
}
|
||
|
||
function renderRow(item: EventListItem, showReopen: boolean): string {
|
||
const urgency = urgencyClass(item);
|
||
const dateLabel = formatDateCell(item);
|
||
const ruleLabel = item.type === "deadline" ? ruleDisplay(item) : "";
|
||
const eventTypeCell = item.type === "deadline" ? eventTypeDisplay(item) : "";
|
||
|
||
const projectCell = item.project_id
|
||
? `<a class="entity-ref-link" href="/projects/${esc(item.project_id)}">${esc(item.project_reference ?? "")}</a>`
|
||
+ `<span class="frist-project-title" title="${esc(item.project_title ?? "")}">${esc(item.project_title ?? "")}</span>`
|
||
: `<span class="termin-personal-tag">${esc(t("appointments.personal"))}</span>`;
|
||
|
||
let checkCell = "";
|
||
let titleClass = "";
|
||
let statusCell = "";
|
||
if (item.type === "deadline") {
|
||
const isDone = item.status === "completed";
|
||
titleClass = isDone ? "frist-title-done" : "";
|
||
const statusLabel = tDyn(`deadlines.status.${item.status ?? "pending"}`) || (item.status ?? "");
|
||
statusCell = `<span class="entity-status-chip entity-status-${esc(item.status ?? "")}">${esc(statusLabel)}</span>`;
|
||
const reopenLabel = esc(t("deadlines.action.reopen"));
|
||
if (isDone) {
|
||
checkCell = 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"))}" />`;
|
||
} else {
|
||
checkCell = `<input type="checkbox" class="frist-complete-cb" aria-label="${esc(t("deadlines.complete.action"))}" />`;
|
||
}
|
||
} else {
|
||
const typeClass = item.appointment_type ? `termin-type-${item.appointment_type}` : "";
|
||
checkCell = `<span class="termin-dot ${typeClass}"></span>`;
|
||
}
|
||
|
||
const locationCell = item.location ? esc(item.location) : "—";
|
||
const appointmentTypeCell = item.appointment_type
|
||
? `<span class="termin-type-chip termin-type-${esc(item.appointment_type)}">${esc(tDyn(`appointments.type.${item.appointment_type}`) || item.appointment_type)}</span>`
|
||
: "—";
|
||
|
||
// Approval pending pill (t-paliad-138 / m's 2026-05-08 cosmetic ask).
|
||
// Soft-tint the row + drop an eye-icon pill next to the title; hover
|
||
// reveals the lifecycle label. Inbox surface shows the full detail.
|
||
//
|
||
// t-paliad-161 ✨: when the pending row came from a Paliadin
|
||
// suggestion (requester_kind='agent'), drop a second pill next to 👀.
|
||
// Two glyphs read together as "needs approval, drafted by Paliadin".
|
||
const pendingClass = item.approval_status === "pending" ? " entity-row--pending-update" : "";
|
||
const pendingLabel = item.approval_status === "pending" ? t("approvals.pending_update.label") : "";
|
||
const pendingPill = item.approval_status === "pending"
|
||
? `<span class="approval-pill approval-pill--icon" title="${esc(pendingLabel)}" aria-label="${esc(pendingLabel)}">${APPROVAL_PILL_GLYPH}</span>`
|
||
: "";
|
||
const agentLabel = t("approvals.agent.label");
|
||
const agentPill = item.approval_status === "pending" && item.requester_kind === "agent"
|
||
? `<span class="approval-pill approval-pill--agent" title="${esc(agentLabel)}" aria-label="${esc(agentLabel)}">${AGENT_PILL_GLYPH}</span>`
|
||
: "";
|
||
|
||
return `<tr class="frist-row events-row events-row-${item.type}${pendingClass}" data-id="${esc(item.id)}" data-type="${item.type}">
|
||
<td class="frist-col-check">${checkCell}</td>
|
||
<td class="events-col-row-type">${rowTypeChip(item)}</td>
|
||
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${esc(dateLabel)}</td>
|
||
<td class="frist-col-title ${titleClass}">${esc(item.title)}${pendingPill ? " " + pendingPill : ""}${agentPill ? " " + agentPill : ""}</td>
|
||
<td class="frist-col-project">${projectCell}</td>
|
||
<td class="frist-col-rule events-col-rule">${ruleLabel || "—"}</td>
|
||
<td class="entity-col-event-type">${eventTypeCell || "—"}</td>
|
||
<td class="events-col-location">${locationCell}</td>
|
||
<td class="events-col-appointment-type">${appointmentTypeCell}</td>
|
||
<td class="entity-col-status">${statusCell}</td>
|
||
</tr>`;
|
||
}
|
||
|
||
// toCalendarItem adapts an EventListItem to the canonical CalendarItem
|
||
// shape consumed by mountCalendar (t-paliad-224). Bucketing date matches
|
||
// the pre-refactor behaviour: deadlines bucket on due_date (fallback to
|
||
// event_date); appointments bucket on start_at (fallback to event_date).
|
||
function toCalendarItem(item: EventListItem): CalendarItem {
|
||
let bucketDate: string;
|
||
if (item.type === "deadline") {
|
||
bucketDate = item.due_date ?? item.event_date;
|
||
} else if (item.start_at) {
|
||
bucketDate = item.start_at;
|
||
} else {
|
||
bucketDate = item.event_date;
|
||
}
|
||
return {
|
||
kind: item.type,
|
||
id: item.id,
|
||
title: item.title,
|
||
event_date: bucketDate,
|
||
project_id: item.project_id,
|
||
project_title: item.project_title,
|
||
project_reference: item.project_reference,
|
||
};
|
||
}
|
||
|
||
function renderCalendarView() {
|
||
const host = document.getElementById("events-calendar-wrap");
|
||
if (!host) return;
|
||
const tableEmpty = document.getElementById("events-empty")!;
|
||
const tableEmptyFiltered = document.getElementById("events-empty-filtered")!;
|
||
tableEmpty.style.display = "none";
|
||
tableEmptyFiltered.style.display = "none";
|
||
(host as HTMLElement).hidden = false;
|
||
|
||
const items = allItems.map(toCalendarItem);
|
||
if (calendar) {
|
||
calendar.update(items);
|
||
return;
|
||
}
|
||
// urlState=true: the Kalender tab persists its month/week/day + anchor
|
||
// in ?cal_view + ?cal_date so a refresh / shared link lands on the same
|
||
// calendar state (per t-paliad-224 §11 Q3 head decision).
|
||
calendar = mountCalendar(host as HTMLElement, items, {
|
||
urlState: true,
|
||
defaultView: "month",
|
||
});
|
||
}
|
||
|
||
function teardownCalendar() {
|
||
if (!calendar) return;
|
||
calendar.destroy();
|
||
calendar = null;
|
||
}
|
||
|
||
function applyView() {
|
||
const tableWrap = document.getElementById("events-table-wrap") as HTMLElement;
|
||
const calWrap = document.getElementById("events-calendar-wrap") as HTMLElement;
|
||
const summary = document.getElementById("events-summary") as HTMLElement;
|
||
|
||
// Active state on selector buttons.
|
||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((b) => {
|
||
b.classList.toggle("events-view-btn-active", b.dataset.eventView === currentView);
|
||
});
|
||
|
||
// Body class so CSS can hook in if needed downstream.
|
||
document.body.classList.toggle("events-view-cards", currentView === "cards");
|
||
document.body.classList.toggle("events-view-list", currentView === "list");
|
||
document.body.classList.toggle("events-view-calendar", currentView === "calendar");
|
||
|
||
// Cards view = the original layout (5-card summary + table).
|
||
// List view = no summary cards, table only — gives more vertical space
|
||
// and matches users' mental model of a flat list.
|
||
// Calendar view = mountCalendar() canon (month/week/day); cards + table
|
||
// both hidden. The handle is torn down when the user leaves Kalender
|
||
// so its URL state isn't reapplied to other shapes.
|
||
summary.style.display = currentView === "cards" ? "" : "none";
|
||
tableWrap.style.display = currentView === "calendar" ? "none" : "";
|
||
calWrap.hidden = currentView !== "calendar";
|
||
|
||
if (currentView === "calendar") {
|
||
if (loadedOK) renderCalendarView();
|
||
} else {
|
||
teardownCalendar();
|
||
}
|
||
}
|
||
|
||
function wireRowHandlers(tbody: HTMLElement) {
|
||
tbody.querySelectorAll<HTMLTableRowElement>(".events-row").forEach((row) => {
|
||
const id = row.dataset.id!;
|
||
const type = row.dataset.type!;
|
||
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 = type === "deadline" ? `/deadlines/${id}` : `/appointments/${id}`;
|
||
});
|
||
|
||
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
|
||
if (cb && !cb.disabled) {
|
||
cb.addEventListener("change", async () => {
|
||
if (!cb.checked) return;
|
||
const titleCell = row.querySelector<HTMLElement>(".events-title");
|
||
const title = (titleCell?.textContent || "").trim();
|
||
const msg = t("deadlines.complete.confirm").replace("{title}", title || "?");
|
||
if (!window.confirm(msg)) {
|
||
cb.checked = false;
|
||
return;
|
||
}
|
||
cb.disabled = true;
|
||
try {
|
||
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
|
||
if (resp.ok) {
|
||
await Promise.all([loadList(), 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([loadList(), loadSummary()]);
|
||
} else {
|
||
reopenBtn.disabled = false;
|
||
}
|
||
} catch {
|
||
reopenBtn.disabled = false;
|
||
}
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
function isFilterPristine(): boolean {
|
||
return (
|
||
statusFilter === defaultStatusFor(currentType) &&
|
||
!projectFilter &&
|
||
!appointmentTypeFilter &&
|
||
(eventTypeFilter?.toQueryValue() ?? "") === ""
|
||
);
|
||
}
|
||
|
||
function applyTypeVisibility() {
|
||
// Page-level chrome — heading, subtitle, actions, type chips' active state.
|
||
const heading = document.getElementById("events-heading");
|
||
const subtitle = document.getElementById("events-subtitle");
|
||
const title = document.getElementById("events-title");
|
||
const isAppointment = currentType === "appointment";
|
||
const isDeadline = currentType === "deadline";
|
||
const isAll = currentType === "all";
|
||
|
||
if (heading) heading.textContent = isAppointment ? t("appointments.list.heading") : t("deadlines.list.heading");
|
||
if (subtitle) subtitle.textContent = isAppointment ? t("appointments.list.subtitle") : t("deadlines.list.subtitle");
|
||
if (title) title.textContent = isAppointment ? t("appointments.list.title") : t("deadlines.list.title");
|
||
|
||
// Single "Neu …" CTA per type. View axis is the calendar/list/cards selector.
|
||
toggleDisplay("events-action-new-deadline", isDeadline || isAll, "inline-flex");
|
||
toggleDisplay("events-action-new-appointment", isAppointment || isAll, "inline-flex");
|
||
|
||
// Empty-state CTAs.
|
||
toggleDisplay("events-empty-cta-deadline", isDeadline || isAll, "inline-flex");
|
||
toggleDisplay("events-empty-cta-appointment", isAppointment || isAll, "inline-flex");
|
||
|
||
// Status filter applies to all types — for appointments the option set
|
||
// narrows to date buckets only (Heute / Diese Woche / Nächste Woche /
|
||
// Später / Alle). populateStatusFilter handles the option swap and may
|
||
// reset statusFilter when switching from a deadline-only value.
|
||
populateStatusFilter();
|
||
// Event-type multi-select is deadline-only (appointments have no event_types).
|
||
toggleFilterPair("events-filter-event-type", !isAppointment);
|
||
// The panel is a popup the trigger owns via `panel.hidden`. Never stamp
|
||
// `display: block` on it from the type filter — that overrides the
|
||
// `.multi-panel[hidden]` CSS rule and leaves the panel visible on larger
|
||
// screens. When switching to appointment view, force-close it.
|
||
const eventTypePanel = document.getElementById("events-filter-event-type-panel") as HTMLElement | null;
|
||
if (eventTypePanel) {
|
||
eventTypePanel.style.display = "";
|
||
if (isAppointment) eventTypePanel.hidden = true;
|
||
}
|
||
// Termin-Typ is appointment-only.
|
||
toggleFilterPair("events-filter-appointment-type", !isDeadline);
|
||
|
||
// "Nur persönliche" applies uniformly to deadlines + appointments now
|
||
// (t-paliad-128) — items where the caller is the creator. Always available.
|
||
|
||
// Update chip active state.
|
||
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
|
||
b.classList.toggle("agenda-chip-active", b.dataset.eventType === currentType);
|
||
});
|
||
|
||
// Refresh overdue card visibility on type change.
|
||
applyOverdueState(parseInt(document.getElementById("events-sum-overdue")?.textContent ?? "0", 10));
|
||
|
||
// Sidebar highlight (t-paliad-115). The bare /events HTML SSRs with
|
||
// currentPath="/events" so neither Fristen nor Termine sidebar entries
|
||
// match — we re-highlight at hydration based on the active type so the
|
||
// user always sees the correct nav entry lit. Same logic for BottomNav,
|
||
// which today doesn't carry these entries but stays consistent if it
|
||
// ever does.
|
||
applySidebarTypeHighlight();
|
||
}
|
||
|
||
function applySidebarTypeHighlight() {
|
||
const items = document.querySelectorAll<HTMLAnchorElement>(".sidebar-item");
|
||
for (const a of items) {
|
||
const href = a.getAttribute("href") ?? "";
|
||
if (href === "/events?type=deadline") {
|
||
a.classList.toggle("active", currentType === "deadline");
|
||
} else if (href === "/events?type=appointment") {
|
||
a.classList.toggle("active", currentType === "appointment");
|
||
}
|
||
}
|
||
}
|
||
|
||
function toggleDisplay(id: string, show: boolean, displayWhenShown = "block") {
|
||
const el = document.getElementById(id);
|
||
if (!el) return;
|
||
el.style.display = show ? displayWhenShown : "none";
|
||
}
|
||
|
||
// toggleFilterPair shows/hides the wrapping .filter-group so the label,
|
||
// control, and (for the multi-select) the .multi-anchor collapse together
|
||
// when a filter doesn't apply for the current type.
|
||
function toggleFilterPair(controlID: string, show: boolean) {
|
||
const ctrl = document.getElementById(controlID);
|
||
const group = ctrl?.closest<HTMLElement>(".filter-group");
|
||
if (group) group.style.display = show ? "" : "none";
|
||
}
|
||
|
||
function syncURLParams() {
|
||
const url = new URL(window.location.href);
|
||
url.searchParams.delete("type");
|
||
url.searchParams.delete("view");
|
||
url.searchParams.delete("status");
|
||
url.searchParams.delete("project_id");
|
||
url.searchParams.delete("personal_only");
|
||
url.searchParams.delete("event_type");
|
||
url.searchParams.delete("type_filter");
|
||
// Default type per route comes from window.__PALIAD_EVENTS__; only
|
||
// surface in the URL when the user opted into something else.
|
||
if (currentType !== defaultType()) url.searchParams.set("type", currentType);
|
||
if (currentView !== "cards") url.searchParams.set("view", currentView);
|
||
if (statusFilter && statusFilter !== defaultStatusFor(currentType)) {
|
||
url.searchParams.set("status", statusFilter);
|
||
}
|
||
if (projectFilter) url.searchParams.set("project_id", projectFilter);
|
||
if (currentType !== "appointment") {
|
||
const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
|
||
if (eventQuery) url.searchParams.set("event_type", eventQuery);
|
||
}
|
||
if (currentType === "appointment" && appointmentTypeFilter) {
|
||
url.searchParams.set("type_filter", appointmentTypeFilter);
|
||
}
|
||
window.history.replaceState(null, "", url.toString());
|
||
}
|
||
|
||
// populateStatusFilter rebuilds the Status `<select>` options for the
|
||
// current type. Called from applyTypeVisibility (type chip click +
|
||
// initial paint) and onLangChange (option labels are localised). When
|
||
// the previously selected status isn't valid for the new option set
|
||
// (e.g. user had `completed` selected and switches to Termine), it
|
||
// falls back to the per-type default and updates the URL params.
|
||
function populateStatusFilter() {
|
||
const sel = document.getElementById("events-filter-status") as HTMLSelectElement | null;
|
||
if (!sel) return;
|
||
const opts = statusOptionsFor(currentType);
|
||
sel.innerHTML = opts
|
||
.map((o) => `<option value="${esc(o.value)}">${esc(t(o.key))}</option>`)
|
||
.join("");
|
||
if (!opts.some((o) => o.value === statusFilter)) {
|
||
statusFilter = defaultStatusFor(currentType);
|
||
syncURLParams();
|
||
}
|
||
sel.value = statusFilter;
|
||
}
|
||
|
||
function populateProjectFilter() {
|
||
const sel = document.getElementById("events-filter-project") as HTMLSelectElement;
|
||
const options: string[] = [
|
||
`<option value="">${esc(t("deadlines.filter.akte.all"))}</option>`,
|
||
`<option value="${PERSONAL}">${esc(t("appointments.filter.akte.personal"))}</option>`,
|
||
];
|
||
for (const a of allProjects) {
|
||
const indent = projectIndent(a.path);
|
||
options.push(
|
||
`<option value="${esc(a.id)}">${indent}${esc(a.reference || "")} — ${esc(a.title)}</option>`,
|
||
);
|
||
}
|
||
sel.innerHTML = options.join("");
|
||
if (projectFilter) sel.value = projectFilter;
|
||
}
|
||
|
||
function initFilters() {
|
||
const params = urlParams();
|
||
currentType = (params.get("type") as EventTypeChoice) || defaultType();
|
||
const rawView = params.get("view");
|
||
if (rawView === "cards" || rawView === "list" || rawView === "calendar") {
|
||
currentView = rawView;
|
||
}
|
||
// Default depends on currentType: appointments default to "all" (no
|
||
// bucket filter), deadlines default to "pending". URL value wins.
|
||
if (params.has("status")) {
|
||
statusFilter = params.get("status")!;
|
||
} else {
|
||
statusFilter = defaultStatusFor(currentType);
|
||
}
|
||
if (params.has("project_id")) projectFilter = params.get("project_id")!;
|
||
// ?personal_only=true is a bookmark-friendly alias for project_id=__personal__.
|
||
// Both URLs land on the same UI state (PERSONAL sentinel in the project select).
|
||
if (params.get("personal_only") === "true") projectFilter = PERSONAL;
|
||
if (params.has("type_filter")) appointmentTypeFilter = params.get("type_filter")!;
|
||
|
||
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
|
||
const project = document.getElementById("events-filter-project") as HTMLSelectElement;
|
||
const appointmentType = document.getElementById("events-filter-appointment-type") as HTMLSelectElement;
|
||
const eventTrigger = document.getElementById("events-filter-event-type") as HTMLButtonElement;
|
||
const eventPanel = document.getElementById("events-filter-event-type-panel") as HTMLElement;
|
||
|
||
status.value = statusFilter;
|
||
appointmentType.value = appointmentTypeFilter;
|
||
|
||
let initialEventIDs: string[] = [];
|
||
let initialIncludeUntyped = false;
|
||
const initialEventRaw = params.get("event_type") ?? "";
|
||
if (initialEventRaw) {
|
||
for (const tok of initialEventRaw.split(",")) {
|
||
const tt = tok.trim();
|
||
if (!tt) continue;
|
||
if (tt === "none") initialIncludeUntyped = true;
|
||
else initialEventIDs.push(tt);
|
||
}
|
||
}
|
||
|
||
status.addEventListener("change", async () => {
|
||
statusFilter = status.value;
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
project.addEventListener("change", async () => {
|
||
projectFilter = project.value;
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
appointmentType.addEventListener("change", async () => {
|
||
appointmentTypeFilter = appointmentType.value;
|
||
syncURLParams();
|
||
await loadList();
|
||
});
|
||
|
||
if (eventTrigger && eventPanel) {
|
||
eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
|
||
initialIDs: initialEventIDs,
|
||
initialIncludeUntyped: initialIncludeUntyped,
|
||
onChange: async () => {
|
||
syncURLParams();
|
||
await loadList();
|
||
},
|
||
});
|
||
}
|
||
|
||
document.querySelectorAll<HTMLButtonElement>('[data-event-type]').forEach((b) => {
|
||
b.addEventListener("click", async () => {
|
||
const next = b.dataset.eventType as EventTypeChoice;
|
||
if (next === currentType) return;
|
||
currentType = next;
|
||
applyTypeVisibility();
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
});
|
||
}
|
||
|
||
function initView() {
|
||
// Kalender state (view + anchor) lives inside mountCalendar; no
|
||
// events-page-level wiring needed. The view chips below switch
|
||
// between Karten / Liste / Kalender; applyView() handles the
|
||
// mount + teardown.
|
||
document.querySelectorAll<HTMLButtonElement>("[data-event-view]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const next = btn.dataset.eventView as EventView;
|
||
if (next === currentView) return;
|
||
currentView = next;
|
||
applyView();
|
||
syncURLParams();
|
||
});
|
||
});
|
||
}
|
||
|
||
function initSummaryCards() {
|
||
document.querySelectorAll<HTMLButtonElement>(".frist-summary-card").forEach((card) => {
|
||
card.addEventListener("click", async () => {
|
||
const bucket = card.dataset.bucket!;
|
||
// Map the card to the matching status filter; the events endpoint
|
||
// handles bucket-aware appointment filtering internally so both
|
||
// rails reflect the click consistently in Beides mode.
|
||
statusFilter = bucket;
|
||
const status = document.getElementById("events-filter-status") as HTMLSelectElement;
|
||
if (status) status.value = bucket;
|
||
syncURLParams();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|
||
});
|
||
}
|
||
|
||
document.addEventListener("DOMContentLoaded", async () => {
|
||
initI18n();
|
||
initSidebar();
|
||
initFilters();
|
||
initView();
|
||
initSummaryCards();
|
||
applyTypeVisibility();
|
||
applyView();
|
||
onLangChange(() => {
|
||
// The static [data-i18n] options are retranslated by initI18n's
|
||
// applyTranslations(), but the project select is rebuilt at runtime
|
||
// and its "Alle Projekte" / "Nur persönliche" labels come from t() —
|
||
// re-run the populator so they pick up the new locale.
|
||
populateProjectFilter();
|
||
applyTypeVisibility();
|
||
render();
|
||
});
|
||
await Promise.all([loadProjects(), loadMe(), loadEventTypes()]);
|
||
populateProjectFilter();
|
||
await Promise.all([loadList(), loadSummary()]);
|
||
});
|