Merge Phase D: Akten CRUD UI
This commit is contained in:
@@ -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/");
|
||||
}
|
||||
|
||||
195
frontend/src/akten-detail.tsx
Normal file
195
frontend/src/akten-detail.tsx
Normal 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 — 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">← Zurück zur Übersicht</a>
|
||||
|
||||
<div id="akten-detail-loading" className="akten-loading">
|
||||
<p data-i18n="akten.detail.loading">Lädt…</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ü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ä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ü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ü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ügbar</h2>
|
||||
<p data-i18n="akten.detail.soon.fristen">
|
||||
Fristenverwaltung kommt in Phase E — 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ügbar</h2>
|
||||
<p data-i18n="akten.detail.soon.termine">
|
||||
Termine & 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ü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ü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ö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öschen?</h2>
|
||||
<button className="modal-close" id="delete-modal-close" type="button">×</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ö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
141
frontend/src/akten-neu.tsx
Normal 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 — 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">← Zurück zur Ü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üro. Sichtbarkeit folgt der Büro-Regel;
|
||||
Partner kö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ührendes Büro</label>
|
||||
<select id="akte-office" required>
|
||||
<option value="munich" data-i18n="office.munich">München</option>
|
||||
<option value="duesseldorf" data-i18n="office.duesseldorf">Dü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ü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üroü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
115
frontend/src/akten.tsx
Normal 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 — 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üro-bezogene Mandate. Verlauf, Parteien und (bald) Fristen & 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üro</label>
|
||||
<select id="akten-office" className="akten-select">
|
||||
<option value="" data-i18n="akten.filter.office.all">Alle Büros</option>
|
||||
<option value="munich" data-i18n="office.munich">München</option>
|
||||
<option value="duesseldorf" data-i18n="office.duesseldorf">Dü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ügbar — 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üro</th>
|
||||
<th data-i18n="akten.col.status">Status</th>
|
||||
<th data-i18n="akten.col.updated">Zuletzt geä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 über „Neue Akte“ — Sie sehen hier später Ihre Mandate, nach Bü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ür diese Filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<script src="/assets/akten.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
425
frontend/src/client/akten-detail.ts
Normal file
425
frontend/src/client/akten-detail.ts
Normal 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();
|
||||
});
|
||||
232
frontend/src/client/akten-neu.ts
Normal file
232
frontend/src/client/akten-neu.ts
Normal 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();
|
||||
});
|
||||
167
frontend/src/client/akten.ts
Normal file
167
frontend/src/client/akten.ts
Normal 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, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
@@ -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() {
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
26
internal/handlers/akten_pages.go
Normal file
26
internal/handlers/akten_pages.go
Normal 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")
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
70
internal/handlers/users.go
Normal file
70
internal/handlers/users.go
Normal 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)
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user