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"; import { formatRuleLabelHTML, formatCustomRuleLabelHTML } from "./rule-label"; 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 ${fmtDateOnly(f.due_date)} ${esc(f.title)}${attributionChip(f.project_id, f.project_title)} ${formatDeadlineRuleCell(f)} ${esc(statusLabel)} `; }) .join(""); tbody.querySelectorAll(".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(".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 ` ${esc(label)}: ${esc(rowProjectTitle)}`; } function esc(s: string): string { const d = document.createElement("div"); d.textContent = s; return d.innerHTML; } function escAttr(s: string): string { return s.replace(/&/g, "&").replace(/"/g, """); } function 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 `${escapedTitle}`; } 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; 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(".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 = { 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("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("button[type=submit]")!; submitBtn.disabled = true; if (msg) { msg.textContent = t("projects.detail.smarttimeline.counterclaim.saving"); msg.className = "form-msg"; } const payload: Record = {}; 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("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 ` ${esc(p.name)} ${esc(roleLabel)} ${esc(p.representative || "")} `; }) .join(""); tbody.querySelectorAll(".party-remove").forEach((btn) => { btn.textContent = t("projects.detail.parteien.remove"); btn.addEventListener("click", async () => { const row = btn.closest("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(".entity-tab").forEach((el) => { el.classList.toggle("active", el.dataset.tab === tab); }); document.querySelectorAll(".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 { 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 ` ${escapeHtml(tplName)} ${escapeHtml(inst.name)}
${done} / ${total}
${escapeHtml(fmtDate(inst.created_at))} `; }).join(""); body.querySelectorAll(".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 ? `${escapeHtml(regime)}` : ""; const descLine = desc ? `

${escapeHtml(desc)}

` : ""; return ``; }).join(""); list.querySelectorAll(".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(".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(".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(".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(".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); // 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 = { 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; 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("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("button[type=submit]")!; submitBtn.disabled = true; const payload: Record = { 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 = ``; const opts = allUnits .filter((u) => !attachedIDs.has(u.id)) .map((u) => ``) .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(".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 = { client: ``, litigation: ``, patent: ``, case: ``, project: ``, }; const BREADCRUMB_CHEVRON = ``; 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 ( `` + `${typeIcon(a.type)}` + `${esc(a.title)}` + `` ); }); const currentLabel = tDyn(`projects.type.${project.type}`) || project.type; crumbs.push( `` + `${typeIcon(project.type)}` + `${esc(project.title)}` + ``, ); 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
    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(".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 `; return ` ${checkboxCell} ${esc(m.user_display_name || m.user_email)} · ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""} ${esc(professionLabel)} ${responsibilityCell} ${source} ${removeBtn} `; }) .join(""); // t-paliad-231 — wire row checkboxes + master + mailto button. body.querySelectorAll(".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(".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(".team-responsibility-select").forEach((sel) => { // Capture the pre-change value on focus so we can roll back the // 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 ``; }) .join(""); return ``; } // 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 { 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(); 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( "#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("#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) => `
    ${esc(u.display_name || u.email)} ${esc(u.email)}
    `, ) .join(""); sugs.querySelectorAll(".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(); });