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:
m
2026-04-20 15:35:01 +02:00
parent a2388e9a6b
commit 7e0c06342b
7 changed files with 728 additions and 86 deletions

View File

@@ -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 &mdash; Paliad</title>
<title data-i18n="projekte.detail.title">Projekt &mdash; 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">&larr; Zur&uuml;ck zur &Uuml;bersicht</a>
<a href="/projekte" className="akten-back-link" data-i18n="projekte.detail.back">&larr; Zur&uuml;ck zur &Uuml;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&auml;dt&hellip;</p>
<p data-i18n="projekte.detail.loading">L&auml;dt&hellip;</p>
</div>
<div id="akten-detail-notfound" className="akten-empty" style="display:none">
<p data-i18n="akten.detail.notfound">Akte nicht gefunden oder keine Berechtigung.</p>
<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 &nearr;</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&uuml;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&uuml;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&uuml;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&auml;ger</option>
<option value="defendant" data-i18n="akten.detail.parteien.role.defendant">Beklagter</option>
<option value="thirdparty" data-i18n="akten.detail.parteien.role.thirdparty">Streitverk&uuml;ndeter / Drittpartei</option>
<option value="claimant" data-i18n="projekte.detail.parteien.role.claimant">Kl&auml;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&uuml;ndeter / Drittpartei</option>
</select>
</div>
</div>
<div className="form-field">
<label htmlFor="partei-rep" data-i18n="akten.detail.parteien.form.rep">Vertreter (optional)</label>
<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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&uuml;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&ouml;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&ouml;schen?</h2>
<h2 data-i18n="projekte.detail.delete.confirm.title">Projekt wirklich archivieren?</h2>
<button className="modal-close" id="delete-modal-close" type="button">&times;</button>
</div>
<p data-i18n="akten.detail.delete.confirm.body">
Die Akte wird archiviert. Sie kann nicht direkt wiederhergestellt werden.
<p 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&ouml;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>

View File

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

View File

@@ -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, "&amp;").replace(/"/g, "&quot;");
}
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")}">
&uarr; ${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">&middot; ${esc(m.user_email)}${m.user_office ? " &middot; " + 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();
});

View File

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

View File

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

View File

@@ -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) : "&mdash;"}</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")} &mdash; ${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, "&amp;").replace(/"/g, "&quot;");
}
async function submitNewDezernat(e: Event): Promise<void> {

View File

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