Files
paliad/frontend/src/client/projects-detail.ts
mAi 1714b788d2 feat(projects-detail): t-paliad-245 — demote Daten Export into Verwaltung tab
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).

Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.

If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
2026-05-25 13:33:14 +02:00

3373 lines
127 KiB
TypeScript

import { initI18n, onLangChange, t, tDyn, getLang, translateEvent } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { initProjectTree, refreshProjectTree, rerenderProjectTree } from "./project-tree";
import {
loadParentCandidates,
initParentPicker,
wireTypeChange,
prefillForm,
readPayload,
populateProceedingTypeSelect,
loadProceedingTypes as loadProceedingTypesShared,
} from "./project-form";
import { mountFilterBar, type BarHandle } from "./filter-bar";
import type { FilterSpec, RenderSpec } from "./views/types";
import { renderSmartTimeline, type TimelineEvent as SmartTimelineEvent, type LaneInfo as SmartTimelineLane } from "./views/shape-timeline";
import { loadAndRenderSubmissions } from "./submissions";
import { buildMailtoHref, type BroadcastRecipient } from "./broadcast";
interface Project {
id: string;
type: string;
parent_id?: string | null;
path: string;
title: string;
reference?: string | null;
// t-paliad-222 / m/paliad#50: auto-derived dotted project code from
// the ancestor tree (e.g. EXMPL.OPNT.789.INF.CFI). Populated by the
// service layer on every projection; equal to `reference` when the
// user typed an override.
code?: string;
opponent_code?: string | null;
description?: string | null;
status: string;
client_number?: string | null;
matter_number?: string | null;
billing_reference?: string | null;
netdocuments_url?: string | null;
industry?: string | null;
country?: string | null;
patent_number?: string | null;
filing_date?: string | null;
grant_date?: string | null;
court?: string | null;
case_number?: string | null;
// t-paliad-223: piggybacked onto the GET /api/projects/{id} payload so
// the team panel can render an inline <select> for callers who can
// change responsibilities (global_admin or effective_project_admin on
// this project / ancestor). Optional for back-compat with cached
// payloads.
effective_admin?: boolean;
updated_at: string;
created_at: string;
}
interface ProjectTeamMember {
id: string;
project_id: string;
user_id: string;
// t-paliad-148: per-project responsibility (lead/member/observer/external).
// The legacy .role field is still set by the server during the
// deprecation window but the UI ignores it for new code.
responsibility: string;
role: string;
inherited: boolean;
user_email: string;
user_display_name: string;
user_office: string;
// user_profession is the structured firm tier (partner/of_counsel/…/
// paralegal). NULL means external collaborator. Read-only here — the
// value is set on the user's firm profile, not at staffing time.
user_profession?: string | null;
inherited_from_id?: string | null;
inherited_from_title?: string | null;
}
// t-paliad-139 — derived team member from a partner-unit attachment.
// One DerivedMember per user; users in multiple attached units carry one
// DerivedMembership per (unit, role) pair so the Herkunft column can list
// every source (t-paliad-143).
interface DerivedMembership {
unit_id: string;
unit_name: string;
unit_role: string;
}
interface DerivedMember {
user_id: string;
user_email: string;
user_display_name: string;
user_office: string;
memberships: DerivedMembership[];
derive_grants_authority: boolean;
}
// t-paliad-139 — partner unit attached to this project.
interface AttachedUnit {
project_id: string;
partner_unit_id: string;
unit_name: string;
derive_unit_roles: string[];
derive_grants_authority: boolean;
derived_member_count: number;
}
interface ProjectMini {
id: string;
type: string;
title: string;
reference?: string | null;
status: string;
}
interface Party {
id: string;
project_id: string;
name: string;
role?: string;
representative?: string;
}
interface ProjectEvent {
id: string;
project_id: string;
event_type?: string;
title: string;
description?: string;
created_at: string;
created_by?: string;
metadata?: Record<string, unknown>;
// Populated only when the response was joined to paliad.projects (Verlauf
// subtree-aggregating queries on /projects/{id}, t-paliad-139). Used to
// render the attribution chip when the event lives on a descendant.
project_title?: string;
}
interface Deadline {
id: string;
project_id: string;
title: string;
due_date: string;
status: string;
rule_id?: string;
rule_code?: string;
// Populated by the union endpoint (/api/events) which is what the project
// detail page calls — used for attribution when the row lives on a
// descendant project (t-paliad-139).
project_title?: string;
}
interface Appointment {
id: string;
project_id?: string;
title: string;
start_at: string;
end_at?: string;
location?: string;
appointment_type?: string;
project_title?: string;
}
interface Me {
id: string;
job_title: string | null;
global_role: string;
office: string;
}
type TabId =
| "history"
| "team"
| "children"
| "parties"
| "deadlines"
| "appointments"
| "notes"
| "checklists"
| "submissions"
| "settings";
const VALID_TABS: TabId[] = [
"history",
"team",
"children",
"parties",
"deadlines",
"appointments",
"notes",
"checklists",
"submissions",
"settings",
];
// Legacy German tab slugs that may appear in bookmarked URLs after the
// rename. Mapped to their English successors so old links still land on the
// right tab instead of silently falling back to "history".
const LEGACY_TAB_ALIASES: Record<string, TabId> = {
verlauf: "history",
kinder: "children",
parteien: "parties",
fristen: "deadlines",
termine: "appointments",
notizen: "notes",
checklisten: "checklists",
};
interface ChecklistInstanceSummary {
id: string;
template_slug: string;
name: string;
state: Record<string, boolean>;
created_at: string;
}
interface ChecklistTemplateSummary {
slug: string;
titleDE: string;
titleEN: string;
descriptionDE?: string;
descriptionEN?: string;
regime?: string;
itemCount: number;
}
let checklistInstances: ChecklistInstanceSummary[] = [];
let checklistTemplates: Record<string, ChecklistTemplateSummary> = {};
let project: Project | null = null;
let me: Me | null = null;
let parties: Party[] = [];
let events: ProjectEvent[] = [];
let deadlines: Deadline[] = [];
let appointments: Appointment[] = [];
let ancestors: ProjectMini[] = [];
let children: ProjectMini[] = [];
// projects-cards' /projects tree owns rendering inside the Projektbaum
// tab too — see initProjectTreeTab below. Local children[] still feeds
// the empty-state gate + the create-link parent_id pre-fill.
let teamMembers: ProjectTeamMember[] = [];
// t-paliad-139 — additional Team-tab sections.
let descendantStaffed: ProjectTeamMember[] = [];
let derivedMembers: DerivedMember[] = [];
let attachedUnits: AttachedUnit[] = [];
let allUnits: { id: string; name: string; office: string }[] = [];
let userOptions: { id: string; display_name: string; email: string; profession?: string }[] = [];
// t-paliad-231 — checkbox selection backing the "Mail an Auswahl"
// mailto: button on the Team tab. Pure client state, wiped on page
// navigation. Pruned to currently-visible user_ids on every renderTeam
// so removed/filtered-out members don't ride along in the next mailto.
const selectedMailUserIDs: Set<string> = new Set();
const EVENTS_PAGE_SIZE = 50;
let eventsHasMore = false;
let eventsLoadingMore = false;
// SmartTimeline (t-paliad-171 / t-paliad-173) — row set + audit-toggle
// + Slice 2 lookahead state. timelineRows is what we render; the count
// of future-projected rows the backend knows about is held separately
// in timelineProjectedTotal so "Mehr anzeigen" can be shown when the
// cap clipped some rows.
let timelineRows: SmartTimelineEvent[] = [];
let timelineAuditFull = parseAuditFullPersisted();
let timelineLookahead = 7; // backend default; overridden from localStorage
let timelineProjectedTotal = 0;
// Slice 3 — counterclaim parallel tracks. timelineAvailableTracks is
// parsed from the X-Projection-Tracks response header; selectedTrack
// is the user's [Track ▼] choice (default "all" → render every track).
let timelineAvailableTracks: string[] = [];
let timelineSelectedTrack = "all";
// Slice 4 — parent-node lane aggregation (t-paliad-175). Lanes come
// from the response envelope's .lanes array. selectedLanes is the
// user's lane-filter state — null = "all selected" (the default);
// set explicitly when the user toggles a chip.
let timelineLanes: SmartTimelineLane[] = [];
let timelineSelectedLanes: string[] | null = null;
// Slice 4 — Client-level "Timeline-Ansicht" toggle. At Client-level
// project pages, the Verlauf tab defaults to the matter-list rendering
// (project tree); flipping the toggle swaps to the SmartTimeline lane
// view. State persists in localStorage per project so navigating away
// and back keeps the user's choice.
let timelineClientShowLanes = false;
// t-paliad-170 / t-paliad-176 — Verlauf FilterBar state.
//
// The bar mounts once, owns the URL params (?time=, ?pe_kind=, ?tl_status=,
// ?tl_track=, …), and drives a client-side filter pass over `timelineRows`
// before render. The SmartTimeline endpoint has no built-in predicate for
// timeline_status / timeline_track / project_event_kind axes — they sit on
// BarState only — so we filter rendered rows in `applyTimelineRowFilters`
// rather than re-fetching on every chip click. The customRunner drains the
// bar's state into `verlaufFilters` and triggers a re-render via onResult.
let verlaufBar: BarHandle | null = null;
interface VerlaufFilters {
// project_event_kind chip — values from KnownProjectEventKinds (see
// internal/services/filter_spec.go). Only filters rows whose underlying
// project_events.event_type is non-empty (deadline / appointment /
// projected rows pass through unaffected — they have no event_type).
eventKinds?: Set<string>;
// timeline_status chip — matches TimelineEvent.status verbatim
// (done | open | overdue | predicted | predicted_overdue | court_set | off_script).
timelineStatuses?: Set<string>;
// timeline_track chip — chip values are "parent" / "counterclaim" /
// "off_script" but row.track may carry suffixed forms like
// "counterclaim:<id>" or "parent_context:<id>". Filtering normalises
// by matching the chip's prefix against the row's track tag.
timelineTracks?: Set<string>;
// Bounds are inclusive lower / exclusive upper, matching
// computeViewSpecBounds in internal/services/view_service.go so the
// semantics align when this surface eventually moves to the substrate.
fromDate?: Date;
toDate?: Date;
}
let verlaufFilters: VerlaufFilters = {};
// applyTimelineRowFilters narrows the SmartTimeline rows to whatever
// the FilterBar's BarState declares. Empty filter → identity passthrough.
// Called from renderTimeline immediately before handing rows to
// renderSmartTimeline (single-column or lane-strip alike).
function applyTimelineRowFilters(rows: SmartTimelineEvent[]): SmartTimelineEvent[] {
const f = verlaufFilters;
if (
!f.eventKinds &&
!f.timelineStatuses &&
!f.timelineTracks &&
!f.fromDate &&
!f.toDate
) {
return rows;
}
return rows.filter((r) => {
// project_event_kind narrows project_events specifically: deadline /
// appointment / projected rows pass through unaffected (they carry no
// project_event_type). A milestone whose project_event_type isn't in
// the picked subset drops out.
if (f.eventKinds && r.project_event_type) {
if (!f.eventKinds.has(r.project_event_type)) return false;
}
if (f.timelineStatuses && !f.timelineStatuses.has(r.status)) return false;
if (f.timelineTracks && !timelineTrackChipMatches(r.track, f.timelineTracks)) return false;
if (f.fromDate || f.toDate) {
// Undated rows (court-set decisions, counterclaim-pending) escape
// the time horizon — same convention as the renderer's "Datum offen"
// bucket. Otherwise compare the row's date against the bounds.
if (r.date) {
const d = new Date(r.date);
if (f.fromDate && d < f.fromDate) return false;
if (f.toDate && d >= f.toDate) return false;
}
}
return true;
});
}
// timelineTrackChipMatches normalises the chip vocabulary against the
// row's track tag — chip "counterclaim" matches both "counterclaim" and
// "counterclaim:<id>"; chip "parent" matches "parent" only (NOT
// "parent_context:<id>", which is a CCR-child-viewing-parent overlay).
function timelineTrackChipMatches(rowTrack: string, chips: Set<string>): boolean {
const tag = rowTrack || "parent";
if (chips.has(tag)) return true;
for (const chip of chips) {
if (chip === "counterclaim" && tag.startsWith("counterclaim:")) return true;
}
return false;
}
// applyVerlaufFilters narrows the legacy /api/projects/{id}/events
// response to the bar's filter state. The render path no longer reads
// this `events` array (the SmartTimeline took over), but loadEvents +
// loadMoreEvents still call it so the cursor pagination state stays
// consistent for any future re-introduction. Keeps the project_event_kind
// + time-horizon filter intact; the SmartTimeline-only axes don't apply
// to the legacy ProjectEvent shape.
function applyVerlaufFilters(rows: ProjectEvent[]): ProjectEvent[] {
const f = verlaufFilters;
if (!f.eventKinds && !f.fromDate && !f.toDate) return rows;
return rows.filter((r) => {
if (f.eventKinds && !f.eventKinds.has(r.event_type ?? "")) return false;
const created = new Date(r.created_at);
if (f.fromDate && created < f.fromDate) return false;
if (f.toDate && created >= f.toDate) return false;
return true;
});
}
// horizonBounds mirrors computeViewSpecBounds in view_service.go for the
// horizons that show up on the Verlauf bar. Forward-looking horizons
// (next_*) are absent on this surface — the timePresets override hides
// them — but the function tolerates them for forward-compatibility with
// the SmartTimeline redesign.
function horizonBounds(horizon: string): { from?: Date; to?: Date } {
const now = new Date();
const day = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
const offset = (days: number) => new Date(day.getTime() + days * 86400000);
switch (horizon) {
case "past_7d": return { from: offset(-7), to: offset(1) };
case "past_30d": return { from: offset(-30), to: offset(1) };
case "past_90d": return { from: offset(-90), to: offset(1) };
case "next_7d": return { from: day, to: offset(7) };
case "next_30d": return { from: day, to: offset(30) };
case "next_90d": return { from: day, to: offset(90) };
default: return {};
}
}
// Subtree aggregation mode (t-paliad-139). Default true → Fristen, Termine,
// Verlauf show rows from this project AND all descendant projects with an
// attribution chip per non-direct row. URL param `?subtree=false` flips to
// narrow (this project's own rows only).
let subtreeMode: boolean = true;
function parseSubtreeMode(): boolean {
try {
const raw = new URLSearchParams(window.location.search).get("subtree");
return raw !== "false";
} catch {
return true;
}
}
function persistSubtreeMode() {
try {
const url = new URL(window.location.href);
if (subtreeMode) {
url.searchParams.delete("subtree");
} else {
url.searchParams.set("subtree", "false");
}
window.history.replaceState({}, "", url.toString());
} catch {
// ignore
}
}
function parseProjectID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
if (parts[0] !== "projects" || !parts[1]) return null;
return parts[1];
}
function parseTab(): TabId {
const parts = window.location.pathname.split("/").filter(Boolean);
const candidate = parts[2];
if (!candidate) return "history";
if ((VALID_TABS as string[]).includes(candidate)) return candidate as TabId;
if (LEGACY_TAB_ALIASES[candidate]) return LEGACY_TAB_ALIASES[candidate];
return "history";
}
async function loadMe() {
try {
const resp = await fetch("/api/me");
if (resp.ok) me = await resp.json();
} catch {
/* optional */
}
}
async function loadProject(id: string): Promise<boolean> {
try {
const resp = await fetch(`/api/projects/${id}`);
if (!resp.ok) return false;
project = await resp.json();
return true;
} catch {
return false;
}
}
async function loadParties(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/parties`);
if (resp.ok) parties = (await resp.json()) ?? [];
} catch {
parties = [];
}
}
// Build a query string suffix conveying the current subtree mode. The
// backend defaults to subtree (direct_only=false), so we only emit the
// param when the user has flipped to direct.
function subtreeParam(): string {
return subtreeMode ? "" : "&direct_only=true";
}
// rawEventsCursor tracks the last *raw* (pre-filter) event ID returned by
// the legacy endpoint so cursor pagination keeps working when filters
// drop most rows from a page. Without it, "Mehr laden" with a tight
// filter could stall because events[] (post-filter) wouldn't reach back
// to the actual pagination boundary.
let rawEventsLastID: string | null = null;
let rawEventsLastPageFull = false;
async function loadEvents(id: string) {
try {
const resp = await fetch(
`/api/projects/${id}/events?limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
);
if (resp.ok) {
const raw: ProjectEvent[] = (await resp.json()) ?? [];
rawEventsLastID = raw.length ? raw[raw.length - 1].id : null;
rawEventsLastPageFull = raw.length === EVENTS_PAGE_SIZE;
events = applyVerlaufFilters(raw);
eventsHasMore = rawEventsLastPageFull;
} else {
events = [];
rawEventsLastID = null;
rawEventsLastPageFull = false;
eventsHasMore = false;
}
} catch {
events = [];
rawEventsLastID = null;
rawEventsLastPageFull = false;
eventsHasMore = false;
}
}
// SmartTimeline (t-paliad-171) — fetches the merged timeline from the
// new /api/projects/{id}/timeline endpoint. Slice 1 returns actuals
// (deadlines + appointments + opted-in project_events); future slices
// add projected rows additively. The audit-full toggle broadens the
// project_events filter to include rows without timeline_kind set.
async function loadTimeline(id: string): Promise<void> {
const params = new URLSearchParams();
if (timelineAuditFull) params.set("include", "audit_full");
if (!subtreeMode) params.set("direct_only", "true");
if (timelineLookahead && timelineLookahead !== 7) {
params.set("lookahead", String(timelineLookahead));
}
const qs = params.toString();
const url = `/api/projects/${encodeURIComponent(id)}/timeline${qs ? "?" + qs : ""}`;
try {
const resp = await fetch(url);
if (resp.ok) {
// Slice 4 (t-paliad-175) — wire shape changed from
// []TimelineEvent to envelope {events, lanes} so lane metadata
// can ride alongside the rows. Defensive parse: tolerate both
// shapes during the rolling deploy window (any cached older
// backend response is treated as events-only).
const body = await resp.json();
if (Array.isArray(body)) {
timelineRows = body;
timelineLanes = [];
} else {
timelineRows = (body?.events ?? []) as SmartTimelineEvent[];
timelineLanes = (body?.lanes ?? []) as SmartTimelineLane[];
}
// Pull projection meta from headers (Slice 2). When absent (e.g.
// proxy strips them), fall back to the visible projected count
// so "Mehr anzeigen" stays hidden — defensible default.
const totalHdr = resp.headers.get("X-Projection-Total");
timelineProjectedTotal = totalHdr ? parseInt(totalHdr, 10) || 0 : 0;
const lookaheadHdr = resp.headers.get("X-Projection-Lookahead");
if (lookaheadHdr) {
const n = parseInt(lookaheadHdr, 10);
if (!isNaN(n) && n > 0) timelineLookahead = n;
}
// Slice 3 — track list comes back as comma-separated tags.
const tracksHdr = resp.headers.get("X-Projection-Tracks");
timelineAvailableTracks = tracksHdr
? tracksHdr.split(",").map((s) => s.trim()).filter((s) => s.length > 0)
: ["parent"];
// Drop a previously-selected track if it disappeared from the
// response (e.g. CCR child was deleted between renders) — fall
// back to "all" so the user doesn't get an empty pane.
if (timelineSelectedTrack !== "all" && !timelineAvailableTracks.includes(timelineSelectedTrack)) {
timelineSelectedTrack = "all";
}
// Drop selected lanes that disappeared between renders (e.g. a
// child case was deleted). null sentinel means "all" so leave it.
if (timelineSelectedLanes !== null) {
const laneIds = new Set(timelineLanes.map((l) => l.id));
timelineSelectedLanes = timelineSelectedLanes.filter((id) => laneIds.has(id));
if (timelineSelectedLanes.length === 0) {
timelineSelectedLanes = null;
}
}
} else {
timelineRows = [];
timelineProjectedTotal = 0;
timelineAvailableTracks = [];
timelineLanes = [];
}
} catch {
timelineRows = [];
timelineProjectedTotal = 0;
timelineAvailableTracks = [];
timelineLanes = [];
}
}
function renderTimeline() {
const host = document.getElementById("project-smart-timeline");
if (!host) return;
const projectId = project?.id;
// Slice 4 — Client-level Timeline-Ansicht toggle. At Client-level
// pages, the Verlauf default is the matter-list (project tree).
// Flipping the toggle swaps to the SmartTimeline lane view.
if (project?.type === "client" && !timelineClientShowLanes) {
renderClientMatterList(host);
return;
}
// t-paliad-176 — apply FilterBar predicates client-side. The
// SmartTimeline endpoint returns the unfiltered superset; the bar's
// BarState (timeline_status / timeline_track / project_event_kind /
// time horizon) narrows what we render. Empty filter → identity.
const filteredRows = applyTimelineRowFilters(timelineRows);
renderSmartTimeline(host, filteredRows, {
projectId,
lang: getLang() === "en" ? "en" : "de",
lookahead: timelineLookahead,
projectedTotal: timelineProjectedTotal,
availableTracks: timelineAvailableTracks,
selectedTrack: timelineSelectedTrack,
lanes: timelineLanes,
selectedLanes: timelineSelectedLanes ?? undefined,
onLaneFilterChange: async (next) => {
// Persist the explicit selection so a re-fetch doesn't reset it.
// Empty array = user unchecked everything → fall back to "all"
// so we never render a blank pane.
timelineSelectedLanes = next.length === 0 ? null : next;
renderTimeline();
},
onTrackChange: async (next) => {
timelineSelectedTrack = next;
// Track filter is purely client-side (rows are already loaded);
// re-render in place without a re-fetch.
renderTimeline();
},
onChange: async () => {
if (!projectId) return;
await loadTimeline(projectId);
renderTimeline();
},
onLookaheadChange: async (next) => {
if (!projectId) return;
timelineLookahead = next;
writeLookaheadPersisted(next);
await loadTimeline(projectId);
renderTimeline();
},
});
}
// renderClientMatterList renders the Client-level default Verlauf view
// — a simple list of direct child litigations with their reference and
// status. This stands in for the existing project-tree component when
// Timeline-Ansicht is OFF (the default at Client level per design §5.1
// + Q12). User can flip the Timeline-Ansicht toggle to see the lane
// SmartTimeline.
function renderClientMatterList(host: HTMLElement) {
host.innerHTML = "";
host.classList.add("smart-timeline");
const wrap = document.createElement("div");
wrap.className = "smart-timeline-matter-list";
const heading = document.createElement("h3");
heading.className = "smart-timeline-matter-list-heading";
heading.textContent = t("projects.detail.smarttimeline.client.matter_list.heading");
wrap.appendChild(heading);
const hint = document.createElement("p");
hint.className = "form-hint";
hint.textContent = t("projects.detail.smarttimeline.client.matter_list.hint");
wrap.appendChild(hint);
// The lane info from the backend already contains the direct child
// litigations (one entry per child). When empty, the message guides
// the user to add a litigation first.
if (timelineLanes.length === 0) {
const empty = document.createElement("p");
empty.className = "entity-events-empty";
empty.textContent = t("projects.detail.smarttimeline.client.matter_list.empty");
wrap.appendChild(empty);
host.appendChild(wrap);
return;
}
const list = document.createElement("ul");
list.className = "smart-timeline-matter-list-items";
for (const lane of timelineLanes) {
const li = document.createElement("li");
li.className = "smart-timeline-matter-list-item";
if (lane.project_id) {
const link = document.createElement("a");
link.href = `/projects/${encodeURIComponent(lane.project_id)}`;
link.textContent = lane.label;
li.appendChild(link);
} else {
li.textContent = lane.label;
}
list.appendChild(li);
}
wrap.appendChild(list);
host.appendChild(wrap);
}
function lookaheadStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.lookahead.${id}`;
}
function writeLookaheadPersisted(n: number) {
try {
if (n === 7) localStorage.removeItem(lookaheadStorageKey());
else localStorage.setItem(lookaheadStorageKey(), String(n));
} catch {
// ignore
}
}
function readLookaheadPersisted(): number {
try {
const raw = localStorage.getItem(lookaheadStorageKey());
if (!raw) return 7;
const n = parseInt(raw, 10);
if (isNaN(n) || n < 1 || n > 50) return 7;
return n;
} catch {
return 7;
}
}
// Audit-full toggle persistence: per-project flag in localStorage so a
// user who flips the legacy view on for one project doesn't see the
// audit clutter on every other project they open.
function auditFullStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.audit_full.${id}`;
}
function parseAuditFullPersisted(): boolean {
// Project ID isn't known yet at module init; fall back to false here
// and re-read in initSmartTimelineAuditToggle once project is loaded.
return false;
}
function readPersistedAuditFull(): boolean {
try {
return localStorage.getItem(auditFullStorageKey()) === "1";
} catch {
return false;
}
}
function writePersistedAuditFull(on: boolean) {
try {
if (on) localStorage.setItem(auditFullStorageKey(), "1");
else localStorage.removeItem(auditFullStorageKey());
} catch {
// ignore
}
}
async function loadMoreEvents(id: string) {
if (eventsLoadingMore || !eventsHasMore || !rawEventsLastID) return;
const cursor = rawEventsLastID;
const btn = document.getElementById("project-events-loadmore") as HTMLButtonElement | null;
eventsLoadingMore = true;
if (btn) {
btn.disabled = true;
btn.textContent = t("projects.detail.verlauf.loadingMore");
}
try {
const resp = await fetch(
`/api/projects/${id}/events?before=${encodeURIComponent(cursor)}&limit=${EVENTS_PAGE_SIZE}${subtreeParam()}`,
);
if (resp.ok) {
const page: ProjectEvent[] = await resp.json();
rawEventsLastID = page.length ? page[page.length - 1].id : rawEventsLastID;
rawEventsLastPageFull = page.length === EVENTS_PAGE_SIZE;
events = events.concat(applyVerlaufFilters(page));
eventsHasMore = rawEventsLastPageFull;
}
} catch {
/* swallow — the button re-enables and the user can retry */
} finally {
eventsLoadingMore = false;
if (btn) {
btn.disabled = false;
btn.textContent = t("projects.detail.verlauf.loadMore");
}
renderTimeline();
}
}
// Shape returned by /api/events — matches EventListItem in
// frontend/src/client/events.ts. Only the fields projects-detail needs.
interface UnionEvent {
type: "deadline" | "appointment";
id: string;
title: string;
project_id?: string;
project_title?: string;
due_date?: string;
status?: string;
rule_id?: string;
rule_code?: string;
start_at?: string;
end_at?: string;
location?: string;
appointment_type?: string;
}
async function loadDeadlines(id: string) {
try {
// t-paliad-139: switched from /api/projects/{id}/deadlines (legacy
// narrow path) to the union endpoint, which already aggregates
// descendants and enriches each row with project_title for the
// attribution chip.
const resp = await fetch(
`/api/events?type=deadline&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
);
if (resp.ok) {
const items: UnionEvent[] = (await resp.json()) ?? [];
deadlines = items
.filter((it) => it.type === "deadline")
.map((it) => ({
id: it.id,
project_id: it.project_id ?? "",
title: it.title,
due_date: it.due_date ?? "",
status: it.status ?? "pending",
rule_id: it.rule_id,
rule_code: it.rule_code,
project_title: it.project_title,
}));
} else {
deadlines = [];
}
} catch {
deadlines = [];
}
}
async function loadAppointments(id: string) {
try {
// t-paliad-139: same migration as loadDeadlines.
const resp = await fetch(
`/api/events?type=appointment&project_id=${encodeURIComponent(id)}${subtreeParam()}`,
);
if (resp.ok) {
const items: UnionEvent[] = (await resp.json()) ?? [];
appointments = items
.filter((it) => it.type === "appointment")
.map((it) => ({
id: it.id,
project_id: it.project_id,
title: it.title,
start_at: it.start_at ?? "",
end_at: it.end_at,
location: it.location,
appointment_type: it.appointment_type,
project_title: it.project_title,
}));
} else {
appointments = [];
}
} catch {
appointments = [];
}
}
function fmtDateTimeLocal(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function renderAppointments() {
const tbody = document.getElementById("project-appointments-body");
const empty = document.getElementById("project-appointments-empty");
const wrap = document.getElementById("project-appointments-tablewrap");
if (!tbody || !empty || !wrap) return;
if (appointments.length === 0) {
tbody.innerHTML = "";
wrap.style.display = "none";
empty.style.display = "block";
return;
}
wrap.style.display = "";
empty.style.display = "none";
tbody.innerHTML = appointments
.map((tt) => {
const typeLabel = tt.appointment_type ? tDyn(`appointments.type.${tt.appointment_type}`) || tt.appointment_type : "";
const typeClass = tt.appointment_type ? `termin-type-${tt.appointment_type}` : "";
return `<tr class="termin-row" data-id="${esc(tt.id)}">
<td class="frist-col-check"><span class="termin-dot ${typeClass}" /></td>
<td>${esc(fmtDateTimeLocal(tt.start_at))}</td>
<td>${esc(tt.title)}${attributionChip(tt.project_id, tt.project_title)}</td>
<td>${esc(tt.location ?? "")}</td>
<td><span class="termin-type-chip ${typeClass}">${esc(typeLabel)}</span></td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLTableRowElement>(".termin-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", () => {
window.location.href = `/appointments/${id}`;
});
});
}
function initProjectAppointmentForm() {
const addBtn = document.getElementById("appointment-add-btn") as HTMLButtonElement | null;
const form = document.getElementById("project-appointment-form") as HTMLFormElement | null;
const cancelBtn = document.getElementById("project-appointment-cancel") as HTMLButtonElement | null;
const msg = document.getElementById("project-appointment-msg");
if (!addBtn || !form || !cancelBtn || !msg) return;
addBtn.addEventListener("click", () => {
form.style.display = "";
addBtn.style.display = "none";
(document.getElementById("project-appointment-title") as HTMLInputElement).focus();
});
cancelBtn.addEventListener("click", () => {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
msg.textContent = "";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
const title = (document.getElementById("project-appointment-title") as HTMLInputElement).value.trim();
const start = (document.getElementById("project-appointment-start") as HTMLInputElement).value;
const end = (document.getElementById("project-appointment-end") as HTMLInputElement).value;
const type = (document.getElementById("project-appointment-type") as HTMLSelectElement).value;
const location = (document.getElementById("project-appointment-location") as HTMLInputElement).value.trim();
if (!title || !start) return;
const payload: Record<string, unknown> = {
project_id: project.id,
title,
start_at: new Date(start).toISOString(),
};
if (end) payload.end_at = new Date(end).toISOString();
if (type) payload.appointment_type = type;
if (location) payload.location = location;
msg.textContent = "";
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch("/api/appointments", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
await loadAppointments(project.id);
renderAppointments();
await loadTimeline(project.id);
renderTimeline();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("projects.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("projects.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
function fmtDateOnly(iso: string): string {
try {
const d = new Date(iso.slice(0, 10) + "T00:00:00");
return d.toLocaleDateString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
});
} catch {
return iso;
}
}
function urgencyClass(due: string, status: string): string {
if (status === "completed") return "frist-urgency-done";
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(due.slice(0, 10) + "T00:00:00");
const diffDays = Math.floor((d.getTime() - today.getTime()) / 86400000);
if (diffDays < 0) return "frist-urgency-overdue";
if (diffDays <= 7) return "frist-urgency-soon";
return "frist-urgency-later";
}
function renderDeadlines() {
const tbody = document.getElementById("project-deadlines-body");
const empty = document.getElementById("project-deadlines-empty");
const wrap = document.getElementById("project-deadlines-tablewrap");
if (!tbody || !empty || !wrap) return;
if (deadlines.length === 0) {
tbody.innerHTML = "";
wrap.style.display = "none";
empty.style.display = "block";
return;
}
wrap.style.display = "";
empty.style.display = "none";
tbody.innerHTML = deadlines
.map((f) => {
const urgency = urgencyClass(f.due_date, f.status);
const statusLabel = tDyn(`deadlines.status.${f.status}`) || f.status;
const checked = f.status === "completed" ? "checked" : "";
const disabled = f.status === "completed" ? "disabled" : "";
const titleClass = f.status === "completed" ? "frist-title-done" : "";
return `<tr class="frist-row" data-id="${esc(f.id)}">
<td class="frist-col-check">
<input type="checkbox" class="frist-complete-cb" ${checked} ${disabled}
aria-label="${esc(t("deadlines.complete.action"))}" />
</td>
<td class="frist-col-due ${urgency}"><span class="frist-due-dot"></span>${fmtDateOnly(f.due_date)}</td>
<td class="frist-col-title ${titleClass}">${esc(f.title)}${attributionChip(f.project_id, f.project_title)}</td>
<td class="frist-col-rule">${f.rule_code ? esc(f.rule_code) : "—"}</td>
<td><span class="entity-status-chip entity-status-${esc(f.status)}">${esc(statusLabel)}</span></td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLTableRowElement>(".frist-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
const target = e.target as HTMLElement;
if (target.closest(".frist-complete-cb")) return;
window.location.href = `/deadlines/${id}`;
});
const cb = row.querySelector<HTMLInputElement>(".frist-complete-cb");
if (cb) {
cb.addEventListener("change", async () => {
if (!cb.checked || !project) return;
cb.disabled = true;
const resp = await fetch(`/api/deadlines/${id}/complete`, { method: "PATCH" });
if (resp.ok) {
await loadDeadlines(project.id);
renderDeadlines();
await loadTimeline(project.id);
renderTimeline();
} else {
cb.checked = false;
cb.disabled = false;
}
});
}
});
}
// attributionChip renders a small inline chip showing which descendant
// project a row actually anchors on, when the row is from an aggregated
// subtree result and not from the project being viewed (t-paliad-139).
// Returns "" when the row's project is the current page or attribution
// data is missing.
function attributionChip(rowProjectID?: string, rowProjectTitle?: string): string {
if (!project) return "";
if (!rowProjectID || !rowProjectTitle) return "";
if (rowProjectID === project.id) return "";
const label = t("aggregation.attribution.on") || "auf";
return ` <span class="aggregation-chip" title="${escAttr(rowProjectTitle)}">${esc(label)}: ${esc(rowProjectTitle)}</span>`;
}
function esc(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function escAttr(s: string): string {
return s.replace(/&/g, "&amp;").replace(/"/g, "&quot;");
}
function fmtDateTime(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleString(getLang() === "de" ? "de-DE" : "en-GB", {
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
});
} catch {
return iso;
}
}
function renderHeader() {
if (!project) return;
(document.getElementById("project-title-display") as HTMLElement).textContent = project.title;
(document.getElementById("project-ref-display") as HTMLElement).textContent = project.reference || "";
// t-paliad-222 / m/paliad#50 — show the auto-derived project code
// as a second badge whenever it's non-empty AND distinct from the
// manual reference. Hides when the derived value equals reference
// (avoids visual duplication when the user typed the same string)
// or when no derivation produced a value.
const codeEl = document.getElementById("project-code-display") as HTMLElement | null;
if (codeEl) {
const code = project.code ?? "";
const ref = project.reference ?? "";
if (code && code !== ref) {
codeEl.textContent = code;
codeEl.style.display = "";
} else {
codeEl.textContent = "";
codeEl.style.display = "none";
}
}
// t-paliad-177 — link from Verlauf header to standalone chart page.
// Wired here (not in the TSX shell) because we need the resolved
// project id, which only exists after the detail fetch settles.
const chartLink = document.getElementById("smart-timeline-open-chart") as HTMLAnchorElement | null;
if (chartLink) {
chartLink.href = `/projects/${encodeURIComponent(project.id)}/chart`;
}
const descDisplay = document.getElementById("project-description-display") as HTMLElement;
const description = project.description ?? "";
descDisplay.textContent = description;
const descWrap = document.getElementById("project-description-wrap");
if (descWrap) {
// Hide the whole Notizen block when there is no description.
descWrap.style.display = description ? "" : "none";
}
const typeChip = document.getElementById("project-type-chip")!;
typeChip.className = `entity-type-chip entity-type-${project.type}`;
typeChip.textContent = tDyn(`projects.type.${project.type}`) || project.type;
// ClientMatter display. If the project itself has no client_number, walk
// up the ancestor chain to find an inherited one.
const cm = document.getElementById("project-clientmatter")!;
const effectiveClient = project.client_number || inheritedClientNumber();
const effectiveMatter = project.matter_number || "";
if (effectiveClient || effectiveMatter) {
cm.textContent =
effectiveClient && effectiveMatter
? `${effectiveClient}.${effectiveMatter}`
: effectiveClient || effectiveMatter;
if (!project.client_number && effectiveClient) {
cm.classList.add("entity-ref-inherited");
cm.title = t("projects.detail.clientmatter.inherited") || "inherited";
} else {
cm.classList.remove("entity-ref-inherited");
cm.title = "";
}
} else {
cm.textContent = "";
}
const statusChip = document.getElementById("project-status-chip")!;
statusChip.className = `entity-status-chip entity-status-${project.status}`;
statusChip.textContent = tDyn(`projects.filter.status.${project.status}`) || project.status;
const netdocs = document.getElementById("project-netdocs") as HTMLAnchorElement;
if (project.netdocuments_url) {
netdocs.href = project.netdocuments_url;
netdocs.style.display = "";
} else {
netdocs.style.display = "none";
}
// Delete visibility: partner/admin only. The Verwaltung tab's archive
// sub-section mirrors the same gate (t-paliad-245) — it only points at
// the Edit-modal danger zone, so it's pointless to show when the danger
// zone itself is hidden.
const deleteWrap = document.getElementById("project-delete-wrap")!;
const archiveSection = document.getElementById("project-settings-archive");
const canArchive = !!me && me.global_role === "global_admin";
deleteWrap.style.display = canArchive ? "" : "none";
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
updateSettingsTabVisibility();
}
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
// eventDetailHref. The renderEvents() orphan it paired with was removed
// in t-paliad-173; the SmartTimeline (renderTimeline) is now the only
// project-page render path.
function wrapEventTitleLink(e: ProjectEvent, escapedTitle: string): string {
const href = eventDetailHref(e);
if (href) {
return `<a href="${href}" class="entity-event-link">${escapedTitle}</a>`;
}
return escapedTitle;
}
// eventDetailHref resolves a ProjectEvent to a deep-link URL, or null if the
// event has no clickable target. Kept separate so the dashboard activity feed
// can reuse the same routing rules without duplicating the wrap logic.
function eventDetailHref(e: ProjectEvent): string | null {
const meta = e.metadata;
const evType = e.event_type ?? "";
if (!meta || typeof meta !== "object") return null;
const m = meta as Record<string, unknown>;
if (evType.startsWith("checklist_") && evType !== "checklist_deleted") {
const id = m["checklist_instance_id"];
if (typeof id === "string" && id) return `/checklists/instances/${esc(id)}`;
}
if (
evType.startsWith("deadline_") &&
evType !== "deadline_deleted" &&
evType !== "deadlines_imported"
) {
const id = m["deadline_id"];
if (typeof id === "string" && id) return `/deadlines/${esc(id)}`;
}
if (evType.startsWith("appointment_") && evType !== "appointment_deleted") {
const id = m["appointment_id"];
if (typeof id === "string" && id) return `/appointments/${esc(id)}`;
}
if (evType === "note_created") {
const apptID = m["appointment_id"];
if (typeof apptID === "string" && apptID) return `/appointments/${esc(apptID)}`;
const deadlineID = m["deadline_id"];
if (typeof deadlineID === "string" && deadlineID) return `/deadlines/${esc(deadlineID)}`;
const projectID = m["project_id"];
if (typeof projectID === "string" && projectID) return `/projects/${esc(projectID)}`;
}
return null;
}
function initEventsLoadMore() {
const btn = document.getElementById("project-events-loadmore");
if (!btn) return;
btn.addEventListener("click", () => {
if (project) void loadMoreEvents(project.id);
});
}
// initSmartTimelineAuditToggle — wires the "Audit-Log anzeigen" button
// in the Verlauf tab header. When ON, the next /timeline fetch passes
// ?include=audit_full so every paliad.project_events row surfaces (the
// legacy chronological Verlauf view); OFF only shows rows that opted
// into timeline_kind. State persists in localStorage per project.
function initSmartTimelineAuditToggle(id: string) {
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
if (!btn) return;
// Re-read from localStorage now that project is loaded.
timelineAuditFull = readPersistedAuditFull();
// Slice 2: lookahead state is also project-scoped — same pattern.
timelineLookahead = readLookaheadPersisted();
refreshAuditToggleLabel();
btn.addEventListener("click", async () => {
timelineAuditFull = !timelineAuditFull;
writePersistedAuditFull(timelineAuditFull);
refreshAuditToggleLabel();
await loadTimeline(id);
renderTimeline();
});
}
function refreshAuditToggleLabel() {
const btn = document.getElementById("smart-timeline-audit-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", timelineAuditFull ? "true" : "false");
btn.textContent = timelineAuditFull
? t("projects.detail.smarttimeline.audit.toggle.hide")
: t("projects.detail.smarttimeline.audit.toggle.show");
btn.classList.toggle("subtree-toggle--active", timelineAuditFull);
}
// Slice 4 — Client-level "Timeline-Ansicht" toggle (t-paliad-175 §5.1
// Q12). Visible only on Client-level projects; default OFF (matter-list
// view). When ON, the SmartTimeline lane view replaces the matter list.
// State persists in localStorage per project.
function clientShowLanesStorageKey(): string {
const id = project?.id ?? "_";
return `paliad.smarttimeline.client_show_lanes.${id}`;
}
function readClientShowLanes(): boolean {
try {
return localStorage.getItem(clientShowLanesStorageKey()) === "1";
} catch {
return false;
}
}
function writeClientShowLanes(on: boolean) {
try {
if (on) localStorage.setItem(clientShowLanesStorageKey(), "1");
else localStorage.removeItem(clientShowLanesStorageKey());
} catch {
// ignore
}
}
function initSmartTimelineClientToggle(id: string) {
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
if (!btn) return;
// Toggle is markup-rendered always; hide on non-Client projects.
if (project?.type !== "client") {
btn.style.display = "none";
return;
}
btn.style.display = "";
timelineClientShowLanes = readClientShowLanes();
refreshClientToggleLabel();
btn.addEventListener("click", async () => {
timelineClientShowLanes = !timelineClientShowLanes;
writeClientShowLanes(timelineClientShowLanes);
refreshClientToggleLabel();
// Reload to make sure lanes are populated when flipping ON.
await loadTimeline(id);
renderTimeline();
});
}
function refreshClientToggleLabel() {
const btn = document.getElementById("smart-timeline-client-toggle") as HTMLButtonElement | null;
if (!btn) return;
btn.setAttribute("aria-pressed", timelineClientShowLanes ? "true" : "false");
btn.textContent = timelineClientShowLanes
? t("projects.detail.smarttimeline.client.toggle.matter_list")
: t("projects.detail.smarttimeline.client.toggle.lanes");
btn.classList.toggle("subtree-toggle--active", timelineClientShowLanes);
}
// initSmartTimelineAddModal — wires the "+ Eintrag" CTA + modal. Only
// the "Eigener Meilenstein" route is fully wired in Slice 1 (writes
// to /api/projects/{id}/timeline/milestone); Frist + Termin are link
// buttons to the existing flows; CCR + R.30 are disabled with a
// "Slice 3" tooltip per the brief.
function initSmartTimelineAddModal(id: string) {
const cta = document.getElementById("smart-timeline-add-btn") as HTMLButtonElement | null;
const modal = document.getElementById("smart-timeline-add-modal") as HTMLDivElement | null;
if (!cta || !modal) return;
const choices = document.querySelector<HTMLDivElement>(".smart-timeline-add-choices");
const form = document.getElementById("smart-timeline-milestone-form") as HTMLFormElement | null;
const milestoneBtn = document.getElementById("smart-timeline-add-milestone") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("smart-timeline-milestone-cancel") as HTMLButtonElement | null;
const closeBtn = document.getElementById("smart-timeline-modal-close") as HTMLButtonElement | null;
const titleInput = document.getElementById("smart-timeline-milestone-title") as HTMLInputElement | null;
const dateInput = document.getElementById("smart-timeline-milestone-date") as HTMLInputElement | null;
const descInput = document.getElementById("smart-timeline-milestone-desc") as HTMLTextAreaElement | null;
const msg = document.getElementById("smart-timeline-milestone-msg") as HTMLDivElement | null;
const dlLink = document.getElementById("smart-timeline-add-deadline") as HTMLAnchorElement | null;
const apptLink = document.getElementById("smart-timeline-add-appointment") as HTMLAnchorElement | null;
if (dlLink) dlLink.href = `/deadlines/new?project=${encodeURIComponent(id)}`;
if (apptLink) apptLink.href = `/appointments/new?project=${encodeURIComponent(id)}`;
const open = () => {
modal.style.display = "";
if (choices) choices.style.display = "";
if (form) form.style.display = "none";
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
};
const close = () => {
modal.style.display = "none";
if (form) form.reset();
};
cta.addEventListener("click", open);
if (closeBtn) closeBtn.addEventListener("click", close);
if (cancelBtn) cancelBtn.addEventListener("click", close);
// Click outside the card → close.
modal.addEventListener("click", (e) => {
if (e.target === modal) close();
});
if (milestoneBtn && form) {
milestoneBtn.addEventListener("click", () => {
if (choices) choices.style.display = "none";
form.style.display = "";
titleInput?.focus();
});
}
if (form && titleInput) {
form.addEventListener("submit", async (e) => {
e.preventDefault();
const title = titleInput.value.trim();
if (!title) {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.title_required");
msg.className = "form-msg form-msg-error";
}
return;
}
const payload: Record<string, unknown> = { title };
const desc = descInput?.value.trim();
if (desc) payload.description = desc;
const date = dateInput?.value;
if (date) payload.occurred_at = date;
// Slice 4 — bubble-up checkbox (t-paliad-175 §7.2 Q5). Default OFF
// for custom_milestone; user opts in to surface this milestone on
// Patent / Litigation / Client SmartTimelines.
const bubbleEl = document.getElementById("smart-timeline-milestone-bubble-up") as HTMLInputElement | null;
if (bubbleEl?.checked) payload.bubble_up = true;
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}/timeline/milestone`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
close();
await loadTimeline(id);
renderTimeline();
} else {
const data = (await resp.json().catch(() => ({}))) as { error?: string };
if (msg) {
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
}
} catch {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
} finally {
submitBtn.disabled = false;
}
});
}
// Slice 3 — Widerklage (CCR) route: opens an inline form, fetches
// proceeding types lazily on first open, posts to
// /api/projects/{id}/counterclaim, navigates to the new child page on
// success.
initCounterclaimRoute(id, modal, choices, form);
}
// loadProceedingTypes is shared from ./project-form so the counterclaim
// modal here and the project-edit picker hit the same cache.
const loadProceedingTypes = loadProceedingTypesShared;
function initCounterclaimRoute(
id: string,
modal: HTMLDivElement,
choices: HTMLDivElement | null,
milestoneForm: HTMLFormElement | null,
) {
const trigger = document.getElementById("smart-timeline-add-counterclaim") as HTMLButtonElement | null;
const form = document.getElementById("smart-timeline-counterclaim-form") as HTMLFormElement | null;
const cancel = document.getElementById("smart-timeline-counterclaim-cancel") as HTMLButtonElement | null;
const procedureSel = document.getElementById("smart-timeline-counterclaim-procedure") as HTMLSelectElement | null;
const titleInput = document.getElementById("smart-timeline-counterclaim-title") as HTMLInputElement | null;
const caseNumberInput = document.getElementById("smart-timeline-counterclaim-case-number") as HTMLInputElement | null;
const flipToggle = document.getElementById("smart-timeline-counterclaim-flip-toggle") as HTMLInputElement | null;
const msg = document.getElementById("smart-timeline-counterclaim-msg") as HTMLDivElement | null;
if (!trigger || !form) return;
const closeModal = () => {
modal.style.display = "none";
form.reset();
};
trigger.addEventListener("click", async () => {
if (choices) choices.style.display = "none";
if (milestoneForm) milestoneForm.style.display = "none";
form.style.display = "";
if (msg) {
msg.textContent = "";
msg.className = "form-msg";
}
// Populate proceeding-type select on first open. Only UPC types
// make sense for a CCR (Nichtigkeit/CCI); pre-select upc.rev.cfi.
if (procedureSel && procedureSel.options.length === 0) {
const types = await loadProceedingTypes();
const upcTypes = types.filter((t) => (t.jurisdiction ?? "").toUpperCase() === "UPC");
const langEN = getLang() === "en";
for (const ty of upcTypes) {
const opt = document.createElement("option");
opt.value = String(ty.id);
opt.textContent = `${ty.code}${langEN ? ty.name_en || ty.name : ty.name}`;
if (ty.code === "upc.rev.cfi") opt.selected = true;
procedureSel.appendChild(opt);
}
}
titleInput?.focus();
});
if (cancel) cancel.addEventListener("click", closeModal);
form.addEventListener("submit", async (e) => {
e.preventDefault();
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.counterclaim.saving");
msg.className = "form-msg";
}
const payload: Record<string, unknown> = {};
if (procedureSel && procedureSel.value) {
const n = parseInt(procedureSel.value, 10);
if (!isNaN(n)) payload.proceeding_type_id = n;
}
const titleVal = titleInput?.value.trim();
if (titleVal) payload.title = titleVal;
const caseNum = caseNumberInput?.value.trim();
if (caseNum) payload.case_number = caseNum;
// flipToggle CHECKED = "Stimmt nicht?" = do NOT flip our_side.
// Backend interprets flip_our_side=false as "keep parent's side".
if (flipToggle && flipToggle.checked) {
payload.flip_our_side = false;
}
try {
const resp = await fetch(
`/api/projects/${encodeURIComponent(id)}/counterclaim`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
},
);
if (resp.ok) {
const data = (await resp.json()) as { id?: string; url?: string };
const dest = data.url ?? (data.id ? `/projects/${data.id}` : null);
if (dest) {
window.location.href = dest;
return;
}
// No id back? Defensive: just close + reload timeline.
closeModal();
await loadTimeline(id);
renderTimeline();
return;
}
const data = (await resp.json().catch(() => ({}))) as { error?: string };
if (msg) {
msg.textContent = data.error || t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
if (msg) {
msg.textContent = t("projects.detail.smarttimeline.error.generic");
msg.className = "form-msg form-msg-error";
}
} finally {
submitBtn.disabled = false;
}
});
}
function renderParties() {
const tbody = document.getElementById("parties-body")!;
const empty = document.getElementById("parties-empty")!;
const tableWrap = tbody.closest<HTMLElement>("table")!;
if (parties.length === 0) {
tbody.innerHTML = "";
tableWrap.style.display = "none";
empty.style.display = "block";
return;
}
tableWrap.style.display = "";
empty.style.display = "none";
tbody.innerHTML = parties
.map((p) => {
const roleKey = p.role ? `projects.detail.parteien.role.${p.role}` : "";
const roleLabel = p.role ? tDyn(roleKey) || p.role : "";
return `<tr data-id="${esc(p.id)}">
<td>${esc(p.name)}</td>
<td>${esc(roleLabel)}</td>
<td>${esc(p.representative || "")}</td>
<td class="entity-col-actions">
<button type="button" class="btn-link-danger party-remove" data-i18n="projects.detail.parteien.remove">Entfernen</button>
</td>
</tr>`;
})
.join("");
tbody.querySelectorAll<HTMLButtonElement>(".party-remove").forEach((btn) => {
btn.textContent = t("projects.detail.parteien.remove");
btn.addEventListener("click", async () => {
const row = btn.closest<HTMLTableRowElement>("tr")!;
const id = row.dataset.id!;
if (!confirm(t("projects.detail.parteien.remove.confirm"))) return;
const resp = await fetch(`/api/parties/${id}`, { method: "DELETE" });
if (resp.ok && project) {
await loadParties(project.id);
renderParties();
}
});
});
}
function showTab(tab: TabId) {
document.querySelectorAll<HTMLElement>(".entity-tab").forEach((el) => {
el.classList.toggle("active", el.dataset.tab === tab);
});
document.querySelectorAll<HTMLElement>(".entity-tab-panel").forEach((el) => {
el.style.display = el.id === `tab-${tab}` ? "" : "none";
});
// Deep-link via pushState so sub-routes stay shareable.
if (project) {
const newPath = `/projects/${project.id}/${tab}`;
if (window.location.pathname !== newPath) {
window.history.replaceState({}, "", newPath);
}
}
if (tab === "checklists" && project) {
void loadAndRenderChecklistInstances(project.id);
}
if (tab === "submissions" && project) {
void loadAndRenderSubmissions(project.id);
}
}
let checklistInstancesInited = false;
let checklistCatalogLoaded = false;
// loadChecklistCatalog populates `checklistTemplates` (slug → template) from
// `/api/checklists`. Reused by the tab renderer and the add-instance modal so
// the second open doesn't refetch the catalog (t-paliad-239).
async function loadChecklistCatalog(): Promise<ChecklistTemplateSummary[]> {
if (checklistCatalogLoaded) return Object.values(checklistTemplates);
try {
const resp = await fetch(`/api/checklists`);
const list = resp.ok ? (((await resp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
checklistTemplates = {};
for (const tpl of list) checklistTemplates[tpl.slug] = tpl;
checklistCatalogLoaded = true;
return list;
} catch {
return [];
}
}
async function loadAndRenderChecklistInstances(projectID: string, force = false) {
if (checklistInstancesInited && !force) return;
checklistInstancesInited = true;
try {
const [instResp] = await Promise.all([
fetch(`/api/projects/${projectID}/checklists`),
loadChecklistCatalog(),
]);
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
} catch {
checklistInstances = [];
}
renderChecklistInstances();
}
function renderChecklistInstances() {
const body = document.getElementById("project-checklists-body");
const empty = document.getElementById("project-checklists-empty");
const wrap = document.getElementById("project-checklists-tablewrap");
if (!body || !empty || !wrap) return;
if (checklistInstances.length === 0) {
empty.style.display = "";
wrap.style.display = "none";
return;
}
empty.style.display = "none";
wrap.style.display = "";
const isEN = document.documentElement.lang === "en";
const fmtDate = (iso: string) => {
const d = new Date(iso);
if (isNaN(d.getTime())) return "";
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
year: "numeric", month: "2-digit", day: "2-digit",
});
};
body.innerHTML = checklistInstances.map((inst) => {
const tpl = checklistTemplates[inst.template_slug];
const tplName = tpl ? (isEN ? tpl.titleEN : tpl.titleDE) : inst.template_slug;
const total = tpl ? tpl.itemCount : 0;
const done = Object.values(inst.state || {}).filter(Boolean).length;
const pct = total === 0 ? 0 : Math.round((done / total) * 100);
return `<tr class="checklist-instance-row" data-id="${escapeHtml(inst.id)}">
<td>${escapeHtml(tplName)}</td>
<td><a href="/checklists/instances/${escapeHtml(inst.id)}" class="checklist-instance-name">${escapeHtml(inst.name)}</a></td>
<td>
<div class="checklist-progress-inline">
<div class="checklist-progress-bar">
<div class="checklist-progress-fill" style="width:${pct}%"></div>
</div>
<span class="checklist-progress-label">${done} / ${total}</span>
</div>
</td>
<td>${escapeHtml(fmtDate(inst.created_at))}</td>
</tr>`;
}).join("");
body.querySelectorAll<HTMLTableRowElement>(".checklist-instance-row").forEach((row) => {
const id = row.dataset.id!;
row.addEventListener("click", (e) => {
if ((e.target as HTMLElement).closest("a")) return;
window.location.href = `/checklists/instances/${id}`;
});
});
}
// initAddChecklistModal wires the "Checkliste hinzufügen" button on the
// project-detail Checklists tab (t-paliad-239). Opens a template picker
// modal; on pick, POSTs to /api/checklists/{slug}/instances with the
// current project_id and the template title as the instance name.
function initAddChecklistModal(projectID: string) {
const addBtn = document.getElementById("checklist-add-btn") as HTMLButtonElement | null;
const modal = document.getElementById("add-checklist-modal") as HTMLDivElement | null;
const closeBtn = document.getElementById("add-checklist-close") as HTMLButtonElement | null;
const search = document.getElementById("add-checklist-search") as HTMLInputElement | null;
const list = document.getElementById("add-checklist-list") as HTMLDivElement | null;
const empty = document.getElementById("add-checklist-empty") as HTMLParagraphElement | null;
const modalMsg = document.getElementById("add-checklist-msg") as HTMLParagraphElement | null;
const tabMsg = document.getElementById("project-checklists-msg") as HTMLParagraphElement | null;
if (!addBtn || !modal || !closeBtn || !search || !list || !empty || !modalMsg || !tabMsg) return;
const close = () => {
modal.style.display = "none";
modalMsg.textContent = "";
modalMsg.className = "form-msg";
};
const renderPicker = () => {
const isEN = getLang() === "en";
const q = search.value.trim().toLowerCase();
const all = Object.values(checklistTemplates);
all.sort((a, b) => {
const at = (isEN ? a.titleEN : a.titleDE) || a.slug;
const bt = (isEN ? b.titleEN : b.titleDE) || b.slug;
return at.localeCompare(bt, isEN ? "en" : "de");
});
const filtered = q
? all.filter((tpl) => {
const title = (isEN ? tpl.titleEN : tpl.titleDE) || "";
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
return title.toLowerCase().includes(q)
|| desc.toLowerCase().includes(q)
|| (tpl.regime || "").toLowerCase().includes(q);
})
: all;
if (filtered.length === 0) {
list.innerHTML = "";
empty.style.display = "";
return;
}
empty.style.display = "none";
list.innerHTML = filtered.map((tpl) => {
const title = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
const regime = tpl.regime || "";
const regimeChip = regime
? `<span class="checklist-regime checklist-regime-${escapeHtml(regime)}">${escapeHtml(regime)}</span>`
: "";
const descLine = desc ? `<p class="add-checklist-row-desc">${escapeHtml(desc)}</p>` : "";
return `<button type="button" class="add-checklist-row" data-slug="${escapeHtml(tpl.slug)}">
<div class="add-checklist-row-head">
<span class="add-checklist-row-title">${escapeHtml(title)}</span>
${regimeChip}
</div>
${descLine}
</button>`;
}).join("");
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((btn) => {
btn.addEventListener("click", () => {
const slug = btn.dataset.slug!;
void pickTemplate(slug, btn);
});
});
};
const pickTemplate = async (slug: string, btn: HTMLButtonElement) => {
const tpl = checklistTemplates[slug];
if (!tpl) return;
const isEN = getLang() === "en";
const name = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
b.disabled = true;
});
modalMsg.textContent = "";
modalMsg.className = "form-msg";
try {
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ name, project_id: projectID }),
});
if (!resp.ok) {
modalMsg.textContent = t("projects.detail.checklisten.add.error");
modalMsg.className = "form-msg form-msg-error";
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
b.disabled = false;
});
return;
}
close();
flashTabMsg(t("projects.detail.checklisten.add.created"));
await loadAndRenderChecklistInstances(projectID, true);
} catch {
modalMsg.textContent = t("projects.detail.checklisten.add.error");
modalMsg.className = "form-msg form-msg-error";
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
b.disabled = false;
});
}
};
let flashTimer = 0;
const flashTabMsg = (text: string) => {
tabMsg.textContent = text;
tabMsg.className = "form-msg form-msg-success";
if (flashTimer) window.clearTimeout(flashTimer);
flashTimer = window.setTimeout(() => {
tabMsg.textContent = "";
tabMsg.className = "form-msg";
}, 3500);
};
addBtn.addEventListener("click", async () => {
await loadChecklistCatalog();
search.value = "";
modalMsg.textContent = "";
modalMsg.className = "form-msg";
renderPicker();
modal.style.display = "flex";
search.focus();
});
closeBtn.addEventListener("click", close);
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
search.addEventListener("input", renderPicker);
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && modal.style.display !== "none") close();
});
}
function escapeHtml(s: string): string {
const d = document.createElement("div");
d.textContent = s;
return d.innerHTML;
}
function initTabs() {
const id = project?.id ?? parseProjectID();
document.querySelectorAll<HTMLAnchorElement>(".entity-tab").forEach((tab) => {
if (id) tab.href = `/projects/${id}/${tab.dataset.tab}`;
tab.addEventListener("click", (e) => {
// SPA flow on plain left-click; let the browser handle middle-click,
// ctrl/meta-click, and "open in new tab" via the real href above.
if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
e.preventDefault();
showTab(tab.dataset.tab as TabId);
});
});
}
// Edit modal — full form, same fields as /projects/new but pre-filled and
// PATCH'd back. The shared client/project-form module handles parent-picker
// suggestions, type-driven field visibility, and payload building.
let editFormPrepared = false;
async function prepareEditForm() {
if (editFormPrepared) return;
editFormPrepared = true;
wireTypeChange();
// Exclude the project itself so users can't accidentally pick themselves
// as the new parent (server would reject anyway).
await loadParentCandidates(project?.id);
initParentPicker();
await populateProceedingTypeSelect();
}
// openEditModal opens the project-edit modal, optionally scrolling +
// focusing a specific field after the form is prefilled. Callers like
// the Schriftsätze empty-state CTA pass focusFieldID="project-proceeding-
// type-id" to land the user directly on the picker they came to set.
function openEditModal(focusFieldID?: string) {
if (!project) return;
const modal = document.getElementById("project-edit-modal");
const msg = document.getElementById("project-edit-msg");
if (!modal || !msg) return;
void prepareEditForm().then(() => {
if (!project) return;
prefillForm(project as unknown as Record<string, unknown>);
// Pre-fill the parent picker label from the immediate parent (if any).
const parentInput = document.getElementById("projekt-parent-input") as HTMLInputElement | null;
const parentHidden = document.getElementById("projekt-parent-id") as HTMLInputElement | null;
if (parentInput && parentHidden) {
if (project.parent_id && ancestors.length > 0) {
const parent = ancestors[ancestors.length - 1];
parentHidden.value = parent.id;
parentInput.value = parent.title;
} else {
parentHidden.value = "";
parentInput.value = "";
}
}
// Re-parenting is out of scope for the edit modal — disable the picker.
if (parentInput) parentInput.disabled = true;
// Type changes are allowed (t-paliad-056). Wire the warning that lists
// which fields will be NULL'd server-side when the user picks a new
// type.
const typeSel = document.getElementById("project-type") as HTMLSelectElement | null;
if (typeSel) {
typeSel.disabled = false;
typeSel.onchange = () => {
// Keep the upstream visibility toggle that wireTypeChange installed.
renderTypeChangeWarning();
};
}
renderTypeChangeWarning();
if (focusFieldID) {
// Wait a tick so the modal has laid out before scrolling — the
// wrapping flex container is display:flex so the field's offset
// height is only reliable after the next animation frame.
requestAnimationFrame(() => {
const target = document.getElementById(focusFieldID);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
if (target instanceof HTMLSelectElement || target instanceof HTMLInputElement) {
target.focus();
}
});
}
});
msg.textContent = "";
msg.className = "form-msg";
modal.style.display = "flex";
}
// renderTypeChangeWarning compares the type select's current value against
// the loaded project's type. When they differ AND the old type has
// non-NULL type-specific fields on the project record, it surfaces an
// inline warning naming each field that will be cleared on save.
//
// Source of truth for the field map mirrors the server's
// typeSpecificColumns helper. Keep them in sync.
const TYPE_SPECIFIC_FIELDS: Record<string, { key: string; i18n: string }[]> = {
client: [
{ key: "industry", i18n: "projects.field.industry" },
{ key: "country", i18n: "projects.field.country" },
{ key: "client_number", i18n: "projects.field.client_number" },
],
patent: [
{ key: "patent_number", i18n: "projects.field.patent_number" },
{ key: "filing_date", i18n: "projects.field.filing_date" },
{ key: "grant_date", i18n: "projects.field.grant_date" },
],
case: [
{ key: "court", i18n: "projects.field.court" },
{ key: "case_number", i18n: "projects.field.case_number" },
{ key: "proceeding_type_id", i18n: "projects.field.proceeding_type_id" },
],
};
function renderTypeChangeWarning() {
const wrap = document.getElementById("project-edit-type-warning") as HTMLDivElement | null;
const fieldsSpan = document.getElementById("project-edit-type-warning-fields") as HTMLSpanElement | null;
const typeSel = document.getElementById("project-type") as HTMLSelectElement | null;
if (!wrap || !fieldsSpan || !typeSel || !project) return;
const newType = typeSel.value;
if (newType === project.type) {
wrap.style.display = "none";
fieldsSpan.textContent = "";
return;
}
const obsolete = TYPE_SPECIFIC_FIELDS[project.type] || [];
const projectRec = project as unknown as Record<string, unknown>;
const lost = obsolete
.filter((f) => {
const v = projectRec[f.key];
return v !== null && v !== undefined && v !== "";
})
.map((f) => tDyn(f.i18n) || f.key);
if (lost.length === 0) {
wrap.style.display = "none";
fieldsSpan.textContent = "";
return;
}
wrap.style.display = "";
// Translate the static label too (it has data-i18n but the modal may have
// opened before the lang change handler re-translated it).
const titleEl = wrap.querySelector("strong");
if (titleEl) titleEl.textContent = t("projects.detail.edit.type_change_warning.title") || titleEl.textContent || "";
fieldsSpan.textContent = " " + lost.join(", ");
}
function closeEditModal() {
const modal = document.getElementById("project-edit-modal");
if (modal) modal.style.display = "none";
}
function initEditModal() {
const editBtn = document.getElementById("project-edit-btn") as HTMLButtonElement | null;
const modal = document.getElementById("project-edit-modal");
const closeBtn = document.getElementById("project-edit-modal-close");
const cancelBtn = document.getElementById("project-edit-cancel");
const form = document.getElementById("project-edit-form") as HTMLFormElement | null;
const msg = document.getElementById("project-edit-msg") as HTMLParagraphElement | null;
if (!editBtn || !modal || !closeBtn || !cancelBtn || !form || !msg) return;
editBtn.addEventListener("click", () => openEditModal());
closeBtn.addEventListener("click", closeEditModal);
cancelBtn.addEventListener("click", closeEditModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeEditModal();
});
// Schriftsätze empty-state CTA — when the panel reports "no proceeding
// set", clicking the button opens the edit modal directly on the
// Verfahrenstyp picker so the lawyer can resolve the gap in one step
// (t-paliad-232).
const submissionsCTA = document.getElementById(
"project-submissions-edit-cta",
) as HTMLButtonElement | null;
if (submissionsCTA) {
submissionsCTA.addEventListener("click", () => {
openEditModal("project-proceeding-type-id");
});
}
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
// the danger-zone archive button (t-paliad-245).
const archiveLink = document.getElementById(
"project-settings-archive-link",
) as HTMLButtonElement | null;
if (archiveLink) {
archiveLink.addEventListener("click", () => {
openEditModal("project-delete-btn");
});
}
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
msg.textContent = "";
msg.className = "form-msg";
const payload = readPayload(msg, { omitEmpty: false, mode: "edit" });
if (!payload) return;
// Type changes from the edit form are an unusual structural action —
// the server allows it but we're explicit about not sending `type` when
// unchanged so the backend doesn't run avoidable validation.
if (payload.type === project.type) delete payload.type;
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
try {
const resp = await fetch(`/api/projects/${project.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const errBody = await resp.json().catch(() => ({ error: "unknown" }));
msg.textContent = errBody.error || t("projects.error.generic");
msg.className = "form-msg form-msg-error";
return;
}
project = await resp.json();
closeEditModal();
if (project) {
await Promise.all([loadAncestors(project.id), loadTimeline(project.id)]);
renderHeader();
renderBreadcrumb();
renderTimeline();
}
} catch (err) {
msg.textContent = t("projects.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
function initPartiesForm() {
const addBtn = document.getElementById("party-add-btn") as HTMLButtonElement;
const form = document.getElementById("party-form") as HTMLFormElement;
const cancelBtn = document.getElementById("party-cancel") as HTMLButtonElement;
const msg = document.getElementById("party-msg")!;
// Both the toolbar button and the empty-state CTA (F-43) trigger the
// same form-open path.
const openForm = () => {
form.style.display = "";
addBtn.style.display = "none";
(document.getElementById("party-name") as HTMLInputElement).focus();
};
addBtn.addEventListener("click", openForm);
document.getElementById("parties-empty-cta")?.addEventListener("click", openForm);
cancelBtn.addEventListener("click", () => {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
msg.textContent = "";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
if (!project) return;
const name = (document.getElementById("party-name") as HTMLInputElement).value.trim();
const role = (document.getElementById("party-role") as HTMLSelectElement).value;
const rep = (document.getElementById("party-rep") as HTMLInputElement).value.trim();
if (!name) return;
msg.textContent = "";
const submitBtn = form.querySelector<HTMLButtonElement>("button[type=submit]")!;
submitBtn.disabled = true;
const payload: Record<string, unknown> = { name, role };
if (rep) payload.representative = rep;
try {
const resp = await fetch(`/api/projects/${project.id}/parties`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (resp.ok) {
form.reset();
form.style.display = "none";
addBtn.style.display = "";
await loadParties(project.id);
renderParties();
await loadTimeline(project.id);
renderTimeline();
} else {
const data = await resp.json().catch(() => ({}) as { error?: string });
msg.textContent = data.error || t("projects.error.generic");
msg.className = "form-msg form-msg-error";
}
} catch {
msg.textContent = t("projects.error.generic");
msg.className = "form-msg form-msg-error";
} finally {
submitBtn.disabled = false;
}
});
}
function initDeadlineAddLink() {
if (!project) return;
const link = document.getElementById("deadline-add-link") as HTMLAnchorElement | null;
if (link) link.href = `/projects/${project.id}/deadlines/new`;
}
function initDelete() {
const btn = document.getElementById("project-delete-btn")!;
const modal = document.getElementById("delete-modal")!;
const close = document.getElementById("delete-modal-close")!;
const cancel = document.getElementById("delete-modal-cancel")!;
const confirmBtn = document.getElementById("delete-modal-confirm") as HTMLButtonElement;
btn.addEventListener("click", () => {
modal.style.display = "flex";
});
const closeModal = () => {
modal.style.display = "none";
};
close.addEventListener("click", closeModal);
cancel.addEventListener("click", closeModal);
modal.addEventListener("click", (e) => {
if (e.target === e.currentTarget) closeModal();
});
confirmBtn.addEventListener("click", async () => {
if (!project) return;
confirmBtn.disabled = true;
const resp = await fetch(`/api/projects/${project.id}`, { method: "DELETE" });
if (resp.ok) {
window.location.href = "/projects";
} else {
confirmBtn.disabled = false;
closeModal();
}
});
}
async function main() {
const id = parseProjectID();
const loading = document.getElementById("project-detail-loading")!;
const notfound = document.getElementById("project-detail-notfound")!;
const body = document.getElementById("project-detail-body")!;
if (!id) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
// Read subtree mode from URL once at startup; subsequent toggles update
// the URL via persistSubtreeMode (replaceState — back-button friendly).
subtreeMode = parseSubtreeMode();
await loadMe();
const ok = await loadProject(id);
if (!ok || !project) {
loading.style.display = "none";
notfound.style.display = "block";
return;
}
// loadEvents stays in this Promise.all so the unfiltered Verlauf is
// ready by first paint (avoids an empty-state flash before the bar's
// customRunner finishes its first run, t-paliad-170). When the URL
// carries filter params (?time=…, ?pe_kind=…) the bar's mount triggers
// a second fetch that narrows to the requested rows — accepted cost.
await Promise.all([
loadParties(id),
loadEvents(id),
loadTimeline(id),
loadDeadlines(id),
loadAppointments(id),
loadAncestors(id),
loadChildren(id),
loadTeam(id),
loadDescendantStaffed(id),
loadDerivedMembers(id),
loadAttachedUnits(id),
loadAllUnits(),
loadUserList(),
]);
loading.style.display = "none";
body.style.display = "";
renderHeader();
renderBreadcrumb();
renderParties();
renderTimeline();
renderDeadlines();
renderAppointments();
renderChildren();
renderTeam();
initDeadlineAddLink();
initChildAddLink();
initTabs();
initEditModal();
initPartiesForm();
initProjectAppointmentForm();
initTeamForm(id);
initDelete();
initEventsLoadMore();
initSubtreeToggles(id);
initSmartTimelineAuditToggle(id);
initSmartTimelineClientToggle(id);
initSmartTimelineAddModal(id);
initAttachUnitForm(id);
initAddChecklistModal(id);
initNotesContainer(id);
mountVerlaufFilterBar(id);
wireExportButton(id);
showTab(parseTab());
}
// mountVerlaufFilterBar mounts the universal FilterBar inside the
// Verlauf tab (t-paliad-170 → t-paliad-176). The bar owns URL params
// (?time=, ?pe_kind=, ?tl_status=, ?tl_track=) and the displayed filter
// chrome; on every state change it invokes the customRunner below, which
// drains the bar state into `verlaufFilters` and lets the bar's onResult
// callback trigger renderTimeline — narrowing happens client-side over
// `timelineRows` in `applyTimelineRowFilters`.
//
// Why customRunner instead of the substrate POST: the SmartTimeline
// endpoint isn't a substrate-managed system view, and timeline_status /
// timeline_track / project_event_kind don't all map cleanly onto the
// substrate's per-source predicates. The customRunner stays as the bar's
// integration point with the SmartTimeline read pipeline.
function mountVerlaufFilterBar(id: string): void {
const host = document.getElementById("project-events-filter-bar");
if (!host) return;
// Synthetic spec — never reaches the substrate (customRunner short-
// circuits the bar's POST), but the bar's contract requires shapes
// that the substrate validator would accept. Sources / scope mirror
// what a future ProjectHistorySystemView would look like.
const baseFilter: FilterSpec = {
version: 1,
sources: ["project_event"],
scope: { projects: { mode: "explicit", ids: [id] } },
time: { horizon: "any" },
};
const baseRender: RenderSpec = { shape: "list" };
verlaufBar = mountFilterBar(host, {
baseFilter,
baseRender,
// t-paliad-176 — exposing timeline_status + timeline_track on the
// Verlauf tab. They were declared in the bar's axis catalogue from
// Slice 2 onward but never mounted on this surface; chips were
// therefore invisible and the wire-up was a no-op.
axes: ["time", "timeline_status", "timeline_track", "project_event_kind"],
surfaceKey: "project-history",
showSaveAsView: false,
timePresets: ["past_7d", "past_30d", "past_90d", "any"],
customRunner: async (effective, state) => {
// project_event_kind rides through effective.filter.predicates
// (substrate-shaped); timeline_status / timeline_track live on raw
// BarState. The bar passes both to keep first-run hydration honest
// (the bar handle hasn't been assigned to verlaufBar yet on first
// run, so we can't reach getState() — the state argument fixes that).
const kinds = effective.filter.predicates?.project_event?.event_types;
const tlStatus = state.timeline_status;
const tlTrack = state.timeline_track;
verlaufFilters = {
eventKinds: kinds && kinds.length ? new Set(kinds) : undefined,
timelineStatuses: tlStatus && tlStatus.length ? new Set(tlStatus) : undefined,
timelineTracks: tlTrack && tlTrack.length ? new Set(tlTrack) : undefined,
...horizonBounds(effective.filter.time?.horizon ?? "any"),
};
return { rows: [], inaccessible_project_ids: [] };
},
onResult: () => renderTimeline(),
});
}
// initAttachUnitForm wires the "Partner Unit zuordnen" form on the Team
// tab (project lead / global_admin only). The select is populated from
// /api/partner-units excluding units already attached.
function initAttachUnitForm(id: string) {
const wrap = document.getElementById("unit-attach-form-wrap");
const form = document.getElementById("unit-attach-form") as HTMLFormElement | null;
const showBtn = document.getElementById("unit-attach-show") as HTMLButtonElement | null;
const cancelBtn = document.getElementById("unit-attach-cancel") as HTMLButtonElement | null;
const select = document.getElementById("unit-attach-select") as HTMLSelectElement | null;
if (!wrap || !form || !showBtn || !cancelBtn || !select) return;
if (!canManagePartnerUnits()) {
showBtn.style.display = "none";
return;
}
const refreshSelect = () => {
const attachedIDs = new Set(attachedUnits.map((u) => u.partner_unit_id));
const placeholder = `<option value="">${esc(t("projects.team.units.choose") || "Bitte Unit wählen…")}</option>`;
const opts = allUnits
.filter((u) => !attachedIDs.has(u.id))
.map((u) => `<option value="${esc(u.id)}">${esc(u.name)}</option>`)
.join("");
select.innerHTML = placeholder + opts;
};
refreshSelect();
showBtn.addEventListener("click", () => {
refreshSelect();
wrap.style.display = "";
showBtn.style.display = "none";
});
cancelBtn.addEventListener("click", () => {
form.reset();
wrap.style.display = "none";
showBtn.style.display = "";
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
const unitID = select.value;
if (!unitID) return;
const rolePA = (document.getElementById("unit-attach-role-pa") as HTMLInputElement).checked;
const roleSenior = (document.getElementById("unit-attach-role-senior_pa") as HTMLInputElement).checked;
const roleAtty = (document.getElementById("unit-attach-role-attorney") as HTMLInputElement).checked;
const grantsAuthority = (document.getElementById("unit-attach-authority") as HTMLInputElement).checked;
const roles: string[] = [];
if (rolePA) roles.push("pa");
if (roleSenior) roles.push("senior_pa");
if (roleAtty) roles.push("attorney");
if (roles.length === 0) {
// Defaults: pa + senior_pa.
roles.push("pa", "senior_pa");
}
const resp = await fetch(`/api/projects/${id}/partner-units`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
partner_unit_id: unitID,
derive_unit_roles: roles,
derive_grants_authority: grantsAuthority,
}),
});
if (resp.ok) {
form.reset();
wrap.style.display = "none";
showBtn.style.display = "";
await Promise.all([loadAttachedUnits(id), loadDerivedMembers(id)]);
renderTeam();
}
});
}
// initSubtreeToggles wires the "Inkl. Unterprojekte / Nur direkt" buttons
// in the History, Deadlines, and Appointments sections. State is shared
// across the three sections (one toggle flips all) and persisted in the
// URL via ?subtree=false. Default = subtree (true).
function initSubtreeToggles(id: string) {
const buttons = document.querySelectorAll<HTMLButtonElement>(".subtree-toggle");
if (buttons.length === 0) return;
const refreshLabels = () => {
buttons.forEach((btn) => {
btn.textContent = subtreeMode
? t("aggregation.toggle.subtree")
: t("aggregation.toggle.direct_only");
btn.setAttribute("aria-pressed", subtreeMode ? "true" : "false");
btn.classList.toggle("subtree-toggle--active", !subtreeMode);
});
};
refreshLabels();
buttons.forEach((btn) => {
btn.addEventListener("click", async () => {
subtreeMode = !subtreeMode;
persistSubtreeMode();
refreshLabels();
// verlaufBar.refresh() drives loadEvents through the bar's
// customRunner (so the current filter state stays applied).
// verlaufBar.refresh() drives loadEvents through the bar's
// customRunner, but render is now driven entirely by loadTimeline.
const barRefresh = verlaufBar ? verlaufBar.refresh() : Promise.resolve();
await Promise.all([
barRefresh,
loadTimeline(id),
loadDeadlines(id),
loadAppointments(id),
]);
renderTimeline();
renderDeadlines();
renderAppointments();
});
});
}
// ----- Breadcrumb + ancestor resolution -----------------------------------
function inheritedClientNumber(): string | null {
// Walks ancestor chain (root → parent) and returns the nearest non-null
// client_number for display when the project itself has none.
for (let i = ancestors.length - 1; i >= 0; i--) {
const a = ancestors[i] as ProjectMini & { 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/projects/${id}/ancestors`);
if (resp.ok) ancestors = ((await resp.json()) as ProjectMini[]) ?? [];
} catch {
ancestors = [];
}
}
// Lucide-style 24x24 icons matched to the project tree's icon set so the
// visual language stays consistent across the app.
const TYPE_ICONS: Record<string, string> = {
client:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<rect x="2" y="7" width="20" height="14" rx="2"/>` +
`<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>` +
`</svg>`,
litigation:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M16 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/>` +
`<path d="M2 16l3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1z"/>` +
`<path d="M7 21h10"/>` +
`<path d="M12 3v18"/>` +
`<path d="M3 7h18"/>` +
`</svg>`,
patent:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2V7"/>` +
`<path d="M3 7v12a2 2 0 0 0 2 2h0"/>` +
`<path d="M21 3a2 2 0 0 0-2 2v14"/>` +
`<line x1="9" y1="9" x2="15" y2="9"/>` +
`<line x1="9" y1="13" x2="15" y2="13"/>` +
`<line x1="9" y1="17" x2="13" y2="17"/>` +
`</svg>`,
case:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M14 13l-7 7-3-3 7-7"/>` +
`<path d="M11.5 7.5l5 5"/>` +
`<path d="M16 3l5 5-3 3-5-5z"/>` +
`<path d="M5 21h6"/>` +
`</svg>`,
project:
`<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>` +
`</svg>`,
};
const BREADCRUMB_CHEVRON =
`<svg class="projekt-breadcrumb-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">` +
`<polyline points="9 18 15 12 9 6"/>` +
`</svg>`;
function typeIcon(type: string): string {
return TYPE_ICONS[type] || TYPE_ICONS.project;
}
function renderBreadcrumb() {
if (!project) return;
const el = document.getElementById("project-breadcrumb");
if (!el) return;
// Hide the breadcrumb when there's no chain — at the root of a tree the
// single crumb just echoes the H1 directly below it (F-27).
if (ancestors.length === 0) {
el.innerHTML = "";
el.style.display = "none";
return;
}
el.style.display = "";
const crumbs: string[] = ancestors.map((a) => {
const label = tDyn(`projects.type.${a.type}`) || a.type;
return (
`<a href="/projects/${esc(a.id)}" class="projekt-crumb projekt-crumb-link projekt-crumb-${esc(a.type)}" title="${escAttr(label)}: ${escAttr(a.title)}">` +
`<span class="projekt-crumb-icon">${typeIcon(a.type)}</span>` +
`<span class="projekt-crumb-title">${esc(a.title)}</span>` +
`</a>`
);
});
const currentLabel = tDyn(`projects.type.${project.type}`) || project.type;
crumbs.push(
`<span class="projekt-crumb projekt-crumb-current projekt-crumb-${esc(project.type)}" title="${escAttr(currentLabel)}: ${escAttr(project.title)}">` +
`<span class="projekt-crumb-icon">${typeIcon(project.type)}</span>` +
`<span class="projekt-crumb-title">${esc(project.title)}</span>` +
`</span>`,
);
el.innerHTML = crumbs.join(BREADCRUMB_CHEVRON);
}
// ----- Project Tree (Projektbaum) -----------------------------------------
// Renders the full visible project hierarchy with the current node highlighted.
// One round-trip to /api/projects/tree gets every project the user can see;
// the renderer walks the tree and produces a nested <ul> with the current
// node visually marked. Direct children of the current node still drive the
// "no sub-projects" empty state, since that is the actionable signal for
// the "+ Untervorhaben anlegen" CTA.
async function loadChildren(id: string) {
// Direct children kept for the "Keine untergeordneten Projekte" empty state
// and the create-new pre-fill (parent_id from the current node).
try {
const resp = await fetch(`/api/projects/${id}/children`);
if (resp.ok) children = ((await resp.json()) as ProjectMini[]) ?? [];
} catch {
children = [];
}
}
// renderChildren is the Projektbaum tab's mount point. m's 2026-05-08
// 21:28: "should just be the same as the Tree in Projects. It has
// symbols, everything." Reuse the /projects tree component
// (project-tree.ts) verbatim — type icons, pin stars, deadline badges,
// expand/collapse, search highlighting all come along for free. The
// current project is highlighted via a CSS modifier we add to its
// data-id row after the tree mounts.
function renderChildren() {
const root = document.getElementById("project-tree")!;
const empty = document.getElementById("children-empty")!;
// Empty state only when the current node has zero direct children — the
// CTA next to the empty message is "create sub-project", which would be
// misleading if the tree itself has other branches.
empty.style.display = children.length ? "none" : "";
// Mount the shared tree. initProjectTree fetches /api/projects/tree on
// first call and caches; subsequent tab-switches re-render from cache.
// Set aria-current on the row matching this project — the shared tree
// already styles aria-current=true with a lime highlight (global.css
// .projekt-tree-node[aria-current="true"] > .projekt-tree-row).
void initProjectTree(root).then(() => {
const currentId = project?.id ?? "";
if (!currentId) return;
root.querySelectorAll<HTMLLIElement>(".projekt-tree-node").forEach((li) => {
if (li.dataset.id === currentId) {
li.setAttribute("aria-current", "true");
} else {
li.removeAttribute("aria-current");
}
});
});
}
function initChildAddLink() {
const link = document.getElementById("child-add-link") as HTMLAnchorElement | null;
if (!link || !project) return;
// Pre-fill parent_id for the create form via query param.
link.href = `/projects/new?parent=${encodeURIComponent(project.id)}`;
}
// ----- Team tab -----------------------------------------------------------
async function loadTeam(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/team`);
if (resp.ok) teamMembers = ((await resp.json()) as ProjectTeamMember[]) ?? [];
} catch {
teamMembers = [];
}
}
// t-paliad-139 — Team-tab subsection loaders. All three are independent so
// main() runs them in parallel.
async function loadDescendantStaffed(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/team/from-descendants`);
if (resp.ok) {
descendantStaffed = ((await resp.json()) as ProjectTeamMember[]) ?? [];
} else {
descendantStaffed = [];
}
} catch {
descendantStaffed = [];
}
}
async function loadDerivedMembers(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/team/derived`);
if (resp.ok) {
derivedMembers = ((await resp.json()) as DerivedMember[]) ?? [];
} else {
derivedMembers = [];
}
} catch {
derivedMembers = [];
}
}
async function loadAttachedUnits(id: string) {
try {
const resp = await fetch(`/api/projects/${id}/partner-units`);
if (resp.ok) {
attachedUnits = ((await resp.json()) as AttachedUnit[]) ?? [];
} else {
attachedUnits = [];
}
} catch {
attachedUnits = [];
}
}
async function loadAllUnits() {
try {
const resp = await fetch(`/api/partner-units`);
if (resp.ok) {
const all = (await resp.json()) as { id: string; name: string; office: string }[];
allUnits = all ?? [];
}
} catch {
allUnits = [];
}
}
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")!;
const mailtoControls = document.getElementById("team-mailto-controls") as HTMLDivElement | null;
// Existing team-body shows the direct + ancestor-inherited members
// returned by /api/projects/{id}/team. The derived + descendant
// sections render into separate tbodies (added in TSX). Empty state
// applies to the union — only show when EVERY section is empty.
const totalRows =
teamMembers.length + descendantStaffed.length + derivedMembers.length;
if (totalRows === 0) {
body.innerHTML = "";
empty.style.display = "";
if (mailtoControls) mailtoControls.style.display = "none";
selectedMailUserIDs.clear();
syncMailtoButton();
syncMasterCheckbox();
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
return;
}
empty.style.display = "none";
if (mailtoControls) mailtoControls.style.display = teamMembers.length > 0 ? "" : "none";
// Prune the selection to whoever is actually rendered in team-body
// right now (e.g. a member just got removed). Invariant: selection ⊆
// currently-visible team-body rows.
pruneMailSelectionToVisible();
// t-paliad-223: callers with effective_project_admin authority see an
// inline <select> on the Rolle cell. Everyone else sees the read-only
// <span>. The bool comes from the GET /api/projects/{id} payload.
const canEditResponsibility = !!project?.effective_admin;
body.innerHTML = teamMembers
.map((m) => {
// t-paliad-148: profession is firm-wide (read-only badge) and
// responsibility is per-project. Both are surfaced; the legacy
// .role field is still set by the server during the deprecation
// window but the UI ignores it.
const responsibility = m.responsibility || "member";
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
const professionLabel = m.user_profession
? tDyn(`projects.team.profession.${m.user_profession}`) || m.user_profession
: (t("projects.team.profession.none") || "(extern)");
const professionTitle = m.user_profession
? (t("projects.team.profession.hint") || "Profession — gesetzt im Firmenprofil")
: (t("projects.team.profession.none.hint") || "Keine Profession gesetzt — keine 4-Augen-Befugnis");
const source = m.inherited
? `<span class="projekt-team-inherited" title="${escAttr(t("projects.team.inherited.hint") || "Inherited from ancestor")}">
&uarr; ${esc(m.inherited_from_title || "")}
</span>`
: `<span class="projekt-team-direct">${esc(t("projects.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("projects.detail.team.remove") || "Entfernen")}</button>`
: "";
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const profCls = m.user_profession ? "projekt-team-profession" : "projekt-team-profession projekt-team-profession--none";
// Inline-select only on direct rows where the caller can edit.
// Inherited rows stay read-only — the edit must happen at the
// ancestor where the row is direct.
const responsibilityCell =
canEditResponsibility && !m.inherited
? renderResponsibilitySelect(m.user_id, responsibility)
: `<span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span>`;
// t-paliad-231: per-row checkbox feeding selectedMailUserIDs. Only
// rows with a real email participate in the mailto: build; rows
// without are still rendered with a disabled checkbox so the
// column geometry stays uniform.
const hasEmail = !!(m.user_email && m.user_email.trim());
const checked = hasEmail && selectedMailUserIDs.has(m.user_id) ? " checked" : "";
const disabled = hasEmail ? "" : " disabled";
const checkboxCell = `<td class="team-col-select"><input type="checkbox" class="team-mail-select" data-user-id="${esc(m.user_id)}" data-email="${escAttr(m.user_email || "")}" aria-label="${escAttr(t("projects.team.mailto.select_row") || "Mitglied auswählen")}"${checked}${disabled} /></td>`;
return `<tr>
${checkboxCell}
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
<td>${responsibilityCell}</td>
<td>${source}</td>
<td>${removeBtn}</td>
</tr>`;
})
.join("");
// t-paliad-231 — wire row checkboxes + master + mailto button.
body.querySelectorAll<HTMLInputElement>(".team-mail-select").forEach((cb) => {
cb.addEventListener("change", () => {
const userID = cb.dataset.userId!;
if (cb.checked) selectedMailUserIDs.add(userID);
else selectedMailUserIDs.delete(userID);
syncMailtoButton();
syncMasterCheckbox();
});
});
wireMailtoMaster();
wireMailtoButton();
syncMailtoButton();
syncMasterCheckbox();
body.querySelectorAll<HTMLButtonElement>(".team-remove-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!project) return;
const userID = btn.dataset.userId!;
if (!window.confirm(t("projects.detail.team.confirm_remove") || "Mitglied entfernen?")) return;
const resp = await fetch(
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
{ method: "DELETE" },
);
if (resp.ok) {
await loadTeam(project.id);
renderTeam();
} else {
await showTeamErrorToast(resp);
}
});
});
body.querySelectorAll<HTMLSelectElement>(".team-responsibility-select").forEach((sel) => {
// Capture the pre-change value on focus so we can roll back the
// <select> if the PATCH fails (e.g. last-admin guard).
sel.dataset.previous = sel.value;
sel.addEventListener("focus", () => {
sel.dataset.previous = sel.value;
});
sel.addEventListener("change", async () => {
if (!project) return;
const userID = sel.dataset.userId!;
const previous = sel.dataset.previous || "member";
const next = sel.value;
if (next === previous) return;
sel.disabled = true;
try {
const resp = await fetch(
`/api/projects/${project.id}/team/${encodeURIComponent(userID)}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ responsibility: next }),
},
);
if (!resp.ok) {
sel.value = previous;
await showTeamErrorToast(resp);
return;
}
sel.dataset.previous = next;
// Refresh the team list so derived/descendant sections re-render
// with the new authority shape.
await loadTeam(project.id);
renderTeam();
} finally {
sel.disabled = false;
}
});
});
renderDescendantStaffed();
renderDerivedMembers();
renderAttachedUnits();
}
// t-paliad-139 — "Aus Unterprojekten" subsection.
function renderDescendantStaffed() {
const section = document.getElementById("team-section-descendants");
const body = document.getElementById("team-descendants-body");
if (!section || !body) return;
if (descendantStaffed.length === 0) {
section.style.display = "none";
body.innerHTML = "";
return;
}
section.style.display = "";
body.innerHTML = descendantStaffed
.map((m) => {
const responsibility = m.responsibility || "member";
const responsibilityLabel = tDyn(`projects.team.responsibility.${responsibility}`) || responsibility;
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const sourceTitle = esc(m.inherited_from_title || "");
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
<td><span class="projekt-team-inherited" title="${escAttr(t("aggregation.attribution.on") || "auf")}: ${sourceTitle}">&darr; ${sourceTitle}</span></td>
</tr>`;
})
.join("");
}
// t-paliad-139 — "Abgeleitet (Partner Unit)" subsection.
function renderDerivedMembers() {
const section = document.getElementById("team-section-derived");
const body = document.getElementById("team-derived-body");
if (!section || !body) return;
if (derivedMembers.length === 0) {
section.style.display = "none";
body.innerHTML = "";
return;
}
section.style.display = "";
body.innerHTML = derivedMembers
.map((m) => {
const memberships = m.memberships || [];
// Role column shows distinct unit_role values (usually one — only
// diverges if the user has different roles in different units).
const distinctRoles = Array.from(new Set(memberships.map((x) => x.unit_role)));
const roleLabel = distinctRoles
.map((r) => tDyn(`unit_role.${r}`) || r)
.join(", ");
// Herkunft column lists every (unit, role) pair so multi-unit users
// surface all their sources, not just the closest one (t-paliad-143).
// Multi-unit: bold each unit name and append the role in parentheses.
// Single-unit: bold the one unit name (matches the legacy rendering).
const sourceLabel = memberships
.map((x) => {
const name = `<strong>${esc(x.unit_name)}</strong>`;
if (memberships.length === 1) return name;
const role = esc(tDyn(`unit_role.${x.unit_role}`) || x.unit_role);
return `${name} (${role})`;
})
.join(", ");
const officeLabel = m.user_office ? tDyn("office." + m.user_office) || m.user_office : "";
const authBadge = m.derive_grants_authority
? `<span class="derived-badge derived-badge--authority" title="${escAttr(t("projects.team.derived.authority.hint") || "Authority granted")}">${esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")}</span>`
: `<span class="derived-badge">${esc(t("projects.team.derived.visibility") || "Sicht")}</span>`;
return `<tr>
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
<span class="form-hint">&middot; ${esc(m.user_email)}${officeLabel ? " &middot; " + esc(officeLabel) : ""}</span></td>
<td><span class="projekt-team-role">${esc(roleLabel)}</span></td>
<td>${esc(t("projects.team.derived.from") || "über")}: ${sourceLabel} ${authBadge}</td>
</tr>`;
})
.join("");
}
// t-paliad-139 — Partner Units management section. Lists attached units
// with detach buttons; admin/lead can add new attachments.
function renderAttachedUnits() {
const section = document.getElementById("team-section-units");
const body = document.getElementById("team-units-body");
if (!section || !body) return;
const canManage = canManagePartnerUnits();
// Always show the section to admins/leads (even if empty so they can attach).
if (!canManage && attachedUnits.length === 0) {
section.style.display = "none";
return;
}
section.style.display = "";
if (attachedUnits.length === 0) {
body.innerHTML = `<tr><td colspan="4" class="form-hint">${esc(t("projects.team.units.empty") || "Keine Partner Units zugeordnet.")}</td></tr>`;
return;
}
body.innerHTML = attachedUnits
.map((u) => {
const roles = (u.derive_unit_roles || []).map((r) => tDyn(`unit_role.${r}`) || r).join(", ");
const auth = u.derive_grants_authority
? esc(t("projects.team.derived.authority") || "Sicht & 4-Augen")
: esc(t("projects.team.derived.visibility") || "Sicht");
const detachBtn = canManage
? `<button type="button" class="btn-ghost btn-small unit-detach-btn" data-unit-id="${esc(u.partner_unit_id)}">${esc(t("projects.team.units.detach") || "Entfernen")}</button>`
: "";
return `<tr>
<td><strong>${esc(u.unit_name)}</strong></td>
<td>${esc(roles)}</td>
<td>${auth}</td>
<td>${u.derived_member_count} ${esc(t("projects.team.units.members") || "Mitglieder")} ${detachBtn}</td>
</tr>`;
})
.join("");
body.querySelectorAll<HTMLButtonElement>(".unit-detach-btn").forEach((btn) => {
btn.addEventListener("click", async () => {
if (!project) return;
const unitID = btn.dataset.unitId!;
if (!window.confirm(t("projects.team.units.confirm_detach") || "Partner Unit entfernen?")) return;
const resp = await fetch(
`/api/projects/${project.id}/partner-units/${encodeURIComponent(unitID)}`,
{ method: "DELETE" },
);
if (resp.ok) {
await Promise.all([loadAttachedUnits(project.id), loadDerivedMembers(project.id)]);
renderTeam();
}
});
});
}
// canManagePartnerUnits returns true for global_admin or this project's
// lead. Mirrors the migration-055 RLS write policy.
function canManagePartnerUnits(): boolean {
if (!me) return false;
if (me.global_role === "global_admin") return true;
if (!project) return false;
return teamMembers.some(
(m) => m.user_id === me!.id && m.responsibility === "lead" && m.project_id === project!.id,
);
}
// canExportProject mirrors the §4 server-side gate for /api/projects/{id}/export:
// global_admin OR direct team responsibility ∈ {lead, member}. Used to
// reveal the export button — server still re-enforces on the request.
function canExportProject(): boolean {
if (!me || !project) return false;
if (me.global_role === "global_admin") return true;
return teamMembers.some(
(m) =>
m.user_id === me!.id &&
m.project_id === project!.id &&
(m.responsibility === "lead" || m.responsibility === "member"),
);
}
// wireExportButton reveals the Export sub-section of the Verwaltung tab
// (t-paliad-245) and hooks up the project-export button. Triggers a
// download via a transient <a download> — same pattern as the personal
// export in client/settings.ts.
function wireExportButton(projectID: string): void {
const section = document.getElementById("project-settings-export") as HTMLElement | null;
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
if (!section || !btn) return;
if (!canExportProject()) {
section.style.display = "none";
updateSettingsTabVisibility();
return;
}
section.style.display = "";
updateSettingsTabVisibility();
btn.addEventListener("click", () => {
const a = document.createElement("a");
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
a.download = "";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
});
}
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
// sub-sections are visible to the current user — an empty tab is worse
// UX than no tab. Called whenever a sub-section's visibility flips.
function updateSettingsTabVisibility(): void {
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
if (!tab) return;
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
tab.style.display = exportShown || archiveShown ? "" : "none";
}
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
if (!me) return false;
if (m.user_id === me.id) return true;
if (me.global_role === "global_admin") return true;
// t-paliad-223: effective_project_admin (from the project payload)
// also covers remove. RLS makes the request fail anyway if the bit is
// stale; this just hides the affordance.
return !!project?.effective_admin;
}
// t-paliad-223: build the inline <select> for the responsibility cell.
// Options mirror the IsValidResponsibility set in approval_levels.go.
function renderResponsibilitySelect(userID: string, current: string): string {
const options = ["admin", "lead", "member", "observer", "external"]
.map((v) => {
const label = tDyn(`projects.team.responsibility.${v}`) || v;
const sel = v === current ? " selected" : "";
return `<option value="${esc(v)}"${sel}>${esc(label)}</option>`;
})
.join("");
return `<select class="team-responsibility-select projekt-team-responsibility" data-user-id="${esc(userID)}">${options}</select>`;
}
// t-paliad-223: surface backend error responses (last-admin guard / 403
// from RLS / etc.) as a transient toast. We have no global toast service
// yet on this page, so write into #team-msg.
async function showTeamErrorToast(resp: Response): Promise<void> {
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
if (!msg) return;
let text = "";
try {
const data = (await resp.json()) as { error?: string };
text = data?.error || "";
} catch {
text = "";
}
if (!text) {
if (resp.status === 409) text = t("projects.team.error.last_admin") || "Mindestens ein Admin muss auf diesem Projekt oder einem übergeordneten verbleiben.";
else if (resp.status === 403 || resp.status === 404) text = t("projects.team.error.forbidden") || "Diese Aktion ist nicht erlaubt.";
else text = t("projects.team.error.generic") || "Aktion fehlgeschlagen.";
}
msg.textContent = text;
msg.classList.add("form-msg--error");
// Auto-clear after 5s so a stale error doesn't linger past the next
// successful action.
window.setTimeout(() => {
if (msg.textContent === text) {
msg.textContent = "";
msg.classList.remove("form-msg--error");
}
}, 5000);
}
// t-paliad-231 — mailto: selection helpers for the Team tab. The
// admin-only server SMTP broadcast (POST /api/team/broadcast) lives
// elsewhere; this is the non-admin / quick-CC variant that opens the
// user's local mail client. Pure client; no server call.
function pruneMailSelectionToVisible(): void {
const visible = new Set<string>();
for (const m of teamMembers) {
if (m.user_email && m.user_email.trim()) visible.add(m.user_id);
}
for (const id of Array.from(selectedMailUserIDs)) {
if (!visible.has(id)) selectedMailUserIDs.delete(id);
}
}
function selectedMailRecipients(): BroadcastRecipient[] {
const out: BroadcastRecipient[] = [];
for (const m of teamMembers) {
if (!selectedMailUserIDs.has(m.user_id)) continue;
if (!m.user_email || !m.user_email.trim()) continue;
out.push({
user_id: m.user_id,
email: m.user_email,
display_name: m.user_display_name || m.user_email,
first_name: (m.user_display_name || m.user_email).trim().split(/\s+/)[0] ?? "",
role_on_project: m.responsibility || "member",
});
}
return out;
}
function syncMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
const label = document.getElementById("team-mailto-label") as HTMLSpanElement | null;
if (!btn || !label) return;
const n = selectedMailRecipients().length;
const baseLabel = t("projects.team.mailto.label") || "Mail an Auswahl";
if (n === 0) {
btn.disabled = true;
label.textContent = baseLabel;
btn.title = t("projects.team.mailto.empty") || "Mindestens ein Mitglied auswählen";
} else {
btn.disabled = false;
label.textContent = `${baseLabel} (${n})`;
const tooltip = (t("projects.team.mailto.count") || "{n} ausgewählt").replace("{n}", String(n));
btn.title = tooltip;
}
}
function syncMasterCheckbox(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master) return;
// Only count rows that actually rendered with an enabled checkbox —
// members without an email don't participate.
const checkboxes = document.querySelectorAll<HTMLInputElement>(
"#team-body .team-mail-select:not(:disabled)",
);
const total = checkboxes.length;
let selected = 0;
checkboxes.forEach((cb) => {
if (selectedMailUserIDs.has(cb.dataset.userId!)) selected++;
});
master.disabled = total === 0;
if (total === 0 || selected === 0) {
master.checked = false;
master.indeterminate = false;
} else if (selected === total) {
master.checked = true;
master.indeterminate = false;
} else {
master.checked = false;
master.indeterminate = true;
}
}
// wireMailtoMaster is idempotent — registers once via a sentinel data
// attr so re-renders don't stack click handlers.
function wireMailtoMaster(): void {
const master = document.getElementById("team-select-master") as HTMLInputElement | null;
if (!master || master.dataset.wired === "1") return;
master.dataset.wired = "1";
master.addEventListener("change", () => {
const turnOn = master.checked;
document
.querySelectorAll<HTMLInputElement>("#team-body .team-mail-select:not(:disabled)")
.forEach((cb) => {
const id = cb.dataset.userId!;
if (turnOn) selectedMailUserIDs.add(id);
else selectedMailUserIDs.delete(id);
cb.checked = turnOn;
});
syncMailtoButton();
syncMasterCheckbox();
});
}
function wireMailtoButton(): void {
const btn = document.getElementById("team-mailto-btn") as HTMLButtonElement | null;
if (!btn || btn.dataset.wired === "1") return;
btn.dataset.wired = "1";
btn.addEventListener("click", (e) => {
e.preventDefault();
const recipients = selectedMailRecipients();
if (recipients.length === 0) return;
window.location.href = buildMailtoHref(recipients);
});
}
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 responsibility = document.getElementById("team-responsibility") as HTMLSelectElement | null;
const professionHint = document.getElementById("team-profession-hint") as HTMLParagraphElement | null;
const inviteHint = document.getElementById("team-user-invite-hint") as HTMLDivElement | null;
const inviteHintText = document.getElementById("team-user-invite-hint-text") as HTMLSpanElement | null;
const inviteBtn = document.getElementById("team-user-invite-btn") as HTMLButtonElement | null;
if (!addBtn || !form || !cancel || !input || !hidden || !sugs || !msg || !responsibility) return;
const hideInviteHint = () => {
if (inviteHint) inviteHint.style.display = "none";
};
const showInviteHint = (q: string) => {
if (!inviteHint || !inviteHintText) return;
const looksLikeEmail = /@/.test(q) && /\./.test(q.split("@")[1] || "");
inviteHintText.textContent = looksLikeEmail
? t("projects.detail.team.invite.hint_email") || "Niemand mit dieser E-Mail."
: t("projects.detail.team.invite.hint") || "Benutzer nicht gefunden?";
inviteHint.dataset.email = looksLikeEmail ? q : "";
inviteHint.style.display = "";
};
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 = "";
hideInviteHint();
msg.textContent = "";
});
input.addEventListener("input", () => {
const q = input.value.trim();
const lc = q.toLowerCase();
hidden.value = "";
if (!q) {
sugs.innerHTML = "";
hideInviteHint();
return;
}
const matches = userOptions
.filter((u) => (u.display_name + " " + u.email).toLowerCase().includes(lc))
.slice(0, 8);
sugs.innerHTML = matches
.map(
(u) => `<div class="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>(".collab-suggestion").forEach((el) => {
el.addEventListener("click", () => {
hidden.value = el.dataset.id!;
input.value = el.dataset.label!;
sugs.innerHTML = "";
hideInviteHint();
// t-paliad-148: surface the picked person's profession so the
// adder sees what firm tier they're staffing on this matter,
// and gets a warning when the user has no profession set.
if (professionHint) {
const picked = userOptions.find((u) => u.id === hidden.value);
const prof = picked?.profession;
if (!prof) {
professionHint.textContent = t("projects.detail.team.form.profession.none") ||
"Keine Profession gesetzt — kann keine 4-Augen-Genehmigungen erteilen.";
professionHint.className = "form-hint form-hint--warning";
professionHint.style.display = "";
} else {
const profLabel = tDyn(`projects.team.profession.${prof}`) || prof;
professionHint.textContent = `${t("projects.detail.team.form.profession.label") || "Profession"}: ${profLabel}`;
professionHint.className = "form-hint";
professionHint.style.display = "";
}
}
});
});
if (matches.length === 0) {
showInviteHint(q);
} else {
hideInviteHint();
}
});
inviteBtn?.addEventListener("click", () => {
const sidebarBtn = document.getElementById("sidebar-invite-btn") as HTMLButtonElement | null;
if (!sidebarBtn) return;
sidebarBtn.click();
const prefill = inviteHint?.dataset.email || "";
if (prefill) {
const inviteEmail = document.getElementById("invite-email") as HTMLInputElement | null;
if (inviteEmail) {
inviteEmail.value = prefill;
inviteEmail.dispatchEvent(new Event("input", { bubbles: true }));
}
}
});
form.addEventListener("submit", async (e) => {
e.preventDefault();
msg.textContent = "";
if (!hidden.value) {
msg.textContent = t("projects.detail.team.error.user_required") || "Benutzer auswählen";
return;
}
const resp = await fetch(`/api/projects/${id}/team`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ user_id: hidden.value, responsibility: responsibility.value }),
});
if (!resp.ok) {
const b = await resp.json().catch(() => ({ error: "unknown" }));
msg.textContent = b.error || "failed";
return;
}
input.value = "";
hidden.value = "";
sugs.innerHTML = "";
hideInviteHint();
form.style.display = "none";
addBtn.style.display = "";
await loadTeam(id);
renderTeam();
});
}
// initNotesContainer hooks the shared Notes module into the project 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.
let notesInited = false;
function initNotesContainer(projectID: string) {
if (notesInited) return;
const container = document.getElementById("notes-container");
if (!container) return;
container.setAttribute("data-parent-id", projectID);
void initNotes(container as HTMLElement, "project", projectID);
notesInited = true;
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
onLangChange(() => {
renderHeader();
renderBreadcrumb();
renderTimeline();
renderParties();
renderDeadlines();
renderAppointments();
renderChildren();
renderTeam();
});
main();
});