Merge: t-paliad-088 PR-2 — Event Types frontend (picker, multi-select filter, add modal)

This commit is contained in:
m
2026-04-30 12:57:57 +02:00
12 changed files with 1222 additions and 4 deletions

View File

@@ -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">

View File

@@ -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) {

View File

@@ -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 = "&mdash;";
} 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 = "&mdash;";
}
}
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();

View File

@@ -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,
});
}
});

View File

@@ -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 "&mdash;";
}
function eventTypeDisplay(f: Deadline): string {
const ids = f.event_type_ids ?? [];
if (ids.length === 0) return "&mdash;";
const labels: string[] = [];
for (const id of ids) {
const et = eventTypeByID.get(id);
if (et) labels.push(eventTypeLabel(et));
}
if (labels.length === 0) return "&mdash;";
return labels.map((l) => `<span class="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()]);
});

View 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;
}
});
});
}

View File

@@ -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",
},
};

View File

@@ -77,6 +77,12 @@ export function renderDeadlinesDetail(): string {
<dt data-i18n="deadlines.detail.rule">Regel</dt>
<dd id="deadline-rule-display">&mdash;</dd>
<dt data-i18n="deadlines.field.event_type">Typ (optional)</dt>
<dd>
<span id="deadline-event-types-display">&mdash;</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" />

View File

@@ -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&auml;lligkeitsdatum</label>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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;
}
}