feat: projekte-detail rewrite + tree view + per-Dezernat member manager (follow-ups)
**akten-detail.tsx rewrite (now projekte-detail-shaped):**
- Removed office-chip, firmwide-chip (v2 no longer uses them).
- Added type-chip, ClientMatter display (inherits via ancestors when absent),
netDocuments external link.
- Breadcrumb nav above header, populated from /api/projekte/{id}/ancestors.
- New 'Untergeordnet' tab with children list from /kinder endpoint;
'Untervorhaben anlegen' link pre-fills parent via ?parent=<id>.
- New 'Team' tab: lists direct + inherited members (inheritance badge
shows ancestor title), remove button gated on self-or-partner/admin,
add form with user typeahead and role picker.
- akten-detail.ts: Akte interface rewritten (reference/type/parent_id/
path/client_number/matter_number/netdocuments_url/court/case_number).
parseAkteID now accepts both /projekte/{id} and /akten/{id}. New loaders
loadAncestors/loadChildren/loadTeam/loadUserList. TabId extended with
'team' and 'kinder'.
- akten-neu.ts: applyParentFromQueryString pre-fills parent picker when
navigated from a projekt's 'Untervorhaben anlegen' link, auto-switches
type from 'client' to 'case'.
**Tree view in Projekte list:**
- Third view mode 'tree' alongside flat/roots. Sorts filtered rows by
path (ancestors precede descendants); depth-indented title cell with
↳ branch glyph based on depthOf(path).
**Per-Dezernat member manager:**
- einstellungen Dezernat tab 'Verwalten' button now toggles an inline
manage panel per Dezernat (expanded row below the admin table row).
- Panel shows current members with per-row remove (confirm dialog).
- Add-member form with user typeahead against /api/users, posts to
/api/dezernate/{id}/members.
- Wires once per Dezernat (data-wired guard); reloads My Dezernat on
any membership change.
i18n: DE + EN keys for dezernat.manage_heading/loading/no_members/
add_member*/add/remove/confirm_remove/error.user_required and for every
projekte.type.* / projekte.team.role.* / projekte.team.direct /
projekte.team.inherited.hint / projekte.view.tree / projekte.detail.team.*
/ projekte.detail.clientmatter.inherited.
go build/vet/test + bun run build all clean.
This commit is contained in:
@@ -2,29 +2,35 @@ import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Footer } from "./components/Footer";
|
||||
|
||||
// Projekt detail shell (v2). File name + export kept for build-pipeline
|
||||
// compatibility; DOM + labels are v2 (reference not aktenzeichen, type chip,
|
||||
// breadcrumb, Team tab with inheritance badges, children section,
|
||||
// ClientMatter + netDocuments display).
|
||||
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>
|
||||
<title data-i18n="projekte.detail.title">Projekt — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/akten" />
|
||||
<Sidebar currentPath="/projekte" />
|
||||
|
||||
<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>
|
||||
<a href="/projekte" className="akten-back-link" data-i18n="projekte.detail.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<nav className="projekt-breadcrumb" id="projekt-breadcrumb" aria-label="Breadcrumb" />
|
||||
|
||||
<div id="akten-detail-loading" className="akten-loading">
|
||||
<p data-i18n="akten.detail.loading">Lädt…</p>
|
||||
<p data-i18n="projekte.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>
|
||||
<p data-i18n="projekte.detail.notfound">Projekt nicht gefunden oder keine Berechtigung.</p>
|
||||
</div>
|
||||
|
||||
<div id="akten-detail-body" style="display:none">
|
||||
@@ -34,50 +40,119 @@ export function renderAktenDetail(): string {
|
||||
<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 id="akte-type-chip" className="akten-type-chip" />
|
||||
<span className="akten-ref" id="akte-ref-display" />
|
||||
<span id="akte-office-chip" className="akten-office-chip" />
|
||||
<span id="akte-clientmatter" className="akten-ref" />
|
||||
<span id="akte-status-chip" className="akten-status-chip" />
|
||||
<span id="akte-firmwide-chip" className="akten-firmwide-chip" style="display:none" />
|
||||
<a id="akte-netdocs" className="akten-netdocs-link" target="_blank" rel="noopener" style="display:none">netDocuments ↗</a>
|
||||
</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">
|
||||
<button id="akte-edit-btn" className="btn-icon" type="button" aria-label="Bearbeiten" data-i18n-title="projekte.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>
|
||||
<button id="akte-save-btn" className="btn-primary btn-cta-lime" type="button" style="display:none" data-i18n="projekte.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="notizen" href="#" data-i18n="akten.detail.tab.notizen">Notizen</a>
|
||||
<a className="akten-tab" data-tab="checklisten" href="#" data-i18n="akten.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="akten-tab" data-tab="verlauf" href="#" data-i18n="projekte.detail.tab.verlauf">Verlauf</a>
|
||||
<a className="akten-tab" data-tab="team" href="#" data-i18n="projekte.detail.tab.team">Team</a>
|
||||
<a className="akten-tab" data-tab="kinder" href="#" data-i18n="projekte.detail.tab.kinder">Untergeordnet</a>
|
||||
<a className="akten-tab" data-tab="parteien" href="#" data-i18n="projekte.detail.tab.parteien">Parteien</a>
|
||||
<a className="akten-tab" data-tab="fristen" href="#" data-i18n="projekte.detail.tab.fristen">Fristen</a>
|
||||
<a className="akten-tab" data-tab="termine" href="#" data-i18n="projekte.detail.tab.termine">Termine</a>
|
||||
<a className="akten-tab" data-tab="notizen" href="#" data-i18n="projekte.detail.tab.notizen">Notizen</a>
|
||||
<a className="akten-tab" data-tab="checklisten" href="#" data-i18n="projekte.detail.tab.checklisten">Checklisten</a>
|
||||
</nav>
|
||||
|
||||
{/* Verlauf (Activity) */}
|
||||
{/* Verlauf */}
|
||||
<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">
|
||||
<p className="akten-events-empty" id="akten-events-empty" style="display:none" data-i18n="projekte.detail.verlauf.empty">
|
||||
Noch keine Ereignisse aufgezeichnet.
|
||||
</p>
|
||||
<div className="akten-events-loadmore" id="akten-events-loadmore-wrap" style="display:none">
|
||||
<button type="button" className="btn-secondary" id="akten-events-loadmore" data-i18n="akten.detail.verlauf.loadMore">
|
||||
<button type="button" className="btn-secondary" id="akten-events-loadmore" data-i18n="projekte.detail.verlauf.loadMore">
|
||||
Mehr laden
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Team */}
|
||||
<section className="akten-tab-panel" id="tab-team" style="display:none">
|
||||
<div className="akten-parteien-controls">
|
||||
<button id="team-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projekte.detail.team.add">
|
||||
Mitglied hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form id="team-form" className="akten-form akten-partei-form" style="display:none" autocomplete="off">
|
||||
<div className="form-field-row">
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-user-input" data-i18n="projekte.detail.team.form.user">Benutzer</label>
|
||||
<input type="text" id="team-user-input" placeholder="Name oder E-Mail..." autocomplete="off" />
|
||||
<input type="hidden" id="team-user-id" />
|
||||
<div id="team-user-suggestions" className="akten-collab-suggestions" />
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<label htmlFor="team-role" data-i18n="projekte.detail.team.form.role">Rolle</label>
|
||||
<select id="team-role">
|
||||
<option value="lead" data-i18n="projekte.team.role.lead">Lead</option>
|
||||
<option value="associate" selected data-i18n="projekte.team.role.associate">Associate</option>
|
||||
<option value="pa" data-i18n="projekte.team.role.pa">PA</option>
|
||||
<option value="of_counsel" data-i18n="projekte.team.role.of_counsel">Of Counsel</option>
|
||||
<option value="local_counsel" data-i18n="projekte.team.role.local_counsel">Local Counsel</option>
|
||||
<option value="expert" data-i18n="projekte.team.role.expert">Experte</option>
|
||||
<option value="observer" data-i18n="projekte.team.role.observer">Beobachter</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="team-cancel" data-i18n="projekte.detail.team.form.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projekte.detail.team.form.submit">Hinzufügen</button>
|
||||
</div>
|
||||
<p className="form-msg" id="team-msg" />
|
||||
</form>
|
||||
|
||||
<table className="akten-parteien-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="projekte.detail.team.col.name">Name</th>
|
||||
<th data-i18n="projekte.detail.team.col.role">Rolle</th>
|
||||
<th data-i18n="projekte.detail.team.col.source">Herkunft</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="team-body" />
|
||||
</table>
|
||||
|
||||
<p className="akten-events-empty" id="team-empty" style="display:none" data-i18n="projekte.detail.team.empty">
|
||||
Noch keine Teammitglieder.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Untergeordnet (children tree) */}
|
||||
<section className="akten-tab-panel" id="tab-kinder" style="display:none">
|
||||
<div className="akten-parteien-controls">
|
||||
<a id="child-add-link" className="btn-primary btn-cta-lime btn-small" href="/projekte/neu" data-i18n="projekte.detail.kinder.add">
|
||||
Untervorhaben anlegen
|
||||
</a>
|
||||
</div>
|
||||
<ul id="kinder-list" className="projekt-children-list" />
|
||||
<p className="akten-events-empty" id="kinder-empty" style="display:none" data-i18n="projekte.detail.kinder.empty">
|
||||
Keine untergeordneten Projekte.
|
||||
</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">
|
||||
<button id="partei-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projekte.detail.parteien.add">
|
||||
Partei hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
@@ -85,25 +160,25 @@ export function renderAktenDetail(): string {
|
||||
<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>
|
||||
<label htmlFor="partei-name" data-i18n="projekte.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>
|
||||
<label htmlFor="partei-role" data-i18n="projekte.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>
|
||||
<option value="claimant" data-i18n="projekte.detail.parteien.role.claimant">Kläger</option>
|
||||
<option value="defendant" data-i18n="projekte.detail.parteien.role.defendant">Beklagter</option>
|
||||
<option value="thirdparty" data-i18n="projekte.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>
|
||||
<label htmlFor="partei-rep" data-i18n="projekte.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>
|
||||
<button type="button" className="btn-cancel" id="partei-cancel" data-i18n="projekte.detail.parteien.form.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projekte.detail.parteien.form.submit">Hinzufügen</button>
|
||||
</div>
|
||||
<p className="form-msg" id="partei-msg" />
|
||||
</form>
|
||||
@@ -111,24 +186,24 @@ export function renderAktenDetail(): string {
|
||||
<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 data-i18n="projekte.detail.parteien.col.name">Name</th>
|
||||
<th data-i18n="projekte.detail.parteien.col.role">Rolle</th>
|
||||
<th data-i18n="projekte.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">
|
||||
<p className="akten-events-empty" id="parteien-empty" style="display:none" data-i18n="projekte.detail.parteien.empty">
|
||||
Noch keine Parteien eingetragen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Fristen — Phase E */}
|
||||
{/* Fristen */}
|
||||
<section className="akten-tab-panel" id="tab-fristen" style="display:none">
|
||||
<div className="akten-parteien-controls">
|
||||
<a id="frist-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="akten.detail.fristen.add" href="#">
|
||||
<a id="frist-add-link" className="btn-primary btn-cta-lime btn-small" data-i18n="projekte.detail.fristen.add" href="#">
|
||||
Frist hinzufügen
|
||||
</a>
|
||||
</div>
|
||||
@@ -146,15 +221,15 @@ export function renderAktenDetail(): string {
|
||||
<tbody id="akte-fristen-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="akten-events-empty" id="akte-fristen-empty" style="display:none" data-i18n="akten.detail.fristen.empty">
|
||||
Für diese Akte sind noch keine Fristen erfasst.
|
||||
<p className="akten-events-empty" id="akte-fristen-empty" style="display:none" data-i18n="projekte.detail.fristen.empty">
|
||||
Für dieses Projekt sind noch keine Fristen erfasst.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Termine — Phase F */}
|
||||
{/* Termine */}
|
||||
<section className="akten-tab-panel" id="tab-termine" style="display:none">
|
||||
<div className="akten-parteien-controls">
|
||||
<button type="button" id="termin-add-btn" className="btn-primary btn-cta-lime btn-small" data-i18n="akten.detail.termine.add">
|
||||
<button type="button" id="termin-add-btn" className="btn-primary btn-cta-lime btn-small" data-i18n="projekte.detail.termine.add">
|
||||
Termin hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
@@ -191,8 +266,8 @@ export function renderAktenDetail(): string {
|
||||
<input type="text" id="akte-termin-location" />
|
||||
</div>
|
||||
<div className="form-actions">
|
||||
<button type="button" className="btn-cancel" id="akte-termin-cancel" data-i18n="akten.detail.termine.form.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="akten.detail.termine.form.submit">Hinzufügen</button>
|
||||
<button type="button" className="btn-cancel" id="akte-termin-cancel" data-i18n="projekte.detail.termine.form.cancel">Abbrechen</button>
|
||||
<button type="submit" className="btn-primary btn-cta-lime" data-i18n="projekte.detail.termine.form.submit">Hinzufügen</button>
|
||||
</div>
|
||||
<p className="form-msg" id="akte-termin-msg" />
|
||||
</form>
|
||||
@@ -211,42 +286,42 @@ export function renderAktenDetail(): string {
|
||||
<tbody id="akte-termine-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="akten-events-empty" id="akte-termine-empty" style="display:none" data-i18n="akten.detail.termine.empty">
|
||||
Für diese Akte sind noch keine Termine erfasst.
|
||||
<p className="akten-events-empty" id="akte-termine-empty" style="display:none" data-i18n="projekte.detail.termine.empty">
|
||||
Für dieses Projekt sind noch keine Termine erfasst.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Notizen — Phase I */}
|
||||
{/* Notizen */}
|
||||
<section className="akten-tab-panel" id="tab-notizen" style="display:none">
|
||||
<div id="notes-container" className="notiz-container" data-parent-type="akte" />
|
||||
<div id="notes-container" className="notiz-container" data-parent-type="projekt" />
|
||||
</section>
|
||||
|
||||
{/* Checklisten — instances linked to this Akte */}
|
||||
{/* Checklisten */}
|
||||
<section className="akten-tab-panel" id="tab-checklisten" style="display:none">
|
||||
<p id="akte-checklisten-empty" className="akten-events-empty" style="display:none" data-i18n="akten.detail.checklisten.empty">
|
||||
Für diese Akte sind noch keine Checklisten-Instanzen erfasst.
|
||||
<p id="akte-checklisten-empty" className="akten-events-empty" style="display:none" data-i18n="projekte.detail.checklisten.empty">
|
||||
Für dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
|
||||
</p>
|
||||
<div className="akten-table-wrap" id="akte-checklisten-tablewrap" style="display:none">
|
||||
<table className="akten-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="akten.detail.checklisten.col.template">Vorlage</th>
|
||||
<th data-i18n="akten.detail.checklisten.col.name">Name</th>
|
||||
<th data-i18n="akten.detail.checklisten.col.progress">Fortschritt</th>
|
||||
<th data-i18n="akten.detail.checklisten.col.created">Angelegt</th>
|
||||
<th data-i18n="projekte.detail.checklisten.col.template">Vorlage</th>
|
||||
<th data-i18n="projekte.detail.checklisten.col.name">Name</th>
|
||||
<th data-i18n="projekte.detail.checklisten.col.progress">Fortschritt</th>
|
||||
<th data-i18n="projekte.detail.checklisten.col.created">Angelegt</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="akte-checklisten-body" />
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle akten-checklisten-hint" data-i18n="akten.detail.checklisten.hint">
|
||||
<p className="tool-subtitle akten-checklisten-hint" data-i18n="projekte.detail.checklisten.hint">
|
||||
Instanzen werden auf der Vorlagen-Seite unter <a href="/checklisten">Checklisten</a> angelegt.
|
||||
</p>
|
||||
</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 id="akte-delete-btn" className="btn-danger" type="button" data-i18n="projekte.detail.delete">
|
||||
Projekt archivieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -255,15 +330,15 @@ export function renderAktenDetail(): string {
|
||||
<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>
|
||||
<h2 data-i18n="projekte.detail.delete.confirm.title">Projekt wirklich archivieren?</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 data-i18n="projekte.detail.delete.confirm.body">
|
||||
Das Projekt wird archiviert. Es 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>
|
||||
<button type="button" className="btn-cancel" id="delete-modal-cancel" data-i18n="projekte.detail.delete.confirm.cancel">Abbrechen</button>
|
||||
<button type="button" className="btn-danger" id="delete-modal-confirm" data-i18n="projekte.detail.delete.confirm.ok">Archivieren</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -72,7 +72,8 @@ export function renderAkten(): string {
|
||||
<label className="akten-filter-label" htmlFor="projekt-view" data-i18n="projekte.filter.view">Ansicht</label>
|
||||
<select id="projekt-view" className="akten-select">
|
||||
<option value="flat" data-i18n="projekte.view.flat">Flache Liste</option>
|
||||
<option value="roots" data-i18n="projekte.view.roots">Nur Mandanten</option>
|
||||
<option value="tree" data-i18n="projekte.view.tree">Baumansicht</option>
|
||||
<option value="roots" data-i18n="projekte.view.roots">Nur Wurzeln</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,16 +4,42 @@ import { initNotes } from "./notizen";
|
||||
|
||||
interface Akte {
|
||||
id: string;
|
||||
aktenzeichen: string;
|
||||
type: string;
|
||||
parent_id?: string | null;
|
||||
path: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
status: string;
|
||||
owning_office: string;
|
||||
firm_wide_visible: boolean;
|
||||
collaborators: string[];
|
||||
client_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
netdocuments_url?: string | null;
|
||||
court?: string | null;
|
||||
case_number?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ProjektTeamMember {
|
||||
id: string;
|
||||
projekt_id: string;
|
||||
user_id: string;
|
||||
role: string;
|
||||
inherited: boolean;
|
||||
user_email: string;
|
||||
user_display_name: string;
|
||||
user_office: string;
|
||||
inherited_from_id?: string | null;
|
||||
inherited_from_title?: string | null;
|
||||
}
|
||||
|
||||
interface ProjektMini {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
interface Partei {
|
||||
id: string;
|
||||
projekt_id: string;
|
||||
@@ -57,9 +83,9 @@ interface Me {
|
||||
office: string;
|
||||
}
|
||||
|
||||
type TabId = "verlauf" | "parteien" | "fristen" | "termine" | "notizen" | "checklisten";
|
||||
type TabId = "verlauf" | "team" | "kinder" | "parteien" | "fristen" | "termine" | "notizen" | "checklisten";
|
||||
|
||||
const VALID_TABS: TabId[] = ["verlauf", "parteien", "fristen", "termine", "notizen", "checklisten"];
|
||||
const VALID_TABS: TabId[] = ["verlauf", "team", "kinder", "parteien", "fristen", "termine", "notizen", "checklisten"];
|
||||
|
||||
interface ChecklistInstanceSummary {
|
||||
id: string;
|
||||
@@ -85,14 +111,19 @@ let parteien: Partei[] = [];
|
||||
let events: AkteEvent[] = [];
|
||||
let fristen: Frist[] = [];
|
||||
let termine: Termin[] = [];
|
||||
let ancestors: ProjektMini[] = [];
|
||||
let children: ProjektMini[] = [];
|
||||
let teamMembers: ProjektTeamMember[] = [];
|
||||
let userOptions: { id: string; display_name: string; email: string }[] = [];
|
||||
|
||||
const EVENTS_PAGE_SIZE = 50;
|
||||
let eventsHasMore = false;
|
||||
let eventsLoadingMore = false;
|
||||
|
||||
function parseAkteID(): string | null {
|
||||
// Accepts /projekte/{id} (new) and /akten/{id} (legacy).
|
||||
const parts = window.location.pathname.split("/").filter(Boolean);
|
||||
if (parts[0] !== "akten" || !parts[1]) return null;
|
||||
if ((parts[0] !== "projekte" && parts[0] !== "akten") || !parts[1]) return null;
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
@@ -404,6 +435,10 @@ function esc(s: string): string {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
@@ -423,22 +458,43 @@ 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;
|
||||
(document.getElementById("akte-ref-display") as HTMLElement).textContent = akte.reference || "";
|
||||
|
||||
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 typeChip = document.getElementById("akte-type-chip")!;
|
||||
typeChip.className = `akten-type-chip akten-type-${akte.type}`;
|
||||
typeChip.textContent = t(`projekte.type.${akte.type}`) || akte.type;
|
||||
|
||||
// ClientMatter display. If the projekt itself has no client_number, walk
|
||||
// up the ancestor chain to find an inherited one.
|
||||
const cm = document.getElementById("akte-clientmatter")!;
|
||||
const effectiveClient = akte.client_number || inheritedClientNumber();
|
||||
const effectiveMatter = akte.matter_number || "";
|
||||
if (effectiveClient || effectiveMatter) {
|
||||
cm.textContent =
|
||||
effectiveClient && effectiveMatter
|
||||
? `${effectiveClient}.${effectiveMatter}`
|
||||
: effectiveClient || effectiveMatter;
|
||||
if (!akte.client_number && effectiveClient) {
|
||||
cm.classList.add("akten-ref-inherited");
|
||||
cm.title = t("projekte.detail.clientmatter.inherited") || "inherited";
|
||||
} else {
|
||||
cm.classList.remove("akten-ref-inherited");
|
||||
cm.title = "";
|
||||
}
|
||||
} else {
|
||||
cm.textContent = "";
|
||||
}
|
||||
|
||||
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;
|
||||
statusChip.textContent = t(`projekte.filter.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");
|
||||
const netdocs = document.getElementById("akte-netdocs") as HTMLAnchorElement;
|
||||
if (akte.netdocuments_url) {
|
||||
netdocs.href = akte.netdocuments_url;
|
||||
netdocs.style.display = "";
|
||||
} else {
|
||||
firmWideChip.style.display = "none";
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
@@ -762,7 +818,7 @@ function initDelete() {
|
||||
confirmBtn.disabled = true;
|
||||
const resp = await fetch(`/api/projekte/${akte.id}`, { method: "DELETE" });
|
||||
if (resp.ok) {
|
||||
window.location.href = "/akten";
|
||||
window.location.href = "/projekte";
|
||||
} else {
|
||||
confirmBtn.disabled = false;
|
||||
closeModal();
|
||||
@@ -790,26 +846,267 @@ async function main() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadParteien(id), loadEvents(id), loadFristen(id), loadTermine(id)]);
|
||||
await Promise.all([
|
||||
loadParteien(id),
|
||||
loadEvents(id),
|
||||
loadFristen(id),
|
||||
loadTermine(id),
|
||||
loadAncestors(id),
|
||||
loadChildren(id),
|
||||
loadTeam(id),
|
||||
loadUserList(),
|
||||
]);
|
||||
|
||||
loading.style.display = "none";
|
||||
body.style.display = "";
|
||||
renderHeader();
|
||||
renderBreadcrumb();
|
||||
renderParteien();
|
||||
renderEvents();
|
||||
renderFristen();
|
||||
renderTermine();
|
||||
renderChildren();
|
||||
renderTeam();
|
||||
initFristAddLink();
|
||||
initChildAddLink();
|
||||
initTabs();
|
||||
initTitleEdit();
|
||||
initParteienForm();
|
||||
initAkteTerminForm();
|
||||
initTeamForm(id);
|
||||
initDelete();
|
||||
initEventsLoadMore();
|
||||
initNotesContainer(id);
|
||||
showTab(parseTab());
|
||||
}
|
||||
|
||||
// ----- Breadcrumb + ancestor resolution -----------------------------------
|
||||
|
||||
function inheritedClientNumber(): string | null {
|
||||
// Walks ancestor chain (root → parent) and returns the nearest non-null
|
||||
// client_number for display when the projekt itself has none.
|
||||
for (let i = ancestors.length - 1; i >= 0; i--) {
|
||||
const a = ancestors[i] as ProjektMini & { client_number?: string | null };
|
||||
if (a.client_number) return a.client_number;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function loadAncestors(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projekte/${id}/ancestors`);
|
||||
if (resp.ok) ancestors = (await resp.json()) as ProjektMini[];
|
||||
} catch {
|
||||
ancestors = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderBreadcrumb() {
|
||||
if (!akte) return;
|
||||
const el = document.getElementById("projekt-breadcrumb");
|
||||
if (!el) return;
|
||||
const parts: string[] = ancestors.map(
|
||||
(a) =>
|
||||
`<a href="/projekte/${esc(a.id)}" class="projekt-crumb">${esc(a.title)}</a>`,
|
||||
);
|
||||
parts.push(`<span class="projekt-crumb projekt-crumb-current">${esc(akte.title)}</span>`);
|
||||
el.innerHTML = parts.join(`<span class="projekt-crumb-sep">\u203A</span>`);
|
||||
}
|
||||
|
||||
// ----- Children -----------------------------------------------------------
|
||||
|
||||
async function loadChildren(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projekte/${id}/kinder`);
|
||||
if (resp.ok) children = (await resp.json()) as ProjektMini[];
|
||||
} catch {
|
||||
children = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderChildren() {
|
||||
const list = document.getElementById("kinder-list")!;
|
||||
const empty = document.getElementById("kinder-empty")!;
|
||||
if (!children.length) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = children
|
||||
.map(
|
||||
(c) => `<li class="projekt-child-item">
|
||||
<a href="/projekte/${esc(c.id)}" class="projekt-child-link">
|
||||
<span class="akten-type-chip akten-type-${esc(c.type)}">${esc(t("projekte.type." + c.type) || c.type)}</span>
|
||||
<span class="projekt-child-title">${esc(c.title)}</span>
|
||||
${c.reference ? `<span class="projekt-child-ref">${esc(c.reference)}</span>` : ""}
|
||||
<span class="akten-status-chip akten-status-${esc(c.status)}">${esc(t("projekte.filter.status." + c.status) || c.status)}</span>
|
||||
</a>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function initChildAddLink() {
|
||||
const link = document.getElementById("child-add-link") as HTMLAnchorElement | null;
|
||||
if (!link || !akte) return;
|
||||
// Pre-fill parent_id for the create form via query param.
|
||||
link.href = `/projekte/neu?parent=${encodeURIComponent(akte.id)}`;
|
||||
}
|
||||
|
||||
// ----- Team tab -----------------------------------------------------------
|
||||
|
||||
async function loadTeam(id: string) {
|
||||
try {
|
||||
const resp = await fetch(`/api/projekte/${id}/team`);
|
||||
if (resp.ok) teamMembers = (await resp.json()) as ProjektTeamMember[];
|
||||
} catch {
|
||||
teamMembers = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUserList() {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (resp.ok) userOptions = (await resp.json()) as typeof userOptions;
|
||||
} catch {
|
||||
userOptions = [];
|
||||
}
|
||||
}
|
||||
|
||||
function renderTeam() {
|
||||
const body = document.getElementById("team-body")!;
|
||||
const empty = document.getElementById("team-empty")!;
|
||||
if (!teamMembers.length) {
|
||||
body.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
body.innerHTML = teamMembers
|
||||
.map((m) => {
|
||||
const roleLabel = t(`projekte.team.role.${m.role}`) || m.role;
|
||||
const source = m.inherited
|
||||
? `<span class="projekt-team-inherited" title="${escAttr(t("projekte.team.inherited.hint") || "Inherited from ancestor")}">
|
||||
↑ ${esc(m.inherited_from_title || "")}
|
||||
</span>`
|
||||
: `<span class="projekt-team-direct">${esc(t("projekte.team.direct") || "direkt")}</span>`;
|
||||
const removeBtn =
|
||||
!m.inherited && canRemoveTeamMember(m)
|
||||
? `<button type="button" class="btn-ghost btn-small team-remove-btn" data-user-id="${esc(m.user_id)}">${esc(t("projekte.detail.team.remove") || "Entfernen")}</button>`
|
||||
: "";
|
||||
return `<tr>
|
||||
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
||||
<span class="form-hint">· ${esc(m.user_email)}${m.user_office ? " · " + esc(m.user_office) : ""}</span></td>
|
||||
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
|
||||
<td>${source}</td>
|
||||
<td>${removeBtn}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
if (!akte) return;
|
||||
const userID = btn.dataset.userId!;
|
||||
if (!window.confirm(t("projekte.detail.team.confirm_remove") || "Mitglied entfernen?")) return;
|
||||
const resp = await fetch(
|
||||
`/api/projekte/${akte.id}/team/${encodeURIComponent(userID)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (resp.ok) {
|
||||
await loadTeam(akte.id);
|
||||
renderTeam();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjektTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
return me.role === "partner" || me.role === "admin";
|
||||
}
|
||||
|
||||
function initTeamForm(id: string) {
|
||||
const addBtn = document.getElementById("team-add-btn") as HTMLButtonElement | null;
|
||||
const form = document.getElementById("team-form") as HTMLFormElement | null;
|
||||
const cancel = document.getElementById("team-cancel") as HTMLButtonElement | null;
|
||||
const input = document.getElementById("team-user-input") as HTMLInputElement | null;
|
||||
const hidden = document.getElementById("team-user-id") as HTMLInputElement | null;
|
||||
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
|
||||
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
||||
const role = document.getElementById("team-role") as HTMLSelectElement | null;
|
||||
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !role) return;
|
||||
|
||||
addBtn.addEventListener("click", () => {
|
||||
form.style.display = "";
|
||||
addBtn.style.display = "none";
|
||||
input.focus();
|
||||
});
|
||||
cancel.addEventListener("click", () => {
|
||||
form.style.display = "none";
|
||||
addBtn.style.display = "";
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
msg.textContent = "";
|
||||
});
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
hidden.value = "";
|
||||
if (!q) {
|
||||
sugs.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const matches = userOptions
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(u) => `<div class="akten-collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
|
||||
<strong>${esc(u.display_name || u.email)}</strong>
|
||||
<span class="form-hint">${esc(u.email)}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
msg.textContent = "";
|
||||
if (!hidden.value) {
|
||||
msg.textContent = t("projekte.detail.team.error.user_required") || "Benutzer ausw\u00e4hlen";
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`/api/projekte/${id}/team`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value, role: role.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const b = await resp.json().catch(() => ({ error: "unknown" }));
|
||||
msg.textContent = b.error || "failed";
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
form.style.display = "none";
|
||||
addBtn.style.display = "";
|
||||
await loadTeam(id);
|
||||
renderTeam();
|
||||
});
|
||||
}
|
||||
|
||||
// initNotesContainer hooks the shared Notes module into the Akten detail's
|
||||
// Notizen tab. Called once per page load — the list lazy-fetches so other
|
||||
// tabs aren't slowed down by the notes query on initial render.
|
||||
@@ -828,10 +1125,13 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
renderHeader();
|
||||
renderBreadcrumb();
|
||||
renderEvents();
|
||||
renderParteien();
|
||||
renderFristen();
|
||||
renderTermine();
|
||||
renderChildren();
|
||||
renderTeam();
|
||||
});
|
||||
main();
|
||||
});
|
||||
|
||||
@@ -158,13 +158,35 @@ function esc(s: string): string {
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
async function applyParentFromQueryString() {
|
||||
const qs = new URLSearchParams(window.location.search);
|
||||
const parentID = qs.get("parent");
|
||||
if (!parentID) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/projekte/${encodeURIComponent(parentID)}`);
|
||||
if (!resp.ok) return;
|
||||
const p = (await resp.json()) as ProjektMini;
|
||||
($("projekt-parent-id") as HTMLInputElement).value = p.id;
|
||||
($("projekt-parent-input") as HTMLInputElement).value = p.title;
|
||||
// Default to 'case' under a non-root parent; user can override.
|
||||
const typeSel = $("projekt-type") as HTMLSelectElement;
|
||||
if (typeSel.value === "client") {
|
||||
typeSel.value = "case";
|
||||
showFieldsForType(typeSel.value);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
const typeSel = $("projekt-type") as HTMLSelectElement;
|
||||
showFieldsForType(typeSel.value);
|
||||
typeSel.addEventListener("change", () => showFieldsForType(typeSel.value));
|
||||
loadParentCandidates();
|
||||
await loadParentCandidates();
|
||||
initParentPicker();
|
||||
await applyParentFromQueryString();
|
||||
submitForm();
|
||||
});
|
||||
|
||||
@@ -18,7 +18,7 @@ interface Projekt {
|
||||
let allRows: Projekt[] = [];
|
||||
let typeFilter = "";
|
||||
let statusFilter = "";
|
||||
let viewMode: "flat" | "roots" = "flat";
|
||||
let viewMode: "flat" | "tree" | "roots" = "flat";
|
||||
let searchQuery = "";
|
||||
let loadedOK = false;
|
||||
|
||||
@@ -66,9 +66,24 @@ function getFiltered(): Projekt[] {
|
||||
return haystack.includes(q);
|
||||
});
|
||||
}
|
||||
if (viewMode === "tree") {
|
||||
// Depth-first order, children indented per path depth. We sort the
|
||||
// filtered rows by path so ancestors precede descendants. Because
|
||||
// filtering can drop ancestors, roots that match are top-level; any
|
||||
// hidden ancestor is simply skipped.
|
||||
const byPath = [...rows].sort((a, b) => a.path.localeCompare(b.path));
|
||||
return byPath;
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function depthOf(path: string): number {
|
||||
// path includes self as last label; depth = dots between labels.
|
||||
let d = 0;
|
||||
for (let i = 0; i < path.length; i++) if (path[i] === ".") d++;
|
||||
return d;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!loadedOK) return;
|
||||
const tbody = document.getElementById("akten-body")!;
|
||||
@@ -107,8 +122,14 @@ function render() {
|
||||
p.client_number && p.matter_number
|
||||
? `${p.client_number}.${p.matter_number}`
|
||||
: p.client_number || p.matter_number || "";
|
||||
const depth = viewMode === "tree" ? depthOf(p.path) : 0;
|
||||
const indent =
|
||||
depth > 0
|
||||
? `<span class="projekt-tree-indent" style="padding-left:${depth * 20}px"></span>` +
|
||||
`<span class="projekt-tree-branch">\u2937</span> `
|
||||
: "";
|
||||
return `<tr class="akten-row" data-id="${esc(p.id)}">
|
||||
<td class="akten-col-title">${esc(p.title)}</td>
|
||||
<td class="akten-col-title">${indent}${esc(p.title)}</td>
|
||||
<td><span class="akten-type-chip akten-type-${esc(p.type)}">${esc(typeLabel)}</span></td>
|
||||
<td class="akten-col-ref">${esc(p.reference || "")}</td>
|
||||
<td class="akten-col-ref">${esc(clientMatter)}</td>
|
||||
@@ -166,7 +187,7 @@ function initFilters() {
|
||||
render();
|
||||
});
|
||||
view.addEventListener("change", () => {
|
||||
viewMode = view.value as "flat" | "roots";
|
||||
viewMode = view.value as "flat" | "tree" | "roots";
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -636,19 +636,59 @@ async function renderMyDezernat(): Promise<void> {
|
||||
container.innerHTML = parts.join("");
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
display_name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
let dezernatUserOptions: UserOption[] = [];
|
||||
|
||||
async function loadUserOptions(): Promise<void> {
|
||||
if (dezernatUserOptions.length) return;
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (resp.ok) dezernatUserOptions = (await resp.json()) as UserOption[];
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function renderDezernatAdminTable(): void {
|
||||
const body = document.getElementById("dezernat-admin-body");
|
||||
if (!body) return;
|
||||
body.innerHTML = allDezernate
|
||||
.map(
|
||||
(d) => `<tr>
|
||||
.flatMap((d) => [
|
||||
`<tr class="dezernat-row" data-id="${esc(d.id)}">
|
||||
<td>${esc(d.name)}</td>
|
||||
<td>${esc(t("office." + d.office) || d.office)}</td>
|
||||
<td>${d.lead_user_id ? esc(d.lead_user_id) : "—"}</td>
|
||||
<td><button type="button" class="btn-ghost dezernat-manage-btn" data-id="${esc(d.id)}">${esc(t("dezernat.manage") || "Verwalten")}</button></td>
|
||||
<td><button type="button" class="btn-danger dezernat-delete-btn" data-id="${esc(d.id)}">${esc(t("dezernat.delete") || "L\u00f6schen")}</button></td>
|
||||
</tr>`,
|
||||
)
|
||||
`<tr class="dezernat-manage-row" id="dezernat-manage-${esc(d.id)}" style="display:none">
|
||||
<td colspan="5">
|
||||
<div class="dezernat-manage-panel" data-dezernat="${esc(d.id)}">
|
||||
<h4>${esc(t("dezernat.manage_heading") || "Mitglieder verwalten")} — ${esc(d.name)}</h4>
|
||||
<ul class="dezernat-member-list" id="dezernat-members-${esc(d.id)}">
|
||||
<li class="form-hint">${esc(t("dezernat.loading") || "L\u00e4dt\u2026")}</li>
|
||||
</ul>
|
||||
<form class="dezernat-add-form" data-dezernat="${esc(d.id)}" autocomplete="off">
|
||||
<div class="form-field">
|
||||
<label>${esc(t("dezernat.add_member") || "Mitglied hinzuf\u00fcgen")}</label>
|
||||
<input type="text" class="dezernat-user-input" placeholder="${escAttr(t("dezernat.add_member.placeholder") || "Name oder E-Mail")}" />
|
||||
<input type="hidden" class="dezernat-user-id" />
|
||||
<div class="akten-collab-suggestions dezernat-user-suggestions"></div>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn-primary btn-cta-lime btn-small">${esc(t("dezernat.add") || "Hinzuf\u00fcgen")}</button>
|
||||
</div>
|
||||
<p class="form-msg dezernat-add-msg"></p>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>`,
|
||||
])
|
||||
.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".dezernat-delete-btn").forEach((btn) => {
|
||||
@@ -663,6 +703,129 @@ function renderDezernatAdminTable(): void {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".dezernat-manage-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const id = btn.dataset.id!;
|
||||
const row = document.getElementById(`dezernat-manage-${id}`);
|
||||
if (!row) return;
|
||||
if (row.style.display === "none") {
|
||||
row.style.display = "";
|
||||
await loadUserOptions();
|
||||
await loadAndRenderDezernatMembers(id);
|
||||
wireDezernatAddForm(id);
|
||||
} else {
|
||||
row.style.display = "none";
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAndRenderDezernatMembers(dezernatID: string): Promise<void> {
|
||||
const ul = document.getElementById(`dezernat-members-${dezernatID}`);
|
||||
if (!ul) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/dezernate/${encodeURIComponent(dezernatID)}/members`);
|
||||
if (!resp.ok) return;
|
||||
const members = (await resp.json()) as DezernatMember[];
|
||||
if (!members.length) {
|
||||
ul.innerHTML = `<li class="form-hint">${esc(t("dezernat.no_members") || "Noch keine Mitglieder.")}</li>`;
|
||||
return;
|
||||
}
|
||||
ul.innerHTML = members
|
||||
.map(
|
||||
(m) => `<li class="dezernat-member-item">
|
||||
<span>${esc(m.display_name || m.email)} <span class="form-hint">(${esc(m.email)})</span></span>
|
||||
<button type="button" class="btn-ghost btn-small dezernat-remove-btn" data-dezernat="${esc(dezernatID)}" data-user="${esc(m.user_id)}">${esc(t("dezernat.remove") || "Entfernen")}</button>
|
||||
</li>`,
|
||||
)
|
||||
.join("");
|
||||
ul.querySelectorAll<HTMLButtonElement>(".dezernat-remove-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const did = btn.dataset.dezernat!;
|
||||
const uid = btn.dataset.user!;
|
||||
if (!window.confirm(t("dezernat.confirm_remove") || "Mitglied entfernen?")) return;
|
||||
const r = await fetch(
|
||||
`/api/dezernate/${encodeURIComponent(did)}/members/${encodeURIComponent(uid)}`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (r.ok) {
|
||||
await loadAndRenderDezernatMembers(did);
|
||||
void renderMyDezernat();
|
||||
}
|
||||
});
|
||||
});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
function wireDezernatAddForm(dezernatID: string): void {
|
||||
const form = document.querySelector<HTMLFormElement>(
|
||||
`.dezernat-add-form[data-dezernat="${dezernatID}"]`,
|
||||
);
|
||||
if (!form || form.dataset.wired === "1") return;
|
||||
form.dataset.wired = "1";
|
||||
|
||||
const input = form.querySelector<HTMLInputElement>(".dezernat-user-input")!;
|
||||
const hidden = form.querySelector<HTMLInputElement>(".dezernat-user-id")!;
|
||||
const sugs = form.querySelector<HTMLDivElement>(".dezernat-user-suggestions")!;
|
||||
const msg = form.querySelector<HTMLParagraphElement>(".dezernat-add-msg")!;
|
||||
|
||||
input.addEventListener("input", () => {
|
||||
const q = input.value.trim().toLowerCase();
|
||||
hidden.value = "";
|
||||
if (!q) {
|
||||
sugs.innerHTML = "";
|
||||
return;
|
||||
}
|
||||
const matches = dezernatUserOptions
|
||||
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(q))
|
||||
.slice(0, 8);
|
||||
sugs.innerHTML = matches
|
||||
.map(
|
||||
(u) => `<div class="akten-collab-suggestion" data-id="${esc(u.id)}" data-label="${escAttr(u.display_name || u.email)}">
|
||||
<strong>${esc(u.display_name || u.email)}</strong>
|
||||
<span class="form-hint">${esc(u.email)}</span>
|
||||
</div>`,
|
||||
)
|
||||
.join("");
|
||||
sugs.querySelectorAll<HTMLDivElement>(".akten-collab-suggestion").forEach((el) => {
|
||||
el.addEventListener("click", () => {
|
||||
hidden.value = el.dataset.id!;
|
||||
input.value = el.dataset.label!;
|
||||
sugs.innerHTML = "";
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
msg.textContent = "";
|
||||
if (!hidden.value) {
|
||||
msg.textContent = t("dezernat.error.user_required") || "Benutzer ausw\u00e4hlen";
|
||||
return;
|
||||
}
|
||||
const resp = await fetch(`/api/dezernate/${encodeURIComponent(dezernatID)}/members`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ user_id: hidden.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const b = await resp.json().catch(() => ({ error: "unknown" }));
|
||||
msg.textContent = b.error || "failed";
|
||||
return;
|
||||
}
|
||||
input.value = "";
|
||||
hidden.value = "";
|
||||
sugs.innerHTML = "";
|
||||
await loadAndRenderDezernatMembers(dezernatID);
|
||||
void renderMyDezernat();
|
||||
});
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
async function submitNewDezernat(e: Event): Promise<void> {
|
||||
|
||||
@@ -719,6 +719,36 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dezernat.delete": "L\u00f6schen",
|
||||
"dezernat.confirm_delete": "Dezernat wirklich l\u00f6schen?",
|
||||
"dezernat.error.name_required": "Name erforderlich",
|
||||
"dezernat.error.user_required": "Benutzer ausw\u00e4hlen",
|
||||
"dezernat.manage_heading": "Mitglieder verwalten",
|
||||
"dezernat.loading": "L\u00e4dt\u2026",
|
||||
"dezernat.no_members": "Noch keine Mitglieder.",
|
||||
"dezernat.add_member": "Mitglied hinzuf\u00fcgen",
|
||||
"dezernat.add_member.placeholder": "Name oder E-Mail",
|
||||
"dezernat.add": "Hinzuf\u00fcgen",
|
||||
"dezernat.remove": "Entfernen",
|
||||
"dezernat.confirm_remove": "Mitglied entfernen?",
|
||||
"projekte.type.client": "Mandant",
|
||||
"projekte.type.litigation": "Streitsache",
|
||||
"projekte.type.patent": "Patent",
|
||||
"projekte.type.case": "Verfahren",
|
||||
"projekte.type.project": "Projekt",
|
||||
"projekte.team.role.lead": "Lead",
|
||||
"projekte.team.role.associate": "Associate",
|
||||
"projekte.team.role.pa": "PA",
|
||||
"projekte.team.role.of_counsel": "Of Counsel",
|
||||
"projekte.team.role.local_counsel": "Local Counsel",
|
||||
"projekte.team.role.expert": "Experte",
|
||||
"projekte.team.role.observer": "Beobachter",
|
||||
"projekte.team.direct": "direkt",
|
||||
"projekte.team.inherited.hint": "Vererbt vom \u00dcberprojekt",
|
||||
"projekte.detail.team.add": "Mitglied hinzuf\u00fcgen",
|
||||
"projekte.detail.team.remove": "Entfernen",
|
||||
"projekte.detail.team.confirm_remove": "Mitglied entfernen?",
|
||||
"projekte.detail.team.empty": "Noch keine Teammitglieder.",
|
||||
"projekte.detail.team.error.user_required": "Benutzer ausw\u00e4hlen",
|
||||
"projekte.view.tree": "Baumansicht",
|
||||
"projekte.detail.clientmatter.inherited": "Vom \u00dcberprojekt vererbt",
|
||||
"einstellungen.profil.email": "E-Mail",
|
||||
"einstellungen.profil.email.hint": "E-Mail kann nicht ge\u00e4ndert werden.",
|
||||
"einstellungen.profil.display_name": "Anzeigename",
|
||||
@@ -1583,6 +1613,36 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"dezernat.delete": "Delete",
|
||||
"dezernat.confirm_delete": "Delete this Dezernat?",
|
||||
"dezernat.error.name_required": "Name required",
|
||||
"dezernat.error.user_required": "Select a user",
|
||||
"dezernat.manage_heading": "Manage members",
|
||||
"dezernat.loading": "Loading\u2026",
|
||||
"dezernat.no_members": "No members yet.",
|
||||
"dezernat.add_member": "Add member",
|
||||
"dezernat.add_member.placeholder": "Name or email",
|
||||
"dezernat.add": "Add",
|
||||
"dezernat.remove": "Remove",
|
||||
"dezernat.confirm_remove": "Remove member?",
|
||||
"projekte.type.client": "Client",
|
||||
"projekte.type.litigation": "Litigation",
|
||||
"projekte.type.patent": "Patent",
|
||||
"projekte.type.case": "Case",
|
||||
"projekte.type.project": "Project",
|
||||
"projekte.team.role.lead": "Lead",
|
||||
"projekte.team.role.associate": "Associate",
|
||||
"projekte.team.role.pa": "PA",
|
||||
"projekte.team.role.of_counsel": "Of Counsel",
|
||||
"projekte.team.role.local_counsel": "Local Counsel",
|
||||
"projekte.team.role.expert": "Expert",
|
||||
"projekte.team.role.observer": "Observer",
|
||||
"projekte.team.direct": "direct",
|
||||
"projekte.team.inherited.hint": "Inherited from ancestor",
|
||||
"projekte.detail.team.add": "Add member",
|
||||
"projekte.detail.team.remove": "Remove",
|
||||
"projekte.detail.team.confirm_remove": "Remove member?",
|
||||
"projekte.detail.team.empty": "No team members yet.",
|
||||
"projekte.detail.team.error.user_required": "Select a user",
|
||||
"projekte.view.tree": "Tree view",
|
||||
"projekte.detail.clientmatter.inherited": "Inherited from parent",
|
||||
"einstellungen.profil.email": "Email",
|
||||
"einstellungen.profil.email.hint": "Email cannot be changed.",
|
||||
"einstellungen.profil.display_name": "Display name",
|
||||
|
||||
Reference in New Issue
Block a user