Merge Phase D: Akten CRUD UI

This commit is contained in:
m
2026-04-16 17:11:07 +02:00
14 changed files with 2371 additions and 9 deletions

View File

@@ -11,6 +11,9 @@ import { renderGebuehrentabellen } from "./src/gebuehrentabellen";
import { renderChecklisten } from "./src/checklisten";
import { renderChecklistenDetail } from "./src/checklisten-detail";
import { renderGerichte } from "./src/gerichte";
import { renderAkten } from "./src/akten";
import { renderAktenNeu } from "./src/akten-neu";
import { renderAktenDetail } from "./src/akten-detail";
const DIST = join(import.meta.dir, "dist");
@@ -33,6 +36,9 @@ async function build() {
join(import.meta.dir, "src/client/checklisten.ts"),
join(import.meta.dir, "src/client/checklisten-detail.ts"),
join(import.meta.dir, "src/client/gerichte.ts"),
join(import.meta.dir, "src/client/akten.ts"),
join(import.meta.dir, "src/client/akten-neu.ts"),
join(import.meta.dir, "src/client/akten-detail.ts"),
],
outdir: join(DIST, "assets"),
naming: "[name].js",
@@ -65,6 +71,9 @@ async function build() {
await Bun.write(join(DIST, "checklisten.html"), renderChecklisten());
await Bun.write(join(DIST, "checklisten-detail.html"), renderChecklistenDetail());
await Bun.write(join(DIST, "gerichte.html"), renderGerichte());
await Bun.write(join(DIST, "akten.html"), renderAkten());
await Bun.write(join(DIST, "akten-neu.html"), renderAktenNeu());
await Bun.write(join(DIST, "akten-detail.html"), renderAktenDetail());
console.log("Build complete \u2192 dist/");
}

View File

@@ -0,0 +1,195 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderAktenDetail(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="akten.detail.title">Akte &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/akten" />
<main>
<section className="tool-page">
<div className="container">
<a href="/akten" className="akten-back-link" data-i18n="akten.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<div id="akten-detail-loading" className="akten-loading">
<p data-i18n="akten.detail.loading">L&auml;dt&hellip;</p>
</div>
<div id="akten-detail-notfound" className="akten-empty" style="display:none">
<p data-i18n="akten.detail.notfound">Akte nicht gefunden oder keine Berechtigung.</p>
</div>
<div id="akten-detail-body" style="display:none">
<header className="akten-detail-header">
<div className="akten-detail-title-row">
<div className="akten-detail-title-col">
<h1 id="akte-title-display" />
<input type="text" id="akte-title-edit" className="akten-title-input" style="display:none" />
<div className="akten-detail-meta">
<span className="akten-ref" id="akte-ref-display" />
<span id="akte-office-chip" className="akten-office-chip" />
<span id="akte-status-chip" className="akten-status-chip" />
<span id="akte-firmwide-chip" className="akten-firmwide-chip" style="display:none" />
</div>
</div>
<div className="akten-detail-actions">
<button id="akte-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="akten.detail.edit" title="Bearbeiten">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
</svg>
</button>
<button id="akte-save-btn" className="btn-primary btn-cta-lime" type="button" style="display:none" data-i18n="akten.detail.save">Speichern</button>
</div>
</div>
</header>
<nav className="akten-tabs" id="akte-tabs">
<a className="akten-tab" data-tab="verlauf" href="#" data-i18n="akten.detail.tab.verlauf">Verlauf</a>
<a className="akten-tab" data-tab="parteien" href="#" data-i18n="akten.detail.tab.parteien">Parteien</a>
<a className="akten-tab" data-tab="fristen" href="#" data-i18n="akten.detail.tab.fristen">Fristen</a>
<a className="akten-tab" data-tab="termine" href="#" data-i18n="akten.detail.tab.termine">Termine</a>
<a className="akten-tab" data-tab="dokumente" href="#" data-i18n="akten.detail.tab.dokumente">Dokumente</a>
<a className="akten-tab" data-tab="notizen" href="#" data-i18n="akten.detail.tab.notizen">Notizen</a>
</nav>
{/* Verlauf (Activity) */}
<section className="akten-tab-panel" id="tab-verlauf">
<ul className="akten-events" id="akten-events-list" />
<p className="akten-events-empty" id="akten-events-empty" style="display:none" data-i18n="akten.detail.verlauf.empty">
Noch keine Ereignisse aufgezeichnet.
</p>
</section>
{/* Parteien */}
<section className="akten-tab-panel" id="tab-parteien" style="display:none">
<div className="akten-parteien-controls">
<button id="partei-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="akten.detail.parteien.add">
Partei hinzuf&uuml;gen
</button>
</div>
<form id="partei-form" className="akten-form akten-partei-form" style="display:none" autocomplete="off">
<div className="form-field-row">
<div className="form-field">
<label htmlFor="partei-name" data-i18n="akten.detail.parteien.form.name">Name</label>
<input type="text" id="partei-name" required />
</div>
<div className="form-field">
<label htmlFor="partei-role" data-i18n="akten.detail.parteien.form.role">Rolle</label>
<select id="partei-role">
<option value="claimant" data-i18n="akten.detail.parteien.role.claimant">Kl&auml;ger</option>
<option value="defendant" data-i18n="akten.detail.parteien.role.defendant">Beklagter</option>
<option value="thirdparty" data-i18n="akten.detail.parteien.role.thirdparty">Streitverk&uuml;ndeter / Drittpartei</option>
</select>
</div>
</div>
<div className="form-field">
<label htmlFor="partei-rep" data-i18n="akten.detail.parteien.form.rep">Vertreter (optional)</label>
<input type="text" id="partei-rep" />
</div>
<div className="form-actions">
<button type="button" className="btn-cancel" id="partei-cancel" data-i18n="akten.detail.parteien.form.cancel">Abbrechen</button>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="akten.detail.parteien.form.submit">Hinzuf&uuml;gen</button>
</div>
<p className="form-msg" id="partei-msg" />
</form>
<table className="akten-parteien-table">
<thead>
<tr>
<th data-i18n="akten.detail.parteien.col.name">Name</th>
<th data-i18n="akten.detail.parteien.col.role">Rolle</th>
<th data-i18n="akten.detail.parteien.col.rep">Vertreter</th>
<th />
</tr>
</thead>
<tbody id="parteien-body" />
</table>
<p className="akten-events-empty" id="parteien-empty" style="display:none" data-i18n="akten.detail.parteien.empty">
Noch keine Parteien eingetragen.
</p>
</section>
{/* Fristen — Phase E placeholder */}
<section className="akten-tab-panel" id="tab-fristen" style="display:none">
<div className="akten-soon">
<h2 data-i18n="akten.detail.soon">Bald verf&uuml;gbar</h2>
<p data-i18n="akten.detail.soon.fristen">
Fristenverwaltung kommt in Phase E &mdash; diese Akte wird dann Fristen anzeigen.
</p>
</div>
</section>
{/* Termine — Phase F placeholder */}
<section className="akten-tab-panel" id="tab-termine" style="display:none">
<div className="akten-soon">
<h2 data-i18n="akten.detail.soon">Bald verf&uuml;gbar</h2>
<p data-i18n="akten.detail.soon.termine">
Termine &amp; CalDAV-Sync folgen in Phase F.
</p>
</div>
</section>
{/* Dokumente — Phase H placeholder */}
<section className="akten-tab-panel" id="tab-dokumente" style="display:none">
<div className="akten-soon">
<h2 data-i18n="akten.detail.soon">Bald verf&uuml;gbar</h2>
<p data-i18n="akten.detail.soon.dokumente">
Dokumenten-Upload folgt in Phase H.
</p>
</div>
</section>
{/* Notizen — Phase I placeholder */}
<section className="akten-tab-panel" id="tab-notizen" style="display:none">
<div className="akten-soon">
<h2 data-i18n="akten.detail.soon">Bald verf&uuml;gbar</h2>
<p data-i18n="akten.detail.soon.notizen">
Notizfunktion folgt in Phase I.
</p>
</div>
</section>
<div className="akten-detail-footer" id="akte-delete-wrap" style="display:none">
<button id="akte-delete-btn" className="btn-danger" type="button" data-i18n="akten.detail.delete">
Akte l&ouml;schen
</button>
</div>
</div>
{/* Delete confirmation modal */}
<div className="modal-overlay" id="delete-modal" style="display:none">
<div className="modal-card">
<div className="modal-header">
<h2 data-i18n="akten.detail.delete.confirm.title">Akte wirklich l&ouml;schen?</h2>
<button className="modal-close" id="delete-modal-close" type="button">&times;</button>
</div>
<p data-i18n="akten.detail.delete.confirm.body">
Die Akte wird archiviert. Sie kann nicht direkt wiederhergestellt werden.
</p>
<div className="form-actions">
<button type="button" className="btn-cancel" id="delete-modal-cancel" data-i18n="akten.detail.delete.confirm.cancel">Abbrechen</button>
<button type="button" className="btn-danger" id="delete-modal-confirm" data-i18n="akten.detail.delete.confirm.ok">L&ouml;schen</button>
</div>
</div>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/akten-detail.js"></script>
</body>
</html>
);
}

141
frontend/src/akten-neu.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderAktenNeu(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="akten.neu.title">Neue Akte &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/akten/neu" />
<main>
<section className="tool-page">
<div className="container container-narrow">
<div className="tool-header">
<a href="/akten" className="akten-back-link" data-i18n="akten.detail.back">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<h1 data-i18n="akten.neu.heading">Neue Akte anlegen</h1>
<p className="tool-subtitle" data-i18n="akten.neu.subtitle">
Anlegen eines neuen Mandats im eigenen B&uuml;ro. Sichtbarkeit folgt der B&uuml;ro-Regel;
Partner k&ouml;nnen firmenweite Sichtbarkeit aktivieren.
</p>
</div>
<form id="akten-neu-form" className="akten-form" autocomplete="off">
<div className="form-field">
<label htmlFor="akte-title" data-i18n="akten.field.title">Titel</label>
<input
type="text"
id="akte-title"
required
placeholder="Kurzbezeichnung des Mandats"
data-i18n-placeholder="akten.field.title.placeholder"
/>
</div>
<div className="form-field">
<label htmlFor="akte-ref" data-i18n="akten.field.ref">Aktenzeichen</label>
<input
type="text"
id="akte-ref"
required
placeholder="z.B. HL-2026-0042"
data-i18n-placeholder="akten.field.ref.placeholder"
/>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="akte-office" data-i18n="akten.field.office">Federf&uuml;hrendes B&uuml;ro</label>
<select id="akte-office" required>
<option value="munich" data-i18n="office.munich">M&uuml;nchen</option>
<option value="duesseldorf" data-i18n="office.duesseldorf">D&uuml;sseldorf</option>
<option value="hamburg" data-i18n="office.hamburg">Hamburg</option>
<option value="amsterdam" data-i18n="office.amsterdam">Amsterdam</option>
<option value="london" data-i18n="office.london">London</option>
<option value="paris" data-i18n="office.paris">Paris</option>
<option value="milan" data-i18n="office.milan">Mailand</option>
</select>
</div>
<div className="form-field">
<label htmlFor="akte-status" data-i18n="akten.field.status">Status</label>
<select id="akte-status">
<option value="active" data-i18n="akten.status.active">Aktiv</option>
<option value="completed" data-i18n="akten.status.completed">Abgeschlossen</option>
</select>
</div>
</div>
<div className="form-field-row">
<div className="form-field">
<label htmlFor="akte-court" data-i18n="akten.field.court">Gericht (optional)</label>
<input type="text" id="akte-court" />
</div>
<div className="form-field">
<label htmlFor="akte-courtref" data-i18n="akten.field.courtRef">Gerichtsaktenzeichen (optional)</label>
<input type="text" id="akte-courtref" />
</div>
</div>
<div className="form-field">
<label htmlFor="akte-type" data-i18n="akten.field.akteType">Verfahrensart (optional)</label>
<input
type="text"
id="akte-type"
placeholder="UPC Infringement, BPatG Nichtigkeit, EPA Opposition..."
/>
</div>
<div className="form-field" id="firm-wide-wrap" style="display:none">
<label className="form-checkbox">
<input type="checkbox" id="akte-firmwide" />
<span data-i18n="akten.field.firmWide">Firmenweit sichtbar</span>
</label>
<p className="form-hint" data-i18n="akten.field.firmWide.hint">
Wenn aktiviert, sehen alle Lawyer diese Akte. Nur f&uuml;r Partner/Admin.
</p>
</div>
<div className="form-field">
<label htmlFor="akte-collab-input" data-i18n="akten.field.collaborators">
Weitere Bearbeiter (optional)
</label>
<div className="akten-collab">
<div id="akte-collab-list" className="akten-collab-chips" />
<input
type="text"
id="akte-collab-input"
placeholder="Name oder E-Mail tippen..."
data-i18n-placeholder="akten.field.collaborators.placeholder"
autocomplete="off"
/>
<div id="akte-collab-suggestions" className="akten-collab-suggestions" />
</div>
<p className="form-hint" data-i18n="akten.field.collaborators.hint">
Personen, die auch Zugriff erhalten sollen (auch b&uuml;ro&uuml;bergreifend).
</p>
</div>
<p className="form-msg" id="akten-neu-msg" />
<div className="form-actions">
<a href="/akten" className="btn-cancel" data-i18n="akten.cancel">Abbrechen</a>
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="akten.submit">Akte anlegen</button>
</div>
</form>
</div>
</section>
</main>
<Footer />
<script src="/assets/akten-neu.js"></script>
</body>
</html>
);
}

115
frontend/src/akten.tsx Normal file
View File

@@ -0,0 +1,115 @@
import { h } from "./jsx";
import { Sidebar } from "./components/Sidebar";
import { Footer } from "./components/Footer";
export function renderAkten(): string {
return "<!DOCTYPE html>" + (
<html lang="de">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title data-i18n="akten.title">Akten &mdash; Paliad</title>
<link rel="stylesheet" href="/assets/global.css" />
</head>
<body className="has-sidebar">
<Sidebar currentPath="/akten" />
<main>
<section className="tool-page">
<div className="container">
<div className="tool-header">
<div className="akten-header-row">
<div>
<h1 data-i18n="akten.heading">Akten</h1>
<p className="tool-subtitle" data-i18n="akten.subtitle">
B&uuml;ro-bezogene Mandate. Verlauf, Parteien und (bald) Fristen &amp; Termine an einem Ort.
</p>
</div>
<a href="/akten/neu" className="btn-primary btn-cta-lime" data-i18n="akten.new">
Neue Akte
</a>
</div>
</div>
<div className="akten-controls">
<div className="glossar-search-wrap akten-search-wrap">
<svg className="glossar-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="11" cy="11" r="8" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
type="text"
id="akten-search"
className="glossar-search"
placeholder="Titel oder Aktenzeichen suchen..."
data-i18n-placeholder="akten.search.placeholder"
autocomplete="off"
/>
<span className="glossar-count" id="akten-count" />
</div>
<div className="akten-filter-row">
<label className="akten-filter-label" htmlFor="akten-office" data-i18n="akten.filter.office">B&uuml;ro</label>
<select id="akten-office" className="akten-select">
<option value="" data-i18n="akten.filter.office.all">Alle B&uuml;ros</option>
<option value="munich" data-i18n="office.munich">M&uuml;nchen</option>
<option value="duesseldorf" data-i18n="office.duesseldorf">D&uuml;sseldorf</option>
<option value="hamburg" data-i18n="office.hamburg">Hamburg</option>
<option value="amsterdam" data-i18n="office.amsterdam">Amsterdam</option>
<option value="london" data-i18n="office.london">London</option>
<option value="paris" data-i18n="office.paris">Paris</option>
<option value="milan" data-i18n="office.milan">Mailand</option>
</select>
<label className="akten-filter-label" htmlFor="akten-status" data-i18n="akten.filter.status">Status</label>
<select id="akten-status" className="akten-select">
<option value="" data-i18n="akten.filter.status.all">Alle Status</option>
<option value="active" data-i18n="akten.filter.status.active">Aktiv</option>
<option value="completed" data-i18n="akten.filter.status.completed">Abgeschlossen</option>
<option value="archived" data-i18n="akten.filter.status.archived">Archiviert</option>
</select>
</div>
</div>
<div id="akten-unavailable" className="akten-unavailable" style="display:none">
<p data-i18n="akten.unavailable">
Aktenverwaltung zurzeit nicht verf&uuml;gbar &mdash; bitte Administrator kontaktieren.
</p>
</div>
<div className="akten-table-wrap">
<table className="akten-table" id="akten-table">
<thead>
<tr>
<th data-i18n="akten.col.title">Titel</th>
<th data-i18n="akten.col.ref">Aktenzeichen</th>
<th data-i18n="akten.col.office">B&uuml;ro</th>
<th data-i18n="akten.col.status">Status</th>
<th data-i18n="akten.col.updated">Zuletzt ge&auml;ndert</th>
</tr>
</thead>
<tbody id="akten-body" />
</table>
</div>
<div className="akten-empty" id="akten-empty" style="display:none">
<h2 data-i18n="akten.empty.title">Noch keine Akte angelegt</h2>
<p data-i18n="akten.empty.hint">
Starten Sie &uuml;ber &bdquo;Neue Akte&ldquo; &mdash; Sie sehen hier sp&auml;ter Ihre Mandate, nach B&uuml;ro gefiltert.
</p>
<a href="/akten/neu" className="btn-primary btn-cta-lime" data-i18n="akten.new">Neue Akte</a>
</div>
<div className="akten-empty akten-empty-filtered" id="akten-empty-filtered" style="display:none">
<p data-i18n="akten.empty.filtered">Keine Treffer f&uuml;r diese Filter.</p>
</div>
</div>
</section>
</main>
<Footer />
<script src="/assets/akten.js"></script>
</body>
</html>
);
}

View File

@@ -0,0 +1,425 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Akte {
id: string;
aktenzeichen: string;
title: string;
status: string;
owning_office: string;
firm_wide_visible: boolean;
collaborators: string[];
updated_at: string;
created_at: string;
}
interface Partei {
id: string;
akte_id: string;
name: string;
role?: string;
representative?: string;
}
interface AkteEvent {
id: string;
akte_id: string;
event_type?: string;
title: string;
description?: string;
created_at: string;
created_by?: string;
}
interface Me {
id: string;
role: string;
office: string;
}
type TabId = "verlauf" | "parteien" | "fristen" | "termine" | "dokumente" | "notizen";
const VALID_TABS: TabId[] = ["verlauf", "parteien", "fristen", "termine", "dokumente", "notizen"];
let akte: Akte | null = null;
let me: Me | null = null;
let parteien: Partei[] = [];
let events: AkteEvent[] = [];
function parseAkteID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "akten" || !parts[1]) return null;
return parts[1];
}
function parseTab(): TabId {
const parts = window.location.pathname.split("/").filter(Boolean);
const candidate = parts[2] as TabId | undefined;
if (candidate && VALID_TABS.includes(candidate)) return candidate;
return "verlauf";
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* optional */
}
}
async function loadAkte(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/akten/${id}`);
if (!resp.ok) return false;
akte = await resp.json();
return true;
} catch {
return false;
}
}
async function loadParteien(id: string) {
try {
const resp = await fetch(`/api/akten/${id}/parteien`);
if (resp.ok) parteien = await resp.json();
} catch {
parteien = [];
}
}
async function loadEvents(id: string) {
try {
const resp = await fetch(`/api/akten/${id}/events`);
if (resp.ok) events = await resp.json();
} catch {
events = [];
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function renderHeader() {
if (!akte) return;
(document.getElementById("akte-title-display") as HTMLElement).textContent = akte.title;
(document.getElementById("akte-title-edit") as HTMLInputElement).value = akte.title;
(document.getElementById("akte-ref-display") as HTMLElement).textContent = akte.aktenzeichen;
const officeChip = document.getElementById("akte-office-chip")!;
officeChip.className = `akten-office-chip akten-office-${akte.owning_office}`;
officeChip.textContent = t(`office.${akte.owning_office}`) || akte.owning_office;
const statusChip = document.getElementById("akte-status-chip")!;
statusChip.className = `akten-status-chip akten-status-${akte.status}`;
statusChip.textContent = t(`akten.status.${akte.status}`) || akte.status;
const firmWideChip = document.getElementById("akte-firmwide-chip")!;
if (akte.firm_wide_visible) {
firmWideChip.style.display = "";
firmWideChip.textContent = t("akten.detail.firmwide.on");
} else {
firmWideChip.style.display = "none";
}
// Delete visibility: partner/admin only
const deleteWrap = document.getElementById("akte-delete-wrap")!;
if (me && (me.role === "partner" || me.role === "admin")) {
deleteWrap.style.display = "";
} else {
deleteWrap.style.display = "none";
}
}
function renderEvents() {
const list = document.getElementById("akten-events-list")!;
const empty = document.getElementById("akten-events-empty")!;
if (events.length === 0) {
list.innerHTML = "";
empty.style.display = "block";
return;
}
empty.style.display = "none";
list.innerHTML = events
.map(
(e) => `<li class="akten-event">
<div class="akten-event-date">${fmtDateTime(e.created_at)}</div>
<div class="akten-event-body">
<div class="akten-event-title">${esc(e.title)}</div>
${e.description ? `<div class="akten-event-desc">${esc(e.description)}</div>` : ""}
</div>
</li>`,
)
.join("");
}
function renderParteien() {
const tbody = document.getElementById("parteien-body")!;
const empty = document.getElementById("parteien-empty")!;
const tableWrap = tbody.closest<HTMLElement>("table")!;
if (parteien.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
empty.style.display = "block";
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
tbody.innerHTML = parteien
.map((p) => {
const roleKey = p.role ? `akten.detail.parteien.role.${p.role}` : "";
const roleLabel = p.role ? t(roleKey) || p.role : "";
return `<tr data-id="${esc(p.id)}">
<td>${esc(p.name)}</td>
<td>${esc(roleLabel)}</td>
<td>${esc(p.representative || "")}</td>
<td class="akten-col-actions">
<button type="button" class="btn-link-danger partei-remove" data-i18n="akten.detail.parteien.remove">Entfernen</button>
</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>(".partei-remove").forEach((btn) => {
btn.textContent = t("akten.detail.parteien.remove");
btn.addEventListener("click", async () => {
const row = btn.closest<HTMLTableRowElement>("tr")!;
const id = row.dataset.id!;
if (!confirm(t("akten.detail.parteien.remove.confirm"))) return;
const resp = await fetch(`/api/parteien/${id}`, { method: "DELETE" });
if (resp.ok && akte) {
await loadParteien(akte.id);
renderParteien();
}
});
});
}
function showTab(tab: TabId) {
document.querySelectorAll<HTMLElement>(".akten-tab").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === tab);
});
document.querySelectorAll<HTMLElement>(".akten-tab-panel").forEach((el) => {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
// Deep-link via pushState so sub-routes stay shareable.
if (akte) {
const newPath = `/akten/${akte.id}/${tab}`;
if (window.location.pathname !== newPath) {
window.history.replaceState({}, "", newPath);
}
}
}
function initTabs() {
document.querySelectorAll<HTMLAnchorElement>(".akten-tab").forEach((tab) => {
tab.addEventListener("click", (e) => {
e.preventDefault();
showTab(tab.dataset.tab as TabId);
});
});
}
function initTitleEdit() {
const display = document.getElementById("akte-title-display")!;
const editInput = document.getElementById("akte-title-edit") as HTMLInputElement;
const editBtn = document.getElementById("akte-edit-btn") as HTMLButtonElement;
const saveBtn = document.getElementById("akte-save-btn") as HTMLButtonElement;
editBtn.addEventListener("click", () => {
display.style.display = "none";
editInput.style.display = "";
saveBtn.style.display = "";
editBtn.style.display = "none";
editInput.focus();
editInput.select();
});
saveBtn.addEventListener("click", async () => {
if (!akte) return;
const newTitle = editInput.value.trim();
if (!newTitle || newTitle === akte.title) {
cancelEdit();
return;
}
saveBtn.disabled = true;
try {
const resp = await fetch(`/api/akten/${akte.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title: newTitle }),
});
if (resp.ok) {
akte = await resp.json();
renderHeader();
if (akte) await loadEvents(akte.id);
renderEvents();
}
} finally {
saveBtn.disabled = false;
cancelEdit();
}
});
function cancelEdit() {
display.style.display = "";
editInput.style.display = "none";
saveBtn.style.display = "none";
editBtn.style.display = "";
}
}
function initParteienForm() {
const addBtn = document.getElementById("partei-add-btn") as HTMLButtonElement;
const form = document.getElementById("partei-form") as HTMLFormElement;
const cancelBtn = document.getElementById("partei-cancel") as HTMLButtonElement;
const msg = document.getElementById("partei-msg")!;
addBtn.addEventListener("click", () => {
form.style.display = "";
addBtn.style.display = "none";
(document.getElementById("partei-name") as HTMLInputElement).focus();
});
cancelBtn.addEventListener("click", () => {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
msg.textContent = "";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!akte) return;
const name = (document.getElementById("partei-name") as HTMLInputElement).value.trim();
const role = (document.getElementById("partei-role") as HTMLSelectElement).value;
const rep = (document.getElementById("partei-rep") as HTMLInputElement).value.trim();
if (!name) return;
msg.textContent = "";
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
const payload: Record<string, unknown> = { name, role };
if (rep) payload.representative = rep;
try {
const resp = await fetch(`/api/akten/${akte.id}/parteien`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
await loadParteien(akte.id);
renderParteien();
await loadEvents(akte.id);
renderEvents();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("akten.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("akten.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
function initDelete() {
const btn = document.getElementById("akte-delete-btn")!;
const modal = document.getElementById("delete-modal")!;
const close = document.getElementById("delete-modal-close")!;
const cancel = document.getElementById("delete-modal-cancel")!;
const confirmBtn = document.getElementById("delete-modal-confirm") as HTMLButtonElement;
btn.addEventListener("click", () => {
modal.style.display = "flex";
});
const closeModal = () => {
modal.style.display = "none";
};
close.addEventListener("click", closeModal);
cancel.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeModal();
});
confirmBtn.addEventListener("click", async () => {
if (!akte) return;
confirmBtn.disabled = true;
const resp = await fetch(`/api/akten/${akte.id}`, { method: "DELETE" });
if (resp.ok) {
window.location.href = "/akten";
} else {
confirmBtn.disabled = false;
closeModal();
}
});
}
async function main() {
const id = parseAkteID();
const loading = document.getElementById("akten-detail-loading")!;
const notfound = document.getElementById("akten-detail-notfound")!;
const body = document.getElementById("akten-detail-body")!;
if (!id) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await loadMe();
const ok = await loadAkte(id);
if (!ok || !akte) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
await Promise.all([loadParteien(id), loadEvents(id)]);
loading.style.display = "none";
body.style.display = "";
renderHeader();
renderParteien();
renderEvents();
initTabs();
initTitleEdit();
initParteienForm();
initDelete();
showTab(parseTab());
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(() => {
renderHeader();
renderEvents();
renderParteien();
});
main();
});

View File

@@ -0,0 +1,232 @@
import { initI18n, t } from "./i18n";
import { initSidebar } from "./sidebar";
interface User {
id: string;
email: string;
display_name: string;
office: string;
role: string;
}
interface Me {
id: string;
email: string;
display_name: string;
office: string;
role: string;
}
const selectedCollabs = new Map<string, User>();
let allUsers: User[] = [];
let me: Me | null = null;
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.status === 404) {
showError(t("akten.onboarding.required"));
disableForm();
return;
}
if (!resp.ok) return;
me = await resp.json();
if (!me) return;
const officeSelect = document.getElementById("akte-office") as HTMLSelectElement;
officeSelect.value = me.office;
if (me.role !== "admin") {
officeSelect.disabled = true;
}
if (me.role === "partner" || me.role === "admin") {
document.getElementById("firm-wide-wrap")!.style.display = "";
}
} catch {
/* non-fatal — form still works */
}
}
async function loadUsers() {
try {
const resp = await fetch("/api/users");
if (!resp.ok) return;
allUsers = await resp.json();
} catch {
/* non-fatal — collaborator picker disabled silently */
}
}
function renderCollabChips() {
const wrap = document.getElementById("akte-collab-list")!;
wrap.innerHTML = Array.from(selectedCollabs.values())
.map(
(u) =>
`<span class="akten-chip" data-id="${esc(u.id)}">${esc(u.display_name || u.email)}<button type="button" class="akten-chip-x" aria-label="remove">\u00d7</button></span>`,
)
.join("");
wrap.querySelectorAll<HTMLButtonElement>(".akten-chip-x").forEach((btn) => {
btn.addEventListener("click", (e) => {
e.preventDefault();
const chip = btn.closest<HTMLElement>(".akten-chip")!;
selectedCollabs.delete(chip.dataset.id!);
renderCollabChips();
});
});
}
function initCollabPicker() {
const input = document.getElementById("akte-collab-input") as HTMLInputElement;
const suggestions = document.getElementById("akte-collab-suggestions")!;
input.addEventListener("input", () => {
const q = input.value.trim().toLowerCase();
if (!q) {
suggestions.innerHTML = "";
suggestions.style.display = "none";
return;
}
const matches = allUsers
.filter((u) => !selectedCollabs.has(u.id) && (!me || u.id !== me.id))
.filter(
(u) =>
u.email.toLowerCase().includes(q) ||
(u.display_name && u.display_name.toLowerCase().includes(q)),
)
.slice(0, 8);
if (matches.length === 0) {
suggestions.innerHTML = "";
suggestions.style.display = "none";
return;
}
suggestions.innerHTML = matches
.map(
(u) =>
`<button type="button" class="akten-suggestion" data-id="${esc(u.id)}">${esc(u.display_name || u.email)}<span class="akten-suggestion-meta">${esc(u.email)}</span></button>`,
)
.join("");
suggestions.style.display = "block";
suggestions.querySelectorAll<HTMLButtonElement>(".akten-suggestion").forEach((btn) => {
btn.addEventListener("click", () => {
const id = btn.dataset.id!;
const user = allUsers.find((u) => u.id === id);
if (user) {
selectedCollabs.set(id, user);
renderCollabChips();
}
input.value = "";
suggestions.innerHTML = "";
suggestions.style.display = "none";
});
});
});
// Hide suggestions on outside click
document.addEventListener("click", (e) => {
if (!(e.target as HTMLElement).closest(".akten-collab")) {
suggestions.style.display = "none";
}
});
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function showError(msg: string) {
const el = document.getElementById("akten-neu-msg")!;
el.textContent = msg;
el.className = "form-msg form-msg-error";
}
function disableForm() {
const form = document.getElementById("akten-neu-form") as HTMLFormElement;
form.querySelectorAll<HTMLInputElement>("input, select, textarea, button[type=submit]").forEach((el) => {
el.disabled = true;
});
}
async function submitForm(e: Event) {
e.preventDefault();
const msg = document.getElementById("akten-neu-msg")!;
const submitBtn = document.querySelector<HTMLButtonElement>("#akten-neu-form button[type=submit]")!;
const title = (document.getElementById("akte-title") as HTMLInputElement).value.trim();
const ref = (document.getElementById("akte-ref") as HTMLInputElement).value.trim();
const office = (document.getElementById("akte-office") as HTMLSelectElement).value;
const status = (document.getElementById("akte-status") as HTMLSelectElement).value;
const court = (document.getElementById("akte-court") as HTMLInputElement).value.trim();
const courtRef = (document.getElementById("akte-courtref") as HTMLInputElement).value.trim();
const akteType = (document.getElementById("akte-type") as HTMLInputElement).value.trim();
const firmWide =
me &&
(me.role === "partner" || me.role === "admin") &&
(document.getElementById("akte-firmwide") as HTMLInputElement).checked;
if (!title || !ref) {
showError(t("akten.error.required"));
return;
}
msg.textContent = "";
msg.className = "form-msg";
submitBtn.disabled = true;
const payload: Record<string, unknown> = {
title,
aktenzeichen: ref,
owning_office: office,
status,
};
if (court) payload.court = court;
if (courtRef) payload.court_ref = courtRef;
if (akteType) payload.akte_type = akteType;
try {
const resp = await fetch("/api/akten", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.status === 401 || resp.status === 403) {
showError(t("akten.error.forbidden"));
submitBtn.disabled = false;
return;
}
if (!resp.ok) {
const data = await resp.json().catch(() => ({}) as { error?: string });
showError(data.error || t("akten.error.generic"));
submitBtn.disabled = false;
return;
}
const akte = await resp.json();
const collabIds = Array.from(selectedCollabs.keys());
if (collabIds.length > 0 || firmWide) {
const patch: Record<string, unknown> = {};
if (collabIds.length > 0) patch.collaborators = collabIds;
if (firmWide) patch.firm_wide_visible = true;
await fetch(`/api/akten/${akte.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(patch),
});
}
window.location.href = `/akten/${akte.id}`;
} catch {
showError(t("akten.error.generic"));
submitBtn.disabled = false;
}
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initCollabPicker();
document.getElementById("akten-neu-form")!.addEventListener("submit", submitForm);
loadMe();
loadUsers();
});

View File

@@ -0,0 +1,167 @@
import { initI18n, onLangChange, t, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
interface Akte {
id: string;
aktenzeichen: string;
title: string;
status: string;
owning_office: string;
firm_wide_visible: boolean;
updated_at: string;
}
let allAkten: Akte[] = [];
let officeFilter = "";
let statusFilter = "";
let searchQuery = "";
let loadedOK = false;
async function loadAkten() {
const unavailable = document.getElementById("akten-unavailable")!;
const table = document.querySelector<HTMLElement>(".akten-table-wrap")!;
try {
const resp = await fetch("/api/akten");
if (resp.status === 503) {
unavailable.style.display = "block";
table.style.display = "none";
document.getElementById("akten-empty")!.style.display = "none";
return;
}
if (!resp.ok) {
unavailable.style.display = "block";
table.style.display = "none";
return;
}
allAkten = await resp.json();
loadedOK = true;
render();
} catch {
unavailable.style.display = "block";
table.style.display = "none";
}
}
function getFiltered(): Akte[] {
let rows = allAkten;
if (officeFilter) rows = rows.filter((a) => a.owning_office === officeFilter);
if (statusFilter) rows = rows.filter((a) => a.status === statusFilter);
if (searchQuery) {
const q = searchQuery.toLowerCase();
rows = rows.filter(
(a) =>
a.title.toLowerCase().includes(q) ||
a.aktenzeichen.toLowerCase().includes(q),
);
}
return rows;
}
function render() {
if (!loadedOK) return;
const tbody = document.getElementById("akten-body")!;
const empty = document.getElementById("akten-empty")!;
const emptyFiltered = document.getElementById("akten-empty-filtered")!;
const tableWrap = document.querySelector<HTMLElement>(".akten-table-wrap")!;
const count = document.getElementById("akten-count")!;
const filtered = getFiltered();
count.textContent = `${filtered.length} / ${allAkten.length}`;
if (allAkten.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
empty.style.display = "block";
emptyFiltered.style.display = "none";
return;
}
if (filtered.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
empty.style.display = "none";
emptyFiltered.style.display = "block";
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
emptyFiltered.style.display = "none";
tbody.innerHTML = filtered
.map((a) => {
const statusKey = `akten.status.${a.status}`;
const statusLabel = t(statusKey);
const officeLabel = t(`office.${a.owning_office}`) || a.owning_office;
const firmWide = a.firm_wide_visible
? `<span class="akten-firmwide-dot" title="${escAttr(t("akten.detail.firmwide.on"))}">\u2737</span>`
: "";
return `<tr class="akten-row" data-id="${esc(a.id)}">
<td class="akten-col-title">${esc(a.title)} ${firmWide}</td>
<td class="akten-col-ref">${esc(a.aktenzeichen)}</td>
<td><span class="akten-office-chip akten-office-${esc(a.owning_office)}">${esc(officeLabel)}</span></td>
<td><span class="akten-status-chip akten-status-${esc(a.status)}">${esc(statusLabel)}</span></td>
<td class="akten-col-updated">${fmtDate(a.updated_at)}</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLTableRowElement>(".akten-row").forEach((row) => {
row.addEventListener("click", () => {
const id = row.dataset.id!;
window.location.href = `/akten/${id}`;
});
});
}
function fmtDate(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function initSearch() {
const input = document.getElementById("akten-search") as HTMLInputElement;
input.addEventListener("input", () => {
searchQuery = input.value.trim();
render();
});
}
function initFilters() {
const office = document.getElementById("akten-office") as HTMLSelectElement;
const status = document.getElementById("akten-status") as HTMLSelectElement;
office.addEventListener("change", () => {
officeFilter = office.value;
render();
});
status.addEventListener("change", () => {
statusFilter = status.value;
render();
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
initSearch();
initFilters();
onLangChange(render);
loadAkten();
});

View File

@@ -21,6 +21,14 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.checklisten": "Checklisten",
"nav.gerichte": "Gerichte",
"nav.logout": "Abmelden",
"nav.akten": "Akten",
"nav.fristen": "Fristen",
"nav.termine": "Termine",
"nav.group.arbeit": "Arbeit",
"nav.group.werkzeuge": "Werkzeuge",
"nav.group.wissen": "Wissen",
"nav.group.ressourcen": "Ressourcen",
"nav.soon.tooltip": "Bald verf\u00fcgbar",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 Nur f\u00fcr internen Gebrauch. Hogan Lovells Patent Practice.",
@@ -343,6 +351,110 @@ const translations: Record<Lang, Record<string, string>> = {
// Index \u2014 Gerichte card
"index.gerichte.title": "Gerichtsverzeichnis",
"index.gerichte.desc": "Gerichte, UPC-Kammern und Patent\u00e4mter auf einen Blick \u2014 mit Adressen, Einreichungshinweisen und Sprachen.",
// Akten (matters) \u2014 list
"akten.title": "Akten \u2014 Paliad",
"akten.heading": "Akten",
"akten.subtitle": "B\u00fcro-bezogene Mandate. Verlauf, Parteien und (bald) Fristen & Termine an einem Ort.",
"akten.new": "Neue Akte",
"akten.search.placeholder": "Titel oder Aktenzeichen suchen\u2026",
"akten.filter.office": "B\u00fcro",
"akten.filter.office.all": "Alle B\u00fcros",
"akten.filter.status": "Status",
"akten.filter.status.all": "Alle Status",
"akten.filter.status.active": "Aktiv",
"akten.filter.status.completed": "Abgeschlossen",
"akten.filter.status.archived": "Archiviert",
"akten.status.active": "Aktiv",
"akten.status.completed": "Abgeschlossen",
"akten.status.archived": "Archiviert",
"akten.col.title": "Titel",
"akten.col.ref": "Aktenzeichen",
"akten.col.office": "B\u00fcro",
"akten.col.status": "Status",
"akten.col.updated": "Zuletzt ge\u00e4ndert",
"akten.empty.title": "Noch keine Akte angelegt",
"akten.empty.hint": "Starten Sie \u00fcber \u201eNeue Akte\u201c \u2014 Sie sehen hier sp\u00e4ter Ihre Mandate, nach B\u00fcro gefiltert.",
"akten.empty.filtered": "Keine Treffer f\u00fcr diese Filter.",
"akten.unavailable": "Aktenverwaltung zurzeit nicht verf\u00fcgbar \u2014 bitte Administrator kontaktieren.",
"akten.onboarding.required": "Bitte schlie\u00dfen Sie das Onboarding ab, bevor Sie Akten anlegen.",
// Akten \u2014 create form
"akten.neu.title": "Neue Akte \u2014 Paliad",
"akten.neu.heading": "Neue Akte anlegen",
"akten.neu.subtitle": "Anlegen eines neuen Mandats im eigenen B\u00fcro. Sichtbarkeit folgt der B\u00fcro-Regel; Partner k\u00f6nnen firmenweite Sichtbarkeit aktivieren.",
"akten.field.title": "Titel",
"akten.field.title.placeholder": "Kurzbezeichnung des Mandats",
"akten.field.ref": "Aktenzeichen",
"akten.field.ref.placeholder": "z.\u202fB. HL-2026-0042",
"akten.field.office": "Federf\u00fchrendes B\u00fcro",
"akten.field.status": "Status",
"akten.field.court": "Gericht (optional)",
"akten.field.courtRef": "Gerichtsaktenzeichen (optional)",
"akten.field.akteType": "Verfahrensart (optional)",
"akten.field.description": "Beschreibung (optional)",
"akten.field.description.placeholder": "Kurzer Sachverhalt, Streitstand\u2026",
"akten.field.firmWide": "Firmenweit sichtbar",
"akten.field.firmWide.hint": "Wenn aktiviert, sehen alle Lawyer diese Akte. Nur f\u00fcr Partner/Admin.",
"akten.field.collaborators": "Weitere Bearbeiter (optional)",
"akten.field.collaborators.placeholder": "Name oder E-Mail tippen\u2026",
"akten.field.collaborators.hint": "Personen, die auch Zugriff erhalten sollen (auch b\u00fcro\u00fcbergreifend).",
"akten.submit": "Akte anlegen",
"akten.cancel": "Abbrechen",
"akten.error.required": "Titel und Aktenzeichen sind Pflichtfelder.",
"akten.error.generic": "Fehler beim Anlegen. Bitte erneut versuchen.",
"akten.error.forbidden": "Nicht erlaubt. Pr\u00fcfen Sie Rolle und B\u00fcro.",
// Akten \u2014 detail
"akten.detail.title": "Akte \u2014 Paliad",
"akten.detail.back": "\u2190 Zur\u00fcck zur \u00dcbersicht",
"akten.detail.edit": "Bearbeiten",
"akten.detail.save": "Speichern",
"akten.detail.delete": "Akte l\u00f6schen",
"akten.detail.delete.confirm.title": "Akte wirklich l\u00f6schen?",
"akten.detail.delete.confirm.body": "Die Akte wird archiviert. Sie kann nicht direkt wiederhergestellt werden.",
"akten.detail.delete.confirm.ok": "L\u00f6schen",
"akten.detail.delete.confirm.cancel": "Abbrechen",
"akten.detail.firmwide.on": "Firmenweit sichtbar",
"akten.detail.firmwide.off": "B\u00fcro-intern",
"akten.detail.tab.verlauf": "Verlauf",
"akten.detail.tab.parteien": "Parteien",
"akten.detail.tab.fristen": "Fristen",
"akten.detail.tab.termine": "Termine",
"akten.detail.tab.dokumente": "Dokumente",
"akten.detail.tab.notizen": "Notizen",
"akten.detail.soon": "Bald verf\u00fcgbar",
"akten.detail.soon.fristen": "Fristenverwaltung kommt in Phase E \u2014 diese Akte wird dann Fristen anzeigen.",
"akten.detail.soon.termine": "Termine & CalDAV-Sync folgen in Phase F.",
"akten.detail.soon.dokumente": "Dokumenten-Upload folgt in Phase H.",
"akten.detail.soon.notizen": "Notizfunktion folgt in Phase I.",
"akten.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
"akten.detail.parteien.add": "Partei hinzuf\u00fcgen",
"akten.detail.parteien.empty": "Noch keine Parteien eingetragen.",
"akten.detail.parteien.col.name": "Name",
"akten.detail.parteien.col.role": "Rolle",
"akten.detail.parteien.col.rep": "Vertreter",
"akten.detail.parteien.role.claimant": "Kl\u00e4ger",
"akten.detail.parteien.role.defendant": "Beklagter",
"akten.detail.parteien.role.thirdparty": "Streitverk\u00fcndeter / Drittpartei",
"akten.detail.parteien.remove": "Entfernen",
"akten.detail.parteien.remove.confirm": "Diese Partei wirklich entfernen?",
"akten.detail.parteien.form.name": "Name",
"akten.detail.parteien.form.role": "Rolle",
"akten.detail.parteien.form.rep": "Vertreter (optional)",
"akten.detail.parteien.form.submit": "Hinzuf\u00fcgen",
"akten.detail.parteien.form.cancel": "Abbrechen",
"akten.detail.loading": "L\u00e4dt \u2026",
"akten.detail.notfound": "Akte nicht gefunden oder keine Berechtigung.",
// Office labels (shared)
"office.munich": "M\u00fcnchen",
"office.duesseldorf": "D\u00fcsseldorf",
"office.hamburg": "Hamburg",
"office.amsterdam": "Amsterdam",
"office.london": "London",
"office.paris": "Paris",
"office.milan": "Mailand",
},
en: {
@@ -357,6 +469,14 @@ const translations: Record<Lang, Record<string, string>> = {
"nav.checklisten": "Checklists",
"nav.gerichte": "Courts",
"nav.logout": "Sign Out",
"nav.akten": "Matters",
"nav.fristen": "Deadlines",
"nav.termine": "Appointments",
"nav.group.arbeit": "Work",
"nav.group.werkzeuge": "Tools",
"nav.group.wissen": "Knowledge",
"nav.group.ressourcen": "Resources",
"nav.soon.tooltip": "Coming soon",
// Footer
"footer.text": "\u00a9 2026 Paliad \u2014 Internal use only. Hogan Lovells Patent Practice.",
@@ -679,6 +799,110 @@ const translations: Record<Lang, Record<string, string>> = {
// Index \u2014 Gerichte card
"index.gerichte.title": "Court Directory",
"index.gerichte.desc": "Courts, UPC divisions and patent offices at a glance \u2014 addresses, filing details, and languages.",
// Akten (matters) \u2014 list
"akten.title": "Matters \u2014 Paliad",
"akten.heading": "Matters",
"akten.subtitle": "Office-scoped mandates. Activity, parties, and (soon) deadlines & appointments in one place.",
"akten.new": "New Matter",
"akten.search.placeholder": "Search title or reference\u2026",
"akten.filter.office": "Office",
"akten.filter.office.all": "All offices",
"akten.filter.status": "Status",
"akten.filter.status.all": "All statuses",
"akten.filter.status.active": "Active",
"akten.filter.status.completed": "Completed",
"akten.filter.status.archived": "Archived",
"akten.status.active": "Active",
"akten.status.completed": "Completed",
"akten.status.archived": "Archived",
"akten.col.title": "Title",
"akten.col.ref": "Reference",
"akten.col.office": "Office",
"akten.col.status": "Status",
"akten.col.updated": "Last updated",
"akten.empty.title": "No matters yet",
"akten.empty.hint": "Start via \u201cNew Matter\u201d \u2014 your mandates will appear here, filterable by office.",
"akten.empty.filtered": "No matches for these filters.",
"akten.unavailable": "Matter management temporarily unavailable \u2014 contact admin.",
"akten.onboarding.required": "Please complete onboarding before creating matters.",
// Akten \u2014 create form
"akten.neu.title": "New Matter \u2014 Paliad",
"akten.neu.heading": "Create a new matter",
"akten.neu.subtitle": "Register a new mandate in your office. Visibility follows the office rule; partners may enable firm-wide visibility.",
"akten.field.title": "Title",
"akten.field.title.placeholder": "Short name for the matter",
"akten.field.ref": "Reference number",
"akten.field.ref.placeholder": "e.g. HL-2026-0042",
"akten.field.office": "Owning office",
"akten.field.status": "Status",
"akten.field.court": "Court (optional)",
"akten.field.courtRef": "Court reference (optional)",
"akten.field.akteType": "Proceeding type (optional)",
"akten.field.description": "Description (optional)",
"akten.field.description.placeholder": "Brief summary, current state\u2026",
"akten.field.firmWide": "Firm-wide visible",
"akten.field.firmWide.hint": "When enabled, all lawyers can see this matter. Partners/admins only.",
"akten.field.collaborators": "Additional collaborators (optional)",
"akten.field.collaborators.placeholder": "Type a name or email\u2026",
"akten.field.collaborators.hint": "Users who should also have access (including cross-office).",
"akten.submit": "Create matter",
"akten.cancel": "Cancel",
"akten.error.required": "Title and reference are required.",
"akten.error.generic": "Error creating matter. Please try again.",
"akten.error.forbidden": "Not allowed. Check your role and office.",
// Akten \u2014 detail
"akten.detail.title": "Matter \u2014 Paliad",
"akten.detail.back": "\u2190 Back to list",
"akten.detail.edit": "Edit",
"akten.detail.save": "Save",
"akten.detail.delete": "Delete matter",
"akten.detail.delete.confirm.title": "Really delete this matter?",
"akten.detail.delete.confirm.body": "The matter will be archived. It cannot be restored directly.",
"akten.detail.delete.confirm.ok": "Delete",
"akten.detail.delete.confirm.cancel": "Cancel",
"akten.detail.firmwide.on": "Firm-wide visible",
"akten.detail.firmwide.off": "Office-only",
"akten.detail.tab.verlauf": "Activity",
"akten.detail.tab.parteien": "Parties",
"akten.detail.tab.fristen": "Deadlines",
"akten.detail.tab.termine": "Appointments",
"akten.detail.tab.dokumente": "Documents",
"akten.detail.tab.notizen": "Notes",
"akten.detail.soon": "Coming soon",
"akten.detail.soon.fristen": "Deadline management ships in Phase E \u2014 this matter will then list its deadlines here.",
"akten.detail.soon.termine": "Appointments & CalDAV sync follow in Phase F.",
"akten.detail.soon.dokumente": "Document upload lands in Phase H.",
"akten.detail.soon.notizen": "Notes ship in Phase I.",
"akten.detail.verlauf.empty": "No events recorded yet.",
"akten.detail.parteien.add": "Add party",
"akten.detail.parteien.empty": "No parties recorded yet.",
"akten.detail.parteien.col.name": "Name",
"akten.detail.parteien.col.role": "Role",
"akten.detail.parteien.col.rep": "Representative",
"akten.detail.parteien.role.claimant": "Claimant",
"akten.detail.parteien.role.defendant": "Defendant",
"akten.detail.parteien.role.thirdparty": "Third party",
"akten.detail.parteien.remove": "Remove",
"akten.detail.parteien.remove.confirm": "Really remove this party?",
"akten.detail.parteien.form.name": "Name",
"akten.detail.parteien.form.role": "Role",
"akten.detail.parteien.form.rep": "Representative (optional)",
"akten.detail.parteien.form.submit": "Add",
"akten.detail.parteien.form.cancel": "Cancel",
"akten.detail.loading": "Loading\u2026",
"akten.detail.notfound": "Matter not found or not accessible.",
// Office labels (shared)
"office.munich": "Munich",
"office.duesseldorf": "D\u00fcsseldorf",
"office.hamburg": "Hamburg",
"office.amsterdam": "Amsterdam",
"office.london": "London",
"office.paris": "Paris",
"office.milan": "Milan",
},
};
@@ -713,6 +937,10 @@ function applyTranslations() {
const key = el.getAttribute("data-i18n-placeholder")!;
(el as HTMLInputElement).placeholder = t(key);
});
document.querySelectorAll<HTMLElement>("[data-i18n-title]").forEach((el) => {
const key = el.getAttribute("data-i18n-title")!;
el.setAttribute("title", t(key));
});
}
function updateToggle() {

View File

@@ -13,13 +13,19 @@ const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor
const ICON_LOGOUT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/></svg>';
const ICON_PIN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 4v6l-2 4h10l-2-4V4"/><line x1="12" y1="16" x2="12" y2="21"/><line x1="8" y1="4" x2="16" y2="4"/></svg>';
const ICON_MENU = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="20" y2="12"/><line x1="4" y1="18" x2="20" y2="18"/></svg>';
const ICON_FOLDER = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>';
const ICON_CALENDAR = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>';
interface SidebarProps {
currentPath: string;
}
function navItem(href: string, icon: string, i18nKey: string, label: string, currentPath: string): string {
const active = href === currentPath;
// "Active" is true for the item whose href is a prefix of currentPath.
// That way sub-routes like /akten/{id}/verlauf keep the /akten entry lit.
const active =
href === currentPath ||
(href !== "/" && currentPath.startsWith(href + "/"));
return (
<a href={href} className={`sidebar-item${active ? " active" : ""}`}>
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: icon }} />
@@ -28,6 +34,24 @@ function navItem(href: string, icon: string, i18nKey: string, label: string, cur
);
}
function navItemDisabled(icon: string, i18nKey: string, label: string, tooltipI18n: string, tooltipText: string): string {
return (
<span className="sidebar-item sidebar-item-disabled" title={tooltipText} data-i18n-title={tooltipI18n}>
<span className="sidebar-icon" dangerouslySetInnerHTML={{ __html: icon }} />
<span className="sidebar-label" data-i18n={i18nKey}>{label}</span>
</span>
);
}
function group(i18nKey: string, label: string, children: string): string {
return (
<div className="sidebar-group">
<div className="sidebar-group-label" data-i18n={i18nKey}>{label}</div>
{children}
</div>
);
}
export function Sidebar({ currentPath }: SidebarProps): string {
return (
<Fragment>
@@ -44,14 +68,29 @@ export function Sidebar({ currentPath }: SidebarProps): string {
<nav className="sidebar-nav">
{navItem("/", ICON_HOME, "nav.home", "Home", currentPath)}
{navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath)}
{navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath)}
{navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath)}
{navItem("/checklisten", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath)}
{navItem("/glossar", ICON_BOOK, "nav.glossar", "Glossar", currentPath)}
{navItem("/gerichte", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath)}
{navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath)}
{navItem("/links", ICON_LINK, "nav.links", "Links", currentPath)}
{group("nav.group.arbeit", "Arbeit",
navItem("/akten", ICON_FOLDER, "nav.akten", "Akten", currentPath) +
navItemDisabled(ICON_CLOCK, "nav.fristen", "Fristen", "nav.soon.tooltip", "Bald verfügbar") +
navItemDisabled(ICON_CALENDAR, "nav.termine", "Termine", "nav.soon.tooltip", "Bald verfügbar"),
)}
{group("nav.group.werkzeuge", "Werkzeuge",
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath),
)}
{group("nav.group.wissen", "Wissen",
navItem("/checklisten", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
navItem("/glossar", ICON_BOOK, "nav.glossar", "Glossar", currentPath) +
navItem("/gerichte", ICON_BUILDING, "nav.gerichte", "Gerichte", currentPath),
)}
{group("nav.group.ressourcen", "Ressourcen",
navItem("/downloads", ICON_DOWNLOAD, "nav.downloads", "Downloads", currentPath) +
navItem("/links", ICON_LINK, "nav.links", "Links", currentPath),
)}
</nav>
<div className="sidebar-spacer" />

View File

@@ -3470,3 +3470,683 @@ input[type="range"]::-moz-range-thumb {
display: contents !important;
}
}
/* ============================================================================
Phase D — Akten (Mandate) UI
============================================================================ */
/* --- Sidebar groups --- */
.sidebar-group {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--color-border);
}
.sidebar-group:first-of-type {
border-top: none;
padding-top: 0.25rem;
}
.sidebar-group-label {
padding: 0 0.5rem 0.25rem 1.5rem;
font-size: 0.66rem;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--color-text-muted);
opacity: 0;
transition: opacity 0.15s ease;
white-space: nowrap;
overflow: hidden;
}
.sidebar.expanded .sidebar-group-label,
.sidebar.pinned .sidebar-group-label,
.sidebar.mobile-open .sidebar-group-label {
opacity: 0.75;
}
.sidebar-item-disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: auto;
}
.sidebar-item-disabled:hover {
background: transparent !important;
}
/* --- Akten list page --- */
.akten-header-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1.5rem;
flex-wrap: wrap;
}
.btn-cta-lime {
display: inline-flex;
align-items: center;
padding: 0.6rem 1.25rem;
font-size: 0.9rem;
font-weight: 600;
color: #1a1a2e;
background: #c6f41c;
border: none;
border-radius: var(--radius);
cursor: pointer;
text-decoration: none;
transition: background 0.15s ease, transform 0.05s ease;
}
.btn-cta-lime:hover {
background: #b8e616;
}
.btn-cta-lime:active {
transform: translateY(1px);
}
.akten-controls {
display: flex;
flex-direction: column;
gap: 0.75rem;
margin-bottom: 1.5rem;
}
.akten-search-wrap {
max-width: 460px;
}
.akten-filter-row {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.akten-filter-label {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-left: 0.5rem;
}
.akten-filter-label:first-child {
margin-left: 0;
}
.akten-select {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff;
color: var(--color-text);
cursor: pointer;
}
.akten-select:focus {
outline: none;
border-color: var(--color-accent);
}
.akten-unavailable {
padding: 1rem 1.25rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: #fff8e6;
color: #70520b;
margin-bottom: 1rem;
}
.akten-table-wrap {
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
overflow-x: auto;
box-shadow: var(--shadow);
}
.akten-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
}
.akten-table thead th {
padding: 0.75rem 1rem;
text-align: left;
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
background: #fafafa;
}
.akten-table tbody tr {
cursor: pointer;
transition: background 0.08s ease;
border-bottom: 1px solid var(--color-border);
}
.akten-table tbody tr:last-child {
border-bottom: none;
}
.akten-table tbody tr:hover {
background: #f8fbf0;
}
.akten-table td {
padding: 0.75rem 1rem;
vertical-align: middle;
}
.akten-col-title {
font-weight: 600;
}
.akten-col-ref {
font-family: var(--font-mono);
font-size: 0.82rem;
color: var(--color-text-muted);
}
.akten-col-updated {
font-size: 0.82rem;
color: var(--color-text-muted);
white-space: nowrap;
}
.akten-office-chip,
.akten-status-chip,
.akten-firmwide-chip {
display: inline-block;
padding: 0.15rem 0.55rem;
font-size: 0.72rem;
font-weight: 600;
border-radius: 999px;
white-space: nowrap;
}
.akten-office-chip {
background: #eef2ff;
color: #4338ca;
}
.akten-office-munich { background: #eef2ff; color: #4338ca; }
.akten-office-duesseldorf { background: #fef3c7; color: #92400e; }
.akten-office-hamburg { background: #dbeafe; color: #1e40af; }
.akten-office-amsterdam { background: #fee2e2; color: #991b1b; }
.akten-office-london { background: #f3e8ff; color: #6b21a8; }
.akten-office-paris { background: #e0f2fe; color: #075985; }
.akten-office-milan { background: #fce7f3; color: #9d174d; }
.akten-status-chip {
background: #e5e7eb;
color: #374151;
}
.akten-status-active {
background: #dcfce7;
color: #166534;
}
.akten-status-completed {
background: #dbeafe;
color: #1e40af;
}
.akten-status-archived {
background: #f3f4f6;
color: #6b7280;
}
.akten-firmwide-chip {
background: #c6f41c;
color: #1a1a2e;
}
.akten-firmwide-dot {
color: #c6f41c;
font-size: 0.95em;
margin-left: 0.4rem;
text-shadow: 0 0 2px rgba(26, 26, 46, 0.35);
}
.akten-empty {
text-align: center;
padding: 3rem 1.5rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
color: var(--color-text-muted);
}
.akten-empty h2 {
font-size: 1.15rem;
color: var(--color-text);
margin-bottom: 0.5rem;
}
.akten-empty .btn-cta-lime {
margin-top: 1rem;
}
.akten-empty-filtered {
padding: 2rem 1.5rem;
}
/* --- Akten create form --- */
.container-narrow {
max-width: 680px;
}
.akten-back-link {
display: inline-block;
margin-bottom: 0.75rem;
font-size: 0.85rem;
color: var(--color-text-muted);
text-decoration: none;
}
.akten-back-link:hover {
color: var(--color-accent);
}
.akten-form {
display: flex;
flex-direction: column;
gap: 1.25rem;
margin-top: 1.5rem;
}
.form-field-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 640px) {
.form-field-row {
grid-template-columns: 1fr;
}
}
.form-checkbox {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
user-select: none;
}
.form-checkbox input {
width: 1rem;
height: 1rem;
cursor: pointer;
}
.form-hint {
font-size: 0.78rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.akten-collab {
position: relative;
border: 1px solid var(--color-border);
border-radius: var(--radius);
padding: 0.4rem;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
align-items: center;
background: #fff;
}
.akten-collab input {
flex: 1;
min-width: 160px;
border: none !important;
padding: 0.3rem 0.5rem !important;
outline: none !important;
background: transparent !important;
}
.akten-collab-chips {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
}
.akten-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.2rem 0.6rem;
font-size: 0.8rem;
background: #eef2ff;
color: #4338ca;
border-radius: 999px;
}
.akten-chip-x {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 0.9rem;
line-height: 1;
padding: 0;
}
.akten-collab-suggestions {
position: absolute;
top: 100%;
left: 0;
right: 0;
margin-top: 0.25rem;
background: #fff;
border: 1px solid var(--color-border);
border-radius: var(--radius);
box-shadow: var(--shadow-md);
max-height: 240px;
overflow-y: auto;
display: none;
z-index: 10;
}
.akten-suggestion {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.5rem 0.75rem;
border: none;
background: #fff;
text-align: left;
font-size: 0.85rem;
cursor: pointer;
}
.akten-suggestion:hover {
background: #f8fbf0;
}
.akten-suggestion-meta {
color: var(--color-text-muted);
font-size: 0.75rem;
margin-left: 0.75rem;
}
/* --- Akten detail --- */
.akten-detail-header {
padding: 1rem 0;
border-bottom: 1px solid var(--color-border);
margin-bottom: 1rem;
}
.akten-detail-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 1rem;
flex-wrap: wrap;
}
.akten-detail-title-col h1 {
font-size: 1.6rem;
margin-bottom: 0.3rem;
}
.akten-title-input {
font-size: 1.6rem;
font-weight: 700;
padding: 0.25rem 0.4rem;
border: 1px solid var(--color-accent);
border-radius: var(--radius);
width: 100%;
max-width: 520px;
}
.akten-detail-meta {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: center;
margin-top: 0.4rem;
}
.akten-ref {
font-family: var(--font-mono);
font-size: 0.85rem;
color: var(--color-text-muted);
margin-right: 0.25rem;
}
.akten-detail-actions {
display: flex;
gap: 0.5rem;
align-items: center;
}
.btn-icon {
width: 36px;
height: 36px;
border-radius: var(--radius);
border: 1px solid var(--color-border);
background: #fff;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--color-text-muted);
transition: border-color 0.15s ease, color 0.15s ease;
}
.btn-icon:hover {
border-color: var(--color-accent);
color: var(--color-accent);
}
.akten-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1rem;
border-bottom: 1px solid var(--color-border);
overflow-x: auto;
}
.akten-tab {
padding: 0.6rem 1rem;
text-decoration: none;
color: var(--color-text-muted);
font-size: 0.88rem;
font-weight: 500;
border-bottom: 2px solid transparent;
white-space: nowrap;
transition: color 0.12s ease, border-color 0.12s ease;
}
.akten-tab:hover {
color: var(--color-text);
}
.akten-tab.active {
color: var(--color-text);
border-bottom-color: #c6f41c;
font-weight: 600;
}
.akten-tab-panel {
padding: 0.5rem 0 2rem;
}
.akten-events {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.akten-event {
display: grid;
grid-template-columns: 170px 1fr;
gap: 1rem;
padding: 0.75rem 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
box-shadow: var(--shadow);
}
@media (max-width: 640px) {
.akten-event {
grid-template-columns: 1fr;
}
}
.akten-event-date {
font-size: 0.8rem;
color: var(--color-text-muted);
font-family: var(--font-mono);
}
.akten-event-title {
font-weight: 600;
font-size: 0.9rem;
}
.akten-event-desc {
font-size: 0.85rem;
color: var(--color-text-muted);
margin-top: 0.25rem;
}
.akten-events-empty {
text-align: center;
padding: 2rem 1rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}
.akten-parteien-controls {
margin-bottom: 1rem;
}
.btn-small {
padding: 0.4rem 0.9rem;
font-size: 0.82rem;
}
.akten-partei-form {
margin-bottom: 1.25rem;
padding: 1rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
gap: 0.75rem;
}
.akten-parteien-table {
width: 100%;
border-collapse: collapse;
font-size: 0.9rem;
border: 1px solid var(--color-border);
border-radius: var(--radius);
overflow: hidden;
background: var(--color-surface);
}
.akten-parteien-table thead th {
text-align: left;
padding: 0.6rem 1rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
border-bottom: 1px solid var(--color-border);
background: #fafafa;
}
.akten-parteien-table td {
padding: 0.6rem 1rem;
border-bottom: 1px solid var(--color-border);
}
.akten-parteien-table tr:last-child td {
border-bottom: none;
}
.akten-col-actions {
text-align: right;
}
.btn-link-danger {
background: none;
border: none;
color: #b91c1c;
cursor: pointer;
font-size: 0.82rem;
padding: 0.2rem 0.5rem;
}
.btn-link-danger:hover {
text-decoration: underline;
}
.btn-danger {
padding: 0.5rem 1.25rem;
font-size: 0.85rem;
font-weight: 600;
color: #fff;
background: #dc2626;
border: none;
border-radius: var(--radius);
cursor: pointer;
transition: background 0.15s ease;
}
.btn-danger:hover {
background: #b91c1c;
}
.akten-soon {
text-align: center;
padding: 3rem 1.5rem;
border: 1px dashed var(--color-border);
border-radius: var(--radius);
background: var(--color-surface);
}
.akten-soon h2 {
font-size: 1.1rem;
color: var(--color-text);
margin-bottom: 0.5rem;
}
.akten-soon p {
color: var(--color-text-muted);
font-size: 0.9rem;
}
.akten-detail-footer {
margin-top: 2.5rem;
padding-top: 1.5rem;
border-top: 1px solid var(--color-border);
display: flex;
justify-content: flex-end;
}
.akten-loading {
text-align: center;
padding: 3rem;
color: var(--color-text-muted);
font-size: 0.9rem;
}

View File

@@ -0,0 +1,26 @@
package handlers
import "net/http"
// Server-rendered page endpoints for the Akten UI.
//
// Like the rest of Paliad, pages are statically generated at build time
// (bun run build) and served from disk; per-page client TS bundles call the
// JSON APIs in akten.go to populate the DOM.
//
// Sub-routes (/akten/{id}/verlauf, /fristen, /termine, /dokumente, /parteien,
// /notizen) all serve the same detail HTML; client JS reads window.location to
// pick the initial tab. Fristen/Termine/Dokumente/Notizen tabs currently show
// a "Coming Soon — Phase X" panel in the client until later phases land.
func handleAktenListPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/akten.html")
}
func handleAktenNewPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/akten-neu.html")
}
func handleAktenDetailPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/akten-detail.html")
}

View File

@@ -88,9 +88,25 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("GET /api/akten/{id}", handleGetAkte)
protected.HandleFunc("PATCH /api/akten/{id}", handleUpdateAkte)
protected.HandleFunc("DELETE /api/akten/{id}", handleDeleteAkte)
protected.HandleFunc("GET /api/akten/{id}/events", handleListAkteEvents)
protected.HandleFunc("GET /api/akten/{id}/parteien", handleListParteien)
protected.HandleFunc("POST /api/akten/{id}/parteien", handleCreatePartei)
protected.HandleFunc("DELETE /api/parteien/{id}", handleDeletePartei)
protected.HandleFunc("GET /api/me", handleGetMe)
protected.HandleFunc("GET /api/users", handleListUsers)
// Phase D — server-rendered Akten pages (pre-built HTML; client TS calls
// the JSON APIs above). Sub-routes share the same detail HTML; the client
// reads window.location to pick the active tab.
protected.HandleFunc("GET /akten", handleAktenListPage)
protected.HandleFunc("GET /akten/neu", handleAktenNewPage)
protected.HandleFunc("GET /akten/{id}", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/verlauf", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/parteien", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/fristen", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/termine", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/dokumente", handleAktenDetailPage)
protected.HandleFunc("GET /akten/{id}/notizen", handleAktenDetailPage)
// Session middleware refreshes tokens; user-id middleware extracts the
// JWT sub claim into the request context for handlers that need it.

View File

@@ -0,0 +1,70 @@
package handlers
import (
"net/http"
"github.com/google/uuid"
)
// GET /api/me — returns the caller's paliad.users row (or 404 if onboarding
// hasn't happened yet). The frontend uses this to gate role-specific UI
// (partner/admin-only delete, partner-only firm_wide_visible checkbox, etc.).
func handleGetMe(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
u, err := dbSvc.users.GetByID(r.Context(), uid)
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
if u == nil {
writeJSON(w, http.StatusNotFound, map[string]string{"error": "no paliad.users row — onboarding required"})
return
}
writeJSON(w, http.StatusOK, u)
}
// GET /api/users — minimal user list for the collaborator picker. Only callable
// by authenticated users. Response is the full models.User list (email +
// display_name + office + role).
func handleListUsers(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
users, err := dbSvc.users.List(r.Context())
if err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, users)
}
// GET /api/akten/{id}/events — audit trail feed for an Akte's detail page.
func handleListAkteEvents(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
akteID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid id"})
return
}
events, err := dbSvc.akte.ListEvents(r.Context(), uid, akteID)
if err != nil {
writeServiceError(w, err)
return
}
writeJSON(w, http.StatusOK, events)
}

View File

@@ -363,6 +363,25 @@ func (s *AkteService) Delete(ctx context.Context, userID, akteID uuid.UUID) erro
return tx.Commit()
}
// ListEvents returns the audit trail for the Akte, newest first. Visibility
// is enforced through GetByID on the parent.
func (s *AkteService) ListEvents(ctx context.Context, userID, akteID uuid.UUID) ([]models.AkteEvent, error) {
if _, err := s.GetByID(ctx, userID, akteID); err != nil {
return nil, err
}
var events []models.AkteEvent
err := s.db.SelectContext(ctx, &events,
`SELECT id, akte_id, event_type, title, description, event_date,
created_by, metadata, created_at, updated_at
FROM paliad.akten_events
WHERE akte_id = $1
ORDER BY created_at DESC`, akteID)
if err != nil {
return nil, fmt.Errorf("list akte events: %w", err)
}
return events, nil
}
// insertAkteEvent appends one row to paliad.akten_events inside the given tx.
func insertAkteEvent(ctx context.Context, tx *sqlx.Tx, akteID, userID uuid.UUID, eventType, title string, description *string) error {
now := time.Now().UTC()