Merge: t-paliad-088 PR-2 — Event Types frontend (picker, multi-select filter, add modal)
This commit is contained in:
@@ -66,6 +66,12 @@ export function renderAgenda(): string {
|
||||
<button type="button" className="agenda-chip" data-range="90" data-i18n="agenda.range.90">90 Tage</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agenda-filter-group">
|
||||
<label className="agenda-filter-label" htmlFor="agenda-filter-event-type" data-i18n="agenda.filter.event_type">Typ</label>
|
||||
<button type="button" id="agenda-filter-event-type" className="akten-select akten-multi-trigger" aria-haspopup="listbox" />
|
||||
<div id="agenda-filter-event-type-panel" className="akten-multi-panel" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="agenda-loading" id="agenda-loading" style="display:none" data-i18n="agenda.loading">
|
||||
|
||||
@@ -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<void> {
|
||||
|
||||
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) {
|
||||
|
||||
@@ -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<string, EventType> = 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 `<span class="akten-event-type-pill">${esc(eventTypeLabel(et))}</span>`;
|
||||
})
|
||||
.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<string, unknown> = {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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<string, EventType> = 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) => `<span class="akten-event-type-pill">${esc(l)}</span>`).join(" ");
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
const tbody = document.getElementById("deadlines-body")!;
|
||||
@@ -209,6 +234,7 @@ function render() {
|
||||
<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="akten-col-event-type">${eventTypeDisplay(f)}</td>
|
||||
<td class="akten-col-status"><span class="akten-status-chip akten-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
|
||||
</tr>`;
|
||||
})
|
||||
@@ -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<HTMLTableRowElement>(".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()]);
|
||||
});
|
||||
|
||||
634
frontend/src/client/event-types.ts
Normal file
634
frontend/src/client/event-types.ts
Normal file
@@ -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<EventType[]> | null = null;
|
||||
|
||||
export async function fetchEventTypes(force = false): Promise<EventType[]> {
|
||||
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<string, EventType[]> {
|
||||
const m = new Map<string, EventType[]>();
|
||||
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<void>;
|
||||
}
|
||||
|
||||
export function attachEventTypePicker(container: HTMLElement, opts: PickerOptions): PickerHandle {
|
||||
let selected = new Set<string>(opts.initialIDs ?? []);
|
||||
let allTypes: EventType[] = [];
|
||||
|
||||
container.classList.add("event-type-picker");
|
||||
container.innerHTML = `
|
||||
<div class="event-type-chips" data-role="chips"></div>
|
||||
<div class="event-type-search-row">
|
||||
<input type="text" class="event-type-search" data-role="search" placeholder="${esc(t("event_types.picker.search"))}" />
|
||||
<button type="button" class="event-type-add-btn" data-role="add">${esc(t("event_types.picker.add"))}</button>
|
||||
</div>
|
||||
<div class="event-type-suggest" data-role="suggest" hidden></div>
|
||||
`;
|
||||
|
||||
const chipsEl = container.querySelector<HTMLElement>("[data-role=chips]")!;
|
||||
const searchEl = container.querySelector<HTMLInputElement>("[data-role=search]")!;
|
||||
const suggestEl = container.querySelector<HTMLElement>("[data-role=suggest]")!;
|
||||
const addBtn = container.querySelector<HTMLButtonElement>("[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 `<span class="event-type-chip" data-id="${esc(et.id)}">
|
||||
<span class="event-type-chip-label">${esc(eventTypeLabel(et))}</span>
|
||||
<button type="button" class="event-type-chip-remove" aria-label="${esc(t("event_types.picker.remove"))}">×</button>
|
||||
</span>`;
|
||||
})
|
||||
.join("");
|
||||
chipsEl.querySelectorAll<HTMLButtonElement>(".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 = `<div class="event-type-suggest-empty">${esc(t("event_types.picker.no_match"))}</div>`;
|
||||
return;
|
||||
}
|
||||
suggestEl.hidden = false;
|
||||
suggestEl.innerHTML = matches
|
||||
.map(
|
||||
(et) => `<button type="button" class="event-type-suggest-row" data-id="${esc(et.id)}">
|
||||
<span class="event-type-suggest-label">${esc(eventTypeLabel(et))}</span>
|
||||
<span class="event-type-suggest-cat">${esc(categoryLabel(et.category))}</span>
|
||||
</button>`,
|
||||
)
|
||||
.join("");
|
||||
suggestEl.querySelectorAll<HTMLButtonElement>(".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<void>;
|
||||
/** 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<string>(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 = `
|
||||
<span class="akten-multi-label" data-role="label"></span>
|
||||
<span class="akten-multi-chevron" aria-hidden="true">▾</span>
|
||||
`;
|
||||
panel.classList.add("akten-multi-panel");
|
||||
panel.hidden = true;
|
||||
|
||||
function updateLabel() {
|
||||
const labelEl = trigger.querySelector<HTMLElement>("[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 `<div class="akten-multi-group">
|
||||
<div class="akten-multi-group-label">${esc(categoryLabel(cat))}</div>
|
||||
${list
|
||||
.map(
|
||||
(et) => `<label class="akten-multi-option">
|
||||
<input type="checkbox" data-id="${esc(et.id)}" ${selected.has(et.id) ? "checked" : ""} />
|
||||
<span>${esc(eventTypeLabel(et))}</span>
|
||||
</label>`,
|
||||
)
|
||||
.join("")}
|
||||
</div>`;
|
||||
};
|
||||
panel.innerHTML = `
|
||||
<div class="akten-multi-search-row">
|
||||
<input type="text" class="akten-multi-search" data-role="search" placeholder="${esc(t("event_types.filter.search"))}" value="${esc(searchQuery)}" />
|
||||
</div>
|
||||
<div class="akten-multi-specials">
|
||||
<label class="akten-multi-option akten-multi-special">
|
||||
<input type="checkbox" data-role="all" ${selected.size === 0 && !includeUntyped ? "checked" : ""} />
|
||||
<span>${esc(t("event_types.filter.all"))}</span>
|
||||
</label>
|
||||
<label class="akten-multi-option akten-multi-special">
|
||||
<input type="checkbox" data-role="untyped" ${includeUntyped ? "checked" : ""} />
|
||||
<span>${esc(t("event_types.filter.untyped"))}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="akten-multi-list">
|
||||
${CATEGORY_ORDER.map(renderGroup).join("")}
|
||||
</div>
|
||||
<div class="akten-multi-actions">
|
||||
<button type="button" class="btn-cancel" data-role="reset">${esc(t("event_types.filter.reset"))}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime" data-role="close">${esc(t("event_types.filter.apply"))}</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const searchInput = panel.querySelector<HTMLInputElement>("[data-role=search]")!;
|
||||
searchInput.addEventListener("input", () => {
|
||||
searchQuery = searchInput.value;
|
||||
renderPanel();
|
||||
// re-focus the search after re-render
|
||||
const fresh = panel.querySelector<HTMLInputElement>("[data-role=search]")!;
|
||||
fresh.focus();
|
||||
fresh.setSelectionRange(searchInput.selectionStart ?? 0, searchInput.selectionEnd ?? 0);
|
||||
});
|
||||
|
||||
const allCb = panel.querySelector<HTMLInputElement>("[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<HTMLInputElement>("[data-role=untyped]")!;
|
||||
untypedCb.addEventListener("change", () => {
|
||||
includeUntyped = untypedCb.checked;
|
||||
renderPanel();
|
||||
updateLabel();
|
||||
opts.onChange?.(Array.from(selected), includeUntyped);
|
||||
});
|
||||
|
||||
panel.querySelectorAll<HTMLInputElement>(".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<HTMLButtonElement>("[data-role=reset]")!.addEventListener("click", () => {
|
||||
selected.clear();
|
||||
includeUntyped = false;
|
||||
searchQuery = "";
|
||||
renderPanel();
|
||||
updateLabel();
|
||||
opts.onChange?.([], false);
|
||||
});
|
||||
panel.querySelector<HTMLButtonElement>("[data-role=close]")!.addEventListener("click", () => {
|
||||
closePanel();
|
||||
});
|
||||
}
|
||||
|
||||
function openPanel() {
|
||||
panel.hidden = false;
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
renderPanel();
|
||||
setTimeout(() => {
|
||||
const search = panel.querySelector<HTMLInputElement>("[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<EventType | null> {
|
||||
return new Promise<EventType | null>((resolve) => {
|
||||
const overlay = document.createElement("div");
|
||||
overlay.className = "modal-overlay event-type-modal-overlay";
|
||||
overlay.innerHTML = `
|
||||
<div class="modal event-type-add-modal" role="dialog" aria-modal="true" aria-labelledby="event-type-add-title">
|
||||
<h2 id="event-type-add-title">${esc(t("event_types.add.title"))}</h2>
|
||||
<div class="event-type-suggest-warn" data-role="warn" hidden></div>
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-label-de">${esc(t("event_types.add.label_de"))}</label>
|
||||
<input type="text" id="event-type-add-label-de" autofocus value="${esc(opts.prefillLabel || "")}" />
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-label-en">${esc(t("event_types.add.label_en"))}</label>
|
||||
<input type="text" id="event-type-add-label-en" />
|
||||
</div>
|
||||
<div class="form-field-row">
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-category">${esc(t("event_types.add.category"))}</label>
|
||||
<select id="event-type-add-category">
|
||||
${CATEGORY_ORDER.map((c) => `<option value="${c}">${esc(categoryLabel(c))}</option>`).join("")}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label for="event-type-add-jurisdiction">${esc(t("event_types.add.jurisdiction"))}</label>
|
||||
<select id="event-type-add-jurisdiction">
|
||||
<option value="">${esc(t("event_types.add.jurisdiction.none"))}</option>
|
||||
<option value="UPC">UPC</option>
|
||||
<option value="EPO">EPA</option>
|
||||
<option value="DPMA">DPMA</option>
|
||||
<option value="DE">DE</option>
|
||||
<option value="any">${esc(t("event_types.add.jurisdiction.any"))}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-field event-type-add-firm-wide" ${opts.isAdmin ? "" : "hidden"}>
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="event-type-add-firm-wide" />
|
||||
<span>${esc(t("event_types.add.firm_wide"))}</span>
|
||||
</label>
|
||||
<p class="form-hint">${esc(t("event_types.add.firm_wide.hint"))}</p>
|
||||
</div>
|
||||
<p class="form-msg" id="event-type-add-msg"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-cancel" data-role="cancel">${esc(t("common.cancel"))}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime" data-role="submit">${esc(t("event_types.add.submit"))}</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
const labelDE = overlay.querySelector<HTMLInputElement>("#event-type-add-label-de")!;
|
||||
const labelEN = overlay.querySelector<HTMLInputElement>("#event-type-add-label-en")!;
|
||||
const catSel = overlay.querySelector<HTMLSelectElement>("#event-type-add-category")!;
|
||||
const jurSel = overlay.querySelector<HTMLSelectElement>("#event-type-add-jurisdiction")!;
|
||||
const firmWide = overlay.querySelector<HTMLInputElement>("#event-type-add-firm-wide")!;
|
||||
const msg = overlay.querySelector<HTMLElement>("#event-type-add-msg")!;
|
||||
const warnEl = overlay.querySelector<HTMLElement>("[data-role=warn]")!;
|
||||
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
|
||||
const submitBtn = overlay.querySelector<HTMLButtonElement>("[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 = `<strong>${esc(t("event_types.add.duplicate_warn"))}</strong> ` +
|
||||
matches.map((m) => `<span class="event-type-suggest-pill">${esc(eventTypeLabel(m))}</span>`).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<string, unknown> = {
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -1378,6 +1378,44 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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<Lang, Record<string, string>> = {
|
||||
"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",
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -77,6 +77,12 @@ export function renderDeadlinesDetail(): string {
|
||||
<dt data-i18n="deadlines.detail.rule">Regel</dt>
|
||||
<dd id="deadline-rule-display">—</dd>
|
||||
|
||||
<dt data-i18n="deadlines.field.event_type">Typ (optional)</dt>
|
||||
<dd>
|
||||
<span id="deadline-event-types-display">—</span>
|
||||
<div id="deadline-event-types-edit" className="event-type-picker-host" style="display:none" />
|
||||
</dd>
|
||||
|
||||
<dt data-i18n="deadlines.detail.source">Quelle</dt>
|
||||
<dd id="deadline-source-display" />
|
||||
|
||||
|
||||
@@ -54,6 +54,11 @@ export function renderDeadlinesNew(): string {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="form-field">
|
||||
<label data-i18n="deadlines.field.event_type">Typ (optional)</label>
|
||||
<div id="deadline-event-types" className="event-type-picker-host" />
|
||||
</div>
|
||||
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="deadline-due" data-i18n="deadlines.field.due">Fälligkeitsdatum</label>
|
||||
|
||||
@@ -82,6 +82,10 @@ export function renderDeadlines(): string {
|
||||
<select id="deadline-filter-project" className="akten-select">
|
||||
<option value="" data-i18n="deadlines.filter.akte.all">Alle Projekte</option>
|
||||
</select>
|
||||
|
||||
<label className="akten-filter-label" htmlFor="deadline-filter-event-type" data-i18n="deadlines.filter.event_type">Typ</label>
|
||||
<button type="button" id="deadline-filter-event-type" className="akten-select akten-multi-trigger" aria-haspopup="listbox" />
|
||||
<div id="deadline-filter-event-type-panel" className="akten-multi-panel" hidden />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,6 +104,7 @@ export function renderDeadlines(): string {
|
||||
<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="akten-col-event-type" data-i18n="deadlines.col.event_type">Typ</th>
|
||||
<th className="akten-col-status" data-i18n="deadlines.col.status">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -190,6 +190,7 @@ export type I18nKey =
|
||||
| "agenda.filter.appointments"
|
||||
| "agenda.filter.both"
|
||||
| "agenda.filter.deadlines"
|
||||
| "agenda.filter.event_type"
|
||||
| "agenda.filter.range"
|
||||
| "agenda.filter.type"
|
||||
| "agenda.heading"
|
||||
@@ -399,6 +400,7 @@ export type I18nKey =
|
||||
| "checklisten.reset.error"
|
||||
| "checklisten.subtitle"
|
||||
| "checklisten.title"
|
||||
| "common.cancel"
|
||||
| "dashboard.action.short.akte_archived"
|
||||
| "dashboard.action.short.akte_created"
|
||||
| "dashboard.action.short.appointment_created"
|
||||
@@ -477,6 +479,7 @@ export type I18nKey =
|
||||
| "deadlines.calculate"
|
||||
| "deadlines.col.akte"
|
||||
| "deadlines.col.due"
|
||||
| "deadlines.col.event_type"
|
||||
| "deadlines.col.rule"
|
||||
| "deadlines.col.status"
|
||||
| "deadlines.col.title"
|
||||
@@ -547,6 +550,7 @@ export type I18nKey =
|
||||
| "deadlines.field.akte.empty"
|
||||
| "deadlines.field.akte.empty.link"
|
||||
| "deadlines.field.due"
|
||||
| "deadlines.field.event_type"
|
||||
| "deadlines.field.notes"
|
||||
| "deadlines.field.notes.placeholder"
|
||||
| "deadlines.field.rule"
|
||||
@@ -557,6 +561,7 @@ export type I18nKey =
|
||||
| "deadlines.filter.akte.all"
|
||||
| "deadlines.filter.all"
|
||||
| "deadlines.filter.completed"
|
||||
| "deadlines.filter.event_type"
|
||||
| "deadlines.filter.overdue"
|
||||
| "deadlines.filter.pending"
|
||||
| "deadlines.filter.status"
|
||||
@@ -713,6 +718,37 @@ export type I18nKey =
|
||||
| "event.title.project_reparented"
|
||||
| "event.title.project_type_changed"
|
||||
| "event.title.status_changed"
|
||||
| "event_types.add.category"
|
||||
| "event_types.add.duplicate_warn"
|
||||
| "event_types.add.error.generic"
|
||||
| "event_types.add.error.required"
|
||||
| "event_types.add.error.slug_taken"
|
||||
| "event_types.add.firm_wide"
|
||||
| "event_types.add.firm_wide.hint"
|
||||
| "event_types.add.jurisdiction"
|
||||
| "event_types.add.jurisdiction.any"
|
||||
| "event_types.add.jurisdiction.none"
|
||||
| "event_types.add.label_de"
|
||||
| "event_types.add.label_en"
|
||||
| "event_types.add.submit"
|
||||
| "event_types.add.title"
|
||||
| "event_types.cat.decision"
|
||||
| "event_types.cat.fee"
|
||||
| "event_types.cat.hearing"
|
||||
| "event_types.cat.order"
|
||||
| "event_types.cat.other"
|
||||
| "event_types.cat.service"
|
||||
| "event_types.cat.submission"
|
||||
| "event_types.filter.all"
|
||||
| "event_types.filter.apply"
|
||||
| "event_types.filter.n_selected"
|
||||
| "event_types.filter.reset"
|
||||
| "event_types.filter.search"
|
||||
| "event_types.filter.untyped"
|
||||
| "event_types.picker.add"
|
||||
| "event_types.picker.no_match"
|
||||
| "event_types.picker.remove"
|
||||
| "event_types.picker.search"
|
||||
| "footer.text"
|
||||
| "gebuehren.col.courtfee"
|
||||
| "gebuehren.col.fee"
|
||||
|
||||
@@ -8166,3 +8166,251 @@ dialog.quick-add-sheet::backdrop {
|
||||
mode, the browser-default scrollbar is dark via color-scheme, so no
|
||||
per-element override is needed here. */
|
||||
|
||||
/* ============================================================================
|
||||
t-paliad-088 — Event Types: picker, multi-select filter, add modal
|
||||
============================================================================ */
|
||||
|
||||
/* Picker host — chip cluster + search + suggest dropdown */
|
||||
.event-type-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
background: var(--color-bg-subtle);
|
||||
}
|
||||
|
||||
.event-type-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.event-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.15rem 0.35rem 0.15rem 0.55rem;
|
||||
background: var(--color-bg-lime-tint);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
.event-type-chip-label { white-space: nowrap; }
|
||||
.event-type-chip-remove {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
padding: 0 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.event-type-chip-remove:hover { color: var(--color-text); }
|
||||
|
||||
.event-type-search-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.event-type-search {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.event-type-add-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px dashed var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.event-type-add-btn:hover { background: var(--color-bg-subtle); }
|
||||
|
||||
.event-type-suggest {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg);
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.event-type-suggest-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.4rem 0.6rem;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.event-type-suggest-row:last-child { border-bottom: 0; }
|
||||
.event-type-suggest-row:hover { background: var(--color-bg-lime-tint); }
|
||||
.event-type-suggest-cat {
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
.event-type-suggest-empty {
|
||||
padding: 0.5rem 0.6rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Multi-select filter — trigger button + popover panel */
|
||||
.akten-multi-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
min-width: 12rem;
|
||||
}
|
||||
.akten-multi-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.akten-multi-chevron {
|
||||
color: var(--color-text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.akten-multi-panel {
|
||||
position: absolute;
|
||||
z-index: 50;
|
||||
margin-top: 0.25rem;
|
||||
width: 22rem;
|
||||
max-width: calc(100vw - 1rem);
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
|
||||
padding: 0.5rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
max-height: 28rem;
|
||||
}
|
||||
.akten-multi-panel[hidden] { display: none; }
|
||||
.akten-multi-search-row { display: flex; }
|
||||
.akten-multi-search {
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.375rem;
|
||||
background: var(--color-bg-subtle);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.akten-multi-specials {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
padding-bottom: 0.4rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.akten-multi-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 4rem;
|
||||
}
|
||||
.akten-multi-group { padding: 0.25rem 0; }
|
||||
.akten-multi-group-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.25rem 0.4rem;
|
||||
}
|
||||
.akten-multi-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.akten-multi-option:hover { background: var(--color-bg-lime-tint); }
|
||||
.akten-multi-option input[type="checkbox"] { margin: 0; }
|
||||
.akten-multi-special { font-weight: 500; }
|
||||
.akten-multi-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Typ pill in the deadlines table — compact, neutral. */
|
||||
.akten-event-type-pill {
|
||||
display: inline-block;
|
||||
padding: 0.05rem 0.4rem;
|
||||
margin-right: 0.2rem;
|
||||
background: var(--color-bg-subtle);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Hide-on-uniform: when no row has any event_type, hide the column. */
|
||||
.akten-table--hide-event-type .akten-col-event-type { display: none; }
|
||||
|
||||
/* Add-modal styling — extends the existing .modal-overlay/.modal pattern. */
|
||||
.event-type-add-modal {
|
||||
width: 28rem;
|
||||
max-width: calc(100vw - 2rem);
|
||||
}
|
||||
.event-type-add-modal .form-field-row { display: flex; gap: 0.75rem; }
|
||||
.event-type-add-modal .form-field-row > .form-field { flex: 1; }
|
||||
.event-type-suggest-warn {
|
||||
padding: 0.5rem 0.6rem;
|
||||
background: var(--status-amber-soft-bg);
|
||||
color: var(--status-amber-soft-fg);
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.event-type-suggest-pill {
|
||||
display: inline-block;
|
||||
margin: 0 0.2rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Mobile: filter row already wraps; the multi-panel becomes a bottom-anchored
|
||||
sheet on narrow viewports. */
|
||||
@media (max-width: 640px) {
|
||||
.akten-multi-panel {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: auto;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
max-height: 70vh;
|
||||
border-radius: 0.75rem 0.75rem 0 0;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user