diff --git a/frontend/src/agenda.tsx b/frontend/src/agenda.tsx
index 1e54356..f1a7824 100644
--- a/frontend/src/agenda.tsx
+++ b/frontend/src/agenda.tsx
@@ -66,6 +66,12 @@ export function renderAgenda(): string {
+
+
diff --git a/frontend/src/client/agenda.ts b/frontend/src/client/agenda.ts
index b830659..180244e 100644
--- a/frontend/src/client/agenda.ts
+++ b/frontend/src/client/agenda.ts
@@ -1,5 +1,8 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
+import { attachEventTypeMultiSelectFilter, type FilterHandle } from "./event-types";
+
+let eventTypeFilter: FilterHandle | null = null;
type Urgency = "overdue" | "today" | "tomorrow" | "this_week" | "later";
type AgendaType = "deadline" | "appointment";
@@ -117,12 +120,40 @@ function wireControls(): void {
});
});
syncChips();
+
+ const eventTrigger = document.getElementById("agenda-filter-event-type") as HTMLButtonElement | null;
+ const eventPanel = document.getElementById("agenda-filter-event-type-panel") as HTMLElement | null;
+ if (eventTrigger && eventPanel) {
+ const q = new URLSearchParams(window.location.search);
+ const initialEventIDs: string[] = [];
+ let initialIncludeUntyped = false;
+ const raw = q.get("event_type") ?? "";
+ if (raw) {
+ for (const tok of raw.split(",")) {
+ const t = tok.trim();
+ if (!t) continue;
+ if (t === "none") initialIncludeUntyped = true;
+ else initialEventIDs.push(t);
+ }
+ }
+ eventTypeFilter = attachEventTypeMultiSelectFilter(eventTrigger, eventPanel, {
+ initialIDs: initialEventIDs,
+ initialIncludeUntyped,
+ onChange: () => {
+ pushURL();
+ void refetch();
+ },
+ });
+ }
}
function pushURL(): void {
const q = new URLSearchParams(window.location.search);
q.set("range", String(state.rangeDays));
q.set("types", typesParam(state.type));
+ const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
+ if (eventQuery) q.set("event_type", eventQuery);
+ else q.delete("event_type");
history.replaceState(null, "", `${window.location.pathname}?${q.toString()}`);
}
@@ -143,7 +174,9 @@ async function refetch(): Promise
{
const from = toISODate(startOfToday());
const to = toISODate(addDays(startOfToday(), state.rangeDays - 1));
- const url = `/api/agenda?from=${from}&to=${to}&types=${typesParam(state.type)}`;
+ const eventQuery = eventTypeFilter?.toQueryValue() ?? "";
+ const eventParam = eventQuery ? `&event_type=${encodeURIComponent(eventQuery)}` : "";
+ const url = `/api/agenda?from=${from}&to=${to}&types=${typesParam(state.type)}${eventParam}`;
try {
const resp = await fetch(url);
if (resp.status === 503) {
diff --git a/frontend/src/client/deadlines-detail.ts b/frontend/src/client/deadlines-detail.ts
index 2d409b5..4e8cdd6 100644
--- a/frontend/src/client/deadlines-detail.ts
+++ b/frontend/src/client/deadlines-detail.ts
@@ -1,6 +1,13 @@
import { initI18n, onLangChange, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
+import {
+ attachEventTypePicker,
+ fetchEventTypes,
+ eventTypeLabel,
+ type EventType,
+ type PickerHandle,
+} from "./event-types";
interface Deadline {
id: string;
@@ -14,8 +21,12 @@ interface Deadline {
notes?: string;
created_at: string;
completed_at?: string;
+ event_type_ids?: string[];
}
+let eventTypePicker: PickerHandle | null = null;
+let eventTypeByID: Map = new Map();
+
interface Project {
id: string;
reference?: string | null;
@@ -169,6 +180,28 @@ function render() {
(document.getElementById("deadline-notes-display") as HTMLElement).textContent = deadline.notes || "—";
(document.getElementById("deadline-notes-edit") as HTMLTextAreaElement).value = deadline.notes || "";
+ // Event-Type display & picker (display always, picker only in edit mode).
+ const etDisplay = document.getElementById("deadline-event-types-display");
+ if (etDisplay) {
+ const ids = deadline.event_type_ids ?? [];
+ if (ids.length === 0) {
+ etDisplay.innerHTML = "—";
+ } else {
+ etDisplay.innerHTML = ids
+ .map((id) => {
+ const et = eventTypeByID.get(id);
+ if (!et) return "";
+ return `${esc(eventTypeLabel(et))}`;
+ })
+ .filter(Boolean)
+ .join(" ");
+ if (etDisplay.innerHTML === "") etDisplay.innerHTML = "—";
+ }
+ }
+ if (eventTypePicker) {
+ eventTypePicker.setIDs(deadline.event_type_ids ?? []);
+ }
+
(document.getElementById("deadline-created-display") as HTMLElement).textContent = fmtDateTime(deadline.created_at);
const completedLabel = document.getElementById("deadline-completed-row-label")!;
@@ -221,6 +254,8 @@ function initEdit() {
const notesEdit = document.getElementById("deadline-notes-edit") as HTMLTextAreaElement;
const editBtn = document.getElementById("deadline-edit-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("deadline-save-btn") as HTMLButtonElement;
+ const etDisplay = document.getElementById("deadline-event-types-display");
+ const etEdit = document.getElementById("deadline-event-types-edit");
function enterEdit() {
titleDisplay.style.display = "none";
@@ -229,6 +264,8 @@ function initEdit() {
dueEdit.style.display = "";
notesDisplay.style.display = "none";
notesEdit.style.display = "";
+ if (etDisplay) etDisplay.style.display = "none";
+ if (etEdit) etEdit.style.display = "";
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -241,6 +278,8 @@ function initEdit() {
dueEdit.style.display = "none";
notesDisplay.style.display = "";
notesEdit.style.display = "none";
+ if (etDisplay) etDisplay.style.display = "";
+ if (etEdit) etEdit.style.display = "none";
saveBtn.style.display = "none";
editBtn.style.display = "";
}
@@ -255,10 +294,18 @@ function initEdit() {
if (!newTitle || !newDue) return;
saveBtn.disabled = true;
try {
+ const payload: Record = {
+ title: newTitle,
+ due_date: newDue,
+ notes: newNotes,
+ };
+ if (eventTypePicker) {
+ payload.event_type_ids = eventTypePicker.getIDs();
+ }
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ title: newTitle, due_date: newDue, notes: newNotes }),
+ body: JSON.stringify(payload),
});
if (resp.ok) {
deadline = await resp.json();
@@ -361,8 +408,28 @@ async function main() {
await loadProject(deadline.project_id);
if (deadline.rule_id) await loadRule(deadline.rule_id);
+ // Load event types in parallel; render once ready (the picker re-renders
+ // chips off the cached map, and the display element re-renders on the
+ // next render() call after data lands).
+ try {
+ const types = await fetchEventTypes();
+ eventTypeByID = new Map(types.map((et) => [et.id, et]));
+ } catch {
+ /* non-fatal */
+ }
+
loading.style.display = "none";
body.style.display = "";
+
+ // Mount the picker (hidden until enterEdit()).
+ const pickerHost = document.getElementById("deadline-event-types-edit");
+ if (pickerHost) {
+ eventTypePicker = attachEventTypePicker(pickerHost, {
+ initialIDs: deadline.event_type_ids ?? [],
+ currentUserAdmin: me?.global_role === "global_admin",
+ });
+ }
+
render();
initEdit();
initComplete();
diff --git a/frontend/src/client/deadlines-new.ts b/frontend/src/client/deadlines-new.ts
index cba71ac..efa1e8e 100644
--- a/frontend/src/client/deadlines-new.ts
+++ b/frontend/src/client/deadlines-new.ts
@@ -1,5 +1,9 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
+import { attachEventTypePicker, type PickerHandle } from "./event-types";
+
+let eventTypePicker: PickerHandle | null = null;
+let currentUserAdmin = false;
interface Project {
id: string;
@@ -114,6 +118,8 @@ async function submitForm(e: Event) {
};
if (ruleID) payload.rule_id = ruleID;
if (notes) payload.notes = notes;
+ const eventTypeIDs = eventTypePicker?.getIDs() ?? [];
+ if (eventTypeIDs.length > 0) payload.event_type_ids = eventTypeIDs;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(projectID)}/deadlines`, {
@@ -151,6 +157,17 @@ function detectPreselect() {
if (fromQuery) preselectedProjectID = fromQuery;
}
+async function loadMe() {
+ try {
+ const resp = await fetch("/api/me");
+ if (!resp.ok) return;
+ const me = await resp.json();
+ currentUserAdmin = me?.global_role === "global_admin";
+ } catch {
+ /* non-fatal */
+ }
+}
+
document.addEventListener("DOMContentLoaded", async () => {
initI18n();
initSidebar();
@@ -160,5 +177,11 @@ document.addEventListener("DOMContentLoaded", async () => {
// Default due to today
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
- await Promise.all([loadProjects(), loadRules()]);
+ await Promise.all([loadProjects(), loadRules(), loadMe()]);
+ const pickerHost = document.getElementById("deadline-event-types");
+ if (pickerHost) {
+ eventTypePicker = attachEventTypePicker(pickerHost, {
+ currentUserAdmin,
+ });
+ }
});
diff --git a/frontend/src/client/deadlines.ts b/frontend/src/client/deadlines.ts
index 4f8410d..1046638 100644
--- a/frontend/src/client/deadlines.ts
+++ b/frontend/src/client/deadlines.ts
@@ -1,5 +1,12 @@
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;
@@ -14,8 +21,12 @@ interface Deadline {
rule_code?: string;
rule_name?: string;
rule_name_en?: string;
+ event_type_ids?: string[];
}
+let eventTypeFilter: FilterHandle | null = null;
+let eventTypeByID: Map = new Map();
+
interface Project {
id: string;
reference?: string | null;
@@ -101,6 +112,8 @@ async function loadDeadlines() {
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";
@@ -162,6 +175,18 @@ function ruleDisplay(f: Deadline): string {
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) => `${esc(l)}`).join(" ");
+}
+
function render() {
if (!loadedOK) return;
const tbody = document.getElementById("deadlines-body")!;
@@ -209,6 +234,7 @@ function render() {
${esc(f.project_title)}
${ruleLabel} |
+ ${eventTypeDisplay(f)} |
${esc(statusLabel)} |
`;
})
@@ -221,6 +247,11 @@ function render() {
const table = document.getElementById("deadlines-table");
table?.classList.toggle("akten-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("akten-table--hide-event-type", !anyEventType);
+
tbody.querySelectorAll(".frist-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
@@ -273,20 +304,59 @@ function render() {
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() {
@@ -314,13 +384,22 @@ function initSummaryCards() {
});
}
+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()]);
+ await Promise.all([loadProjects(), loadMe(), loadEventTypes()]);
populateProjectFilter();
await Promise.all([loadDeadlines(), loadSummary()]);
});
diff --git a/frontend/src/client/event-types.ts b/frontend/src/client/event-types.ts
new file mode 100644
index 0000000..1a8f784
--- /dev/null
+++ b/frontend/src/client/event-types.ts
@@ -0,0 +1,634 @@
+// t-paliad-088: Event Types — shared client module.
+//
+// Three surfaces share this module:
+// 1. EventTypePicker — multi-tag chip cluster on /deadlines/new and the
+// /deadlines/{id} edit form. Lets the user pick 0..N event types.
+// 2. EventTypeMultiSelectFilter — listbox-panel filter on /deadlines and
+// /agenda. Multi-select with search + "Alle" + "Ohne Typ" specials.
+// 3. AddEventTypeModal — opened from inside both surfaces via a
+// "+ Neuen Typ hinzufügen…" affordance. Any authenticated user may
+// publish firm-wide types (per m's Q6); admins moderate via archive.
+//
+// Backend contract: see internal/handlers/event_types.go and
+// internal/services/event_type_service.go.
+
+import { t, tDyn, getLang } from "./i18n";
+
+export interface EventType {
+ id: string;
+ slug: string;
+ label_de: string;
+ label_en: string;
+ category: string;
+ jurisdiction?: string | null;
+ description: string;
+ trigger_event_id?: number | null;
+ created_by?: string | null;
+ is_firm_wide: boolean;
+ archived_at?: string | null;
+ created_at: string;
+ updated_at: string;
+}
+
+export const CATEGORY_ORDER = [
+ "submission",
+ "decision",
+ "order",
+ "service",
+ "fee",
+ "hearing",
+ "other",
+] as const;
+
+export type Category = (typeof CATEGORY_ORDER)[number];
+
+export function eventTypeLabel(et: EventType): string {
+ const lang = getLang();
+ const primary = lang === "en" ? et.label_en : et.label_de;
+ return primary?.trim() || et.label_en || et.label_de || et.slug;
+}
+
+export function categoryLabel(category: string): string {
+ return tDyn(`event_types.cat.${category}`) || category;
+}
+
+let cache: EventType[] | null = null;
+let cachePromise: Promise | null = null;
+
+export async function fetchEventTypes(force = false): Promise {
+ if (!force && cache) return cache;
+ if (!force && cachePromise) return cachePromise;
+ cachePromise = (async () => {
+ const resp = await fetch("/api/event-types");
+ if (!resp.ok) {
+ cachePromise = null;
+ return [];
+ }
+ cache = (await resp.json()) as EventType[];
+ cachePromise = null;
+ return cache;
+ })();
+ return cachePromise;
+}
+
+export function invalidateEventTypeCache() {
+ cache = null;
+ cachePromise = null;
+}
+
+function esc(s: string): string {
+ const d = document.createElement("div");
+ d.textContent = s;
+ return d.innerHTML;
+}
+
+function groupByCategory(types: EventType[]): Map {
+ const m = new Map();
+ for (const cat of CATEGORY_ORDER) m.set(cat, []);
+ for (const et of types) {
+ const list = m.get(et.category) ?? [];
+ list.push(et);
+ m.set(et.category, list);
+ }
+ // Sort each bucket by label.
+ for (const [k, v] of m) {
+ v.sort((a, b) => eventTypeLabel(a).localeCompare(eventTypeLabel(b)));
+ m.set(k, v);
+ }
+ return m;
+}
+
+// ============================================================================
+// Picker (multi-tag chip cluster) — used on deadline create/edit
+// ============================================================================
+
+interface PickerOptions {
+ initialIDs?: string[];
+ onChange?: (ids: string[]) => void;
+ currentUserAdmin?: boolean;
+}
+
+export interface PickerHandle {
+ getIDs(): string[];
+ setIDs(ids: string[]): void;
+ refresh(): Promise;
+}
+
+export function attachEventTypePicker(container: HTMLElement, opts: PickerOptions): PickerHandle {
+ let selected = new Set(opts.initialIDs ?? []);
+ let allTypes: EventType[] = [];
+
+ container.classList.add("event-type-picker");
+ container.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ const chipsEl = container.querySelector("[data-role=chips]")!;
+ const searchEl = container.querySelector("[data-role=search]")!;
+ const suggestEl = container.querySelector("[data-role=suggest]")!;
+ const addBtn = container.querySelector("[data-role=add]")!;
+
+ function notify() {
+ opts.onChange?.(Array.from(selected));
+ }
+
+ function renderChips() {
+ const byID = new Map(allTypes.map((et) => [et.id, et]));
+ chipsEl.innerHTML = Array.from(selected)
+ .map((id) => {
+ const et = byID.get(id);
+ if (!et) return "";
+ return `
+ ${esc(eventTypeLabel(et))}
+
+ `;
+ })
+ .join("");
+ chipsEl.querySelectorAll(".event-type-chip-remove").forEach((btn) => {
+ btn.addEventListener("click", () => {
+ const id = btn.parentElement?.dataset.id;
+ if (id) {
+ selected.delete(id);
+ renderChips();
+ notify();
+ }
+ });
+ });
+ }
+
+ function renderSuggest(query: string) {
+ const q = query.trim().toLowerCase();
+ if (q.length < 1) {
+ suggestEl.hidden = true;
+ suggestEl.innerHTML = "";
+ return;
+ }
+ const matches = allTypes
+ .filter((et) => !selected.has(et.id))
+ .filter((et) => {
+ const lde = et.label_de.toLowerCase();
+ const len = et.label_en.toLowerCase();
+ return lde.includes(q) || len.includes(q);
+ })
+ .slice(0, 12);
+ if (matches.length === 0) {
+ suggestEl.hidden = false;
+ suggestEl.innerHTML = `${esc(t("event_types.picker.no_match"))}
`;
+ return;
+ }
+ suggestEl.hidden = false;
+ suggestEl.innerHTML = matches
+ .map(
+ (et) => ``,
+ )
+ .join("");
+ suggestEl.querySelectorAll(".event-type-suggest-row").forEach((row) => {
+ row.addEventListener("click", () => {
+ const id = row.dataset.id!;
+ selected.add(id);
+ searchEl.value = "";
+ renderSuggest("");
+ renderChips();
+ notify();
+ searchEl.focus();
+ });
+ });
+ }
+
+ searchEl.addEventListener("input", () => renderSuggest(searchEl.value));
+ searchEl.addEventListener("focus", () => renderSuggest(searchEl.value));
+ searchEl.addEventListener("blur", () => {
+ // Delay so the click lands first.
+ setTimeout(() => {
+ suggestEl.hidden = true;
+ }, 200);
+ });
+ addBtn.addEventListener("click", async () => {
+ const created = await openAddEventTypeModal({
+ prefillLabel: searchEl.value.trim(),
+ isAdmin: !!opts.currentUserAdmin,
+ });
+ if (created) {
+ await fetchEventTypes(true);
+ allTypes = (await fetchEventTypes()) ?? [];
+ selected.add(created.id);
+ searchEl.value = "";
+ renderSuggest("");
+ renderChips();
+ notify();
+ }
+ });
+
+ const handle: PickerHandle = {
+ getIDs: () => Array.from(selected),
+ setIDs: (ids) => {
+ selected = new Set(ids);
+ renderChips();
+ },
+ refresh: async () => {
+ invalidateEventTypeCache();
+ allTypes = await fetchEventTypes(true);
+ renderChips();
+ },
+ };
+
+ void (async () => {
+ allTypes = await fetchEventTypes();
+ renderChips();
+ })();
+
+ return handle;
+}
+
+// ============================================================================
+// Multi-select filter (listbox panel) — used on /deadlines + /agenda
+// ============================================================================
+
+interface FilterOptions {
+ initialIDs?: string[];
+ initialIncludeUntyped?: boolean;
+ onChange?: (ids: string[], includeUntyped: boolean) => void;
+}
+
+export interface FilterHandle {
+ getIDs(): string[];
+ getIncludeUntyped(): boolean;
+ setSelection(ids: string[], includeUntyped: boolean): void;
+ refresh(): Promise;
+ /** Serialise to the `?event_type=` query-param value (or "" when "Alle"). */
+ toQueryValue(): string;
+}
+
+export function attachEventTypeMultiSelectFilter(
+ trigger: HTMLButtonElement,
+ panel: HTMLElement,
+ opts: FilterOptions = {},
+): FilterHandle {
+ let selected = new Set(opts.initialIDs ?? []);
+ let includeUntyped = !!opts.initialIncludeUntyped;
+ let allTypes: EventType[] = [];
+ let searchQuery = "";
+
+ trigger.classList.add("akten-multi-trigger");
+ trigger.setAttribute("aria-haspopup", "listbox");
+ trigger.setAttribute("aria-expanded", "false");
+ trigger.innerHTML = `
+
+ ▾
+ `;
+ panel.classList.add("akten-multi-panel");
+ panel.hidden = true;
+
+ function updateLabel() {
+ const labelEl = trigger.querySelector("[data-role=label]")!;
+ const total = selected.size + (includeUntyped ? 1 : 0);
+ if (total === 0) {
+ labelEl.textContent = t("event_types.filter.all");
+ } else if (total === 1 && includeUntyped) {
+ labelEl.textContent = t("event_types.filter.untyped");
+ } else if (total === 1 && selected.size === 1) {
+ const id = Array.from(selected)[0];
+ const et = allTypes.find((x) => x.id === id);
+ labelEl.textContent = et ? eventTypeLabel(et) : t("event_types.filter.n_selected").replace("{n}", "1");
+ } else {
+ labelEl.textContent = t("event_types.filter.n_selected").replace("{n}", String(total));
+ }
+ }
+
+ function renderPanel() {
+ const groups = groupByCategory(allTypes);
+ const q = searchQuery.trim().toLowerCase();
+ const matches = (et: EventType) => {
+ if (!q) return true;
+ return (
+ et.label_de.toLowerCase().includes(q) ||
+ et.label_en.toLowerCase().includes(q) ||
+ et.slug.toLowerCase().includes(q)
+ );
+ };
+ const renderGroup = (cat: string) => {
+ const list = (groups.get(cat) ?? []).filter(matches);
+ if (list.length === 0) return "";
+ return `
+
${esc(categoryLabel(cat))}
+ ${list
+ .map(
+ (et) => `
`,
+ )
+ .join("")}
+
`;
+ };
+ panel.innerHTML = `
+
+
+
+
+
+
+
+
+ ${CATEGORY_ORDER.map(renderGroup).join("")}
+
+
+
+
+
+ `;
+
+ const searchInput = panel.querySelector("[data-role=search]")!;
+ searchInput.addEventListener("input", () => {
+ searchQuery = searchInput.value;
+ renderPanel();
+ // re-focus the search after re-render
+ const fresh = panel.querySelector("[data-role=search]")!;
+ fresh.focus();
+ fresh.setSelectionRange(searchInput.selectionStart ?? 0, searchInput.selectionEnd ?? 0);
+ });
+
+ const allCb = panel.querySelector("[data-role=all]")!;
+ allCb.addEventListener("change", () => {
+ if (allCb.checked) {
+ selected.clear();
+ includeUntyped = false;
+ renderPanel();
+ updateLabel();
+ opts.onChange?.([], false);
+ } else {
+ // Re-tick "Alle" if user tried to uncheck it without ticking anything
+ // else — a "no filter" state with everything off doesn't exist.
+ allCb.checked = true;
+ }
+ });
+
+ const untypedCb = panel.querySelector("[data-role=untyped]")!;
+ untypedCb.addEventListener("change", () => {
+ includeUntyped = untypedCb.checked;
+ renderPanel();
+ updateLabel();
+ opts.onChange?.(Array.from(selected), includeUntyped);
+ });
+
+ panel.querySelectorAll(".akten-multi-list input[type=checkbox]").forEach((cb) => {
+ cb.addEventListener("change", () => {
+ const id = cb.dataset.id!;
+ if (cb.checked) selected.add(id);
+ else selected.delete(id);
+ renderPanel();
+ updateLabel();
+ opts.onChange?.(Array.from(selected), includeUntyped);
+ });
+ });
+
+ panel.querySelector("[data-role=reset]")!.addEventListener("click", () => {
+ selected.clear();
+ includeUntyped = false;
+ searchQuery = "";
+ renderPanel();
+ updateLabel();
+ opts.onChange?.([], false);
+ });
+ panel.querySelector("[data-role=close]")!.addEventListener("click", () => {
+ closePanel();
+ });
+ }
+
+ function openPanel() {
+ panel.hidden = false;
+ trigger.setAttribute("aria-expanded", "true");
+ renderPanel();
+ setTimeout(() => {
+ const search = panel.querySelector("[data-role=search]");
+ search?.focus();
+ }, 0);
+ }
+ function closePanel() {
+ panel.hidden = true;
+ trigger.setAttribute("aria-expanded", "false");
+ }
+
+ trigger.addEventListener("click", () => {
+ if (panel.hidden) openPanel();
+ else closePanel();
+ });
+
+ document.addEventListener("click", (e) => {
+ const target = e.target as Node;
+ if (panel.hidden) return;
+ if (panel.contains(target) || trigger.contains(target)) return;
+ closePanel();
+ });
+ document.addEventListener("keydown", (e) => {
+ if (e.key === "Escape" && !panel.hidden) closePanel();
+ });
+
+ const handle: FilterHandle = {
+ getIDs: () => Array.from(selected),
+ getIncludeUntyped: () => includeUntyped,
+ setSelection: (ids, untyped) => {
+ selected = new Set(ids);
+ includeUntyped = untyped;
+ updateLabel();
+ if (!panel.hidden) renderPanel();
+ },
+ refresh: async () => {
+ invalidateEventTypeCache();
+ allTypes = await fetchEventTypes(true);
+ updateLabel();
+ if (!panel.hidden) renderPanel();
+ },
+ toQueryValue: () => {
+ const tokens: string[] = [];
+ for (const id of selected) tokens.push(id);
+ if (includeUntyped) tokens.push("none");
+ return tokens.join(",");
+ },
+ };
+
+ void (async () => {
+ allTypes = await fetchEventTypes();
+ updateLabel();
+ })();
+
+ return handle;
+}
+
+// ============================================================================
+// Add modal
+// ============================================================================
+
+interface AddModalOptions {
+ prefillLabel?: string;
+ isAdmin: boolean;
+}
+
+export function openAddEventTypeModal(opts: AddModalOptions): Promise {
+ return new Promise((resolve) => {
+ const overlay = document.createElement("div");
+ overlay.className = "modal-overlay event-type-modal-overlay";
+ overlay.innerHTML = `
+
+
${esc(t("event_types.add.title"))}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+ document.body.appendChild(overlay);
+
+ const labelDE = overlay.querySelector("#event-type-add-label-de")!;
+ const labelEN = overlay.querySelector("#event-type-add-label-en")!;
+ const catSel = overlay.querySelector("#event-type-add-category")!;
+ const jurSel = overlay.querySelector("#event-type-add-jurisdiction")!;
+ const firmWide = overlay.querySelector("#event-type-add-firm-wide")!;
+ const msg = overlay.querySelector("#event-type-add-msg")!;
+ const warnEl = overlay.querySelector("[data-role=warn]")!;
+ const cancelBtn = overlay.querySelector("[data-role=cancel]")!;
+ const submitBtn = overlay.querySelector("[data-role=submit]")!;
+
+ let suggestTimer: number | null = null;
+ function checkDuplicates(query: string) {
+ if (suggestTimer) window.clearTimeout(suggestTimer);
+ const q = query.trim();
+ if (q.length < 2) {
+ warnEl.hidden = true;
+ warnEl.innerHTML = "";
+ return;
+ }
+ suggestTimer = window.setTimeout(async () => {
+ try {
+ const resp = await fetch(`/api/event-types/suggest?q=${encodeURIComponent(q)}`);
+ if (!resp.ok) return;
+ const matches = (await resp.json()) as EventType[];
+ if (matches.length === 0) {
+ warnEl.hidden = true;
+ warnEl.innerHTML = "";
+ return;
+ }
+ warnEl.hidden = false;
+ warnEl.innerHTML = `${esc(t("event_types.add.duplicate_warn"))} ` +
+ matches.map((m) => `${esc(eventTypeLabel(m))}`).join(" ");
+ } catch {
+ /* non-fatal */
+ }
+ }, 250);
+ }
+ labelDE.addEventListener("input", () => checkDuplicates(labelDE.value));
+
+ function close(value: EventType | null) {
+ overlay.remove();
+ resolve(value);
+ }
+
+ cancelBtn.addEventListener("click", () => close(null));
+ overlay.addEventListener("click", (e) => {
+ if (e.target === overlay) close(null);
+ });
+ document.addEventListener(
+ "keydown",
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") {
+ document.removeEventListener("keydown", onKey);
+ close(null);
+ }
+ },
+ );
+
+ submitBtn.addEventListener("click", async () => {
+ const labelDEv = labelDE.value.trim();
+ if (!labelDEv) {
+ msg.textContent = t("event_types.add.error.required");
+ msg.className = "form-msg form-msg-error";
+ return;
+ }
+ submitBtn.disabled = true;
+ try {
+ const payload: Record = {
+ label_de: labelDEv,
+ category: catSel.value,
+ is_firm_wide: opts.isAdmin ? firmWide.checked : false,
+ };
+ if (labelEN.value.trim()) payload.label_en = labelEN.value.trim();
+ if (jurSel.value) payload.jurisdiction = jurSel.value;
+ const resp = await fetch("/api/event-types", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(payload),
+ });
+ if (resp.status === 409) {
+ msg.textContent = t("event_types.add.error.slug_taken");
+ msg.className = "form-msg form-msg-error";
+ submitBtn.disabled = false;
+ return;
+ }
+ if (!resp.ok) {
+ const data = await resp.json().catch(() => ({}) as { error?: string });
+ msg.textContent = data.error || t("event_types.add.error.generic");
+ msg.className = "form-msg form-msg-error";
+ submitBtn.disabled = false;
+ return;
+ }
+ const created = (await resp.json()) as EventType;
+ invalidateEventTypeCache();
+ close(created);
+ } catch {
+ msg.textContent = t("event_types.add.error.generic");
+ msg.className = "form-msg form-msg-error";
+ submitBtn.disabled = false;
+ }
+ });
+ });
+}
diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts
index ba6f482..effd438 100644
--- a/frontend/src/client/i18n.ts
+++ b/frontend/src/client/i18n.ts
@@ -1378,6 +1378,44 @@ const translations: Record> = {
"notfound.heading": "Seite nicht gefunden",
"notfound.lede": "Diese Seite existiert nicht oder wurde verschoben.",
"notfound.cta": "Zurück zum Dashboard",
+
+ // t-paliad-088: Event Types — picker, multi-select filter, add modal.
+ "common.cancel": "Abbrechen",
+ "event_types.cat.submission": "Eingaben",
+ "event_types.cat.decision": "Entscheidungen",
+ "event_types.cat.order": "Anordnungen",
+ "event_types.cat.service": "Zustellungen",
+ "event_types.cat.fee": "Gebühren",
+ "event_types.cat.hearing": "Verhandlungen",
+ "event_types.cat.other": "Sonstiges",
+ "event_types.picker.search": "Suchen oder tippen…",
+ "event_types.picker.add": "+ Neuen Typ hinzufügen…",
+ "event_types.picker.remove": "Entfernen",
+ "event_types.picker.no_match": "Keine Treffer.",
+ "event_types.filter.all": "Alle Typen",
+ "event_types.filter.untyped": "— Ohne Typ —",
+ "event_types.filter.search": "Typ suchen…",
+ "event_types.filter.reset": "Zurücksetzen",
+ "event_types.filter.apply": "Anwenden",
+ "event_types.filter.n_selected": "{n} Typen",
+ "event_types.add.title": "Neuen Event-Typ anlegen",
+ "event_types.add.label_de": "Bezeichnung (DE) *",
+ "event_types.add.label_en": "Bezeichnung (EN, optional)",
+ "event_types.add.category": "Kategorie *",
+ "event_types.add.jurisdiction": "Jurisdiktion (optional)",
+ "event_types.add.jurisdiction.none": "—",
+ "event_types.add.jurisdiction.any": "Allgemein",
+ "event_types.add.firm_wide": "Firmenweit verfügbar machen",
+ "event_types.add.firm_wide.hint": "Firmenweite Typen sind für alle Kolleg:innen sichtbar. Admins können sie archivieren.",
+ "event_types.add.submit": "Anlegen",
+ "event_types.add.duplicate_warn": "Existiert vermutlich schon:",
+ "event_types.add.error.required": "Bezeichnung (DE) ist Pflichtfeld.",
+ "event_types.add.error.slug_taken": "Ein Typ mit diesem Namen existiert bereits.",
+ "event_types.add.error.generic": "Fehler beim Anlegen. Bitte erneut versuchen.",
+ "deadlines.field.event_type": "Typ (optional)",
+ "deadlines.col.event_type": "Typ",
+ "deadlines.filter.event_type": "Typ",
+ "agenda.filter.event_type": "Typ",
},
en: {
@@ -2735,6 +2773,44 @@ const translations: Record> = {
"notfound.heading": "Page not found",
"notfound.lede": "This page doesn't exist or has been moved.",
"notfound.cta": "Back to dashboard",
+
+ // t-paliad-088: Event Types — picker, multi-select filter, add modal.
+ "common.cancel": "Cancel",
+ "event_types.cat.submission": "Submissions",
+ "event_types.cat.decision": "Decisions",
+ "event_types.cat.order": "Orders",
+ "event_types.cat.service": "Service",
+ "event_types.cat.fee": "Fees",
+ "event_types.cat.hearing": "Hearings",
+ "event_types.cat.other": "Other",
+ "event_types.picker.search": "Search or type…",
+ "event_types.picker.add": "+ Add new type…",
+ "event_types.picker.remove": "Remove",
+ "event_types.picker.no_match": "No matches.",
+ "event_types.filter.all": "All types",
+ "event_types.filter.untyped": "— Untyped —",
+ "event_types.filter.search": "Search type…",
+ "event_types.filter.reset": "Reset",
+ "event_types.filter.apply": "Apply",
+ "event_types.filter.n_selected": "{n} types",
+ "event_types.add.title": "Create new event type",
+ "event_types.add.label_de": "Label (DE) *",
+ "event_types.add.label_en": "Label (EN, optional)",
+ "event_types.add.category": "Category *",
+ "event_types.add.jurisdiction": "Jurisdiction (optional)",
+ "event_types.add.jurisdiction.none": "—",
+ "event_types.add.jurisdiction.any": "Any",
+ "event_types.add.firm_wide": "Make firm-wide",
+ "event_types.add.firm_wide.hint": "Firm-wide types are visible to all colleagues. Admins can archive them.",
+ "event_types.add.submit": "Create",
+ "event_types.add.duplicate_warn": "Probably already exists:",
+ "event_types.add.error.required": "Label (DE) is required.",
+ "event_types.add.error.slug_taken": "A type with this name already exists.",
+ "event_types.add.error.generic": "Could not create. Please try again.",
+ "deadlines.field.event_type": "Type (optional)",
+ "deadlines.col.event_type": "Type",
+ "deadlines.filter.event_type": "Type",
+ "agenda.filter.event_type": "Type",
},
};
diff --git a/frontend/src/deadlines-detail.tsx b/frontend/src/deadlines-detail.tsx
index 9fd6186..2cb10d3 100644
--- a/frontend/src/deadlines-detail.tsx
+++ b/frontend/src/deadlines-detail.tsx
@@ -77,6 +77,12 @@ export function renderDeadlinesDetail(): string {
Regel
—
+ Typ (optional)
+
+ —
+
+
+
Quelle
diff --git a/frontend/src/deadlines-new.tsx b/frontend/src/deadlines-new.tsx
index d6fb4c3..6514946 100644
--- a/frontend/src/deadlines-new.tsx
+++ b/frontend/src/deadlines-new.tsx
@@ -54,6 +54,11 @@ export function renderDeadlinesNew(): string {
/>
+