Phase 3 Slice 5 frontend. loadProceedingTypes() in projects-detail.ts now fetches /api/proceeding-types-db?category=fristenrechner so the project edit picker only ever shows the 19 fristenrechner codes, never the 7 legacy litigation codes (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The Fristenrechner calculator page + Verfahrensablauf page are NOT touched — they still need the full proceeding_types catalog (the litigation codes have rule trees the calculator can render, per design §3.F: "litigation codes stay … reachable via cascade leaves"). Only the project-binding picker is restricted. Defence-in-depth: even if a future fetch bypasses this filter, the server-side service guard (ErrInvalidProceedingTypeCategory) and the mig 088 DB trigger both reject the write. The picker filter is the UX layer of the chain — invisible bad-shape inputs. projects-new.ts has no proceeding-type field today (the form lives on the edit page only); no change needed there.
2856 lines
106 KiB
TypeScript
2856 lines
106 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,
|
|
} 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";
|
|
|
|
interface Project {
|
|
id: string;
|
|
type: string;
|
|
parent_id?: string | null;
|
|
path: string;
|
|
title: string;
|
|
reference?: 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;
|
|
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";
|
|
|
|
const VALID_TABS: TabId[] = [
|
|
"history",
|
|
"team",
|
|
"children",
|
|
"parties",
|
|
"deadlines",
|
|
"appointments",
|
|
"notes",
|
|
"checklists",
|
|
];
|
|
|
|
// 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;
|
|
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 }[] = [];
|
|
|
|
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, "&").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-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
|
|
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
|
if (me && (me.global_role === "global_admin")) {
|
|
deleteWrap.style.display = "";
|
|
} else {
|
|
deleteWrap.style.display = "none";
|
|
}
|
|
}
|
|
|
|
// 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);
|
|
}
|
|
|
|
interface ProceedingTypeRow {
|
|
id: number;
|
|
code: string;
|
|
name: string;
|
|
name_en: string;
|
|
jurisdiction?: string;
|
|
is_active: boolean;
|
|
}
|
|
|
|
let proceedingTypesCache: ProceedingTypeRow[] | null = null;
|
|
|
|
// loadProceedingTypes fetches active proceeding types for the project
|
|
// picker. Phase 3 Slice 5 (t-paliad-186) restricts project-binding to
|
|
// fristenrechner-category codes (design §3.F + m's Q2 ruling), so the
|
|
// picker only ever shows those — never the 7 legacy litigation codes
|
|
// (INF / REV / CCR / APM / APP / AMD / ZPO_CIVIL). The matching
|
|
// server-side service validation + DB trigger (mig 088) are the
|
|
// defence-in-depth backstops for any non-UI writer.
|
|
async function loadProceedingTypes(): Promise<ProceedingTypeRow[]> {
|
|
if (proceedingTypesCache) return proceedingTypesCache;
|
|
try {
|
|
const resp = await fetch("/api/proceeding-types-db?category=fristenrechner");
|
|
if (!resp.ok) return [];
|
|
const rows = ((await resp.json()) ?? []) as ProceedingTypeRow[];
|
|
proceedingTypesCache = rows.filter((r) => r.is_active);
|
|
return proceedingTypesCache;
|
|
} catch {
|
|
return [];
|
|
}
|
|
}
|
|
|
|
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.
|
|
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") 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);
|
|
}
|
|
}
|
|
|
|
let checklistInstancesInited = false;
|
|
async function loadAndRenderChecklistInstances(projectID: string) {
|
|
if (checklistInstancesInited) return;
|
|
checklistInstancesInited = true;
|
|
try {
|
|
const [instResp, tplResp] = await Promise.all([
|
|
fetch(`/api/projects/${projectID}/checklists`),
|
|
fetch(`/api/checklists`),
|
|
]);
|
|
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
|
|
const templates = tplResp.ok ? (((await tplResp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
|
checklistTemplates = {};
|
|
for (const tpl of templates) checklistTemplates[tpl.slug] = tpl;
|
|
} 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}`;
|
|
});
|
|
});
|
|
}
|
|
|
|
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();
|
|
}
|
|
|
|
function openEditModal() {
|
|
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();
|
|
});
|
|
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();
|
|
});
|
|
|
|
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);
|
|
initNotesContainer(id);
|
|
mountVerlaufFilterBar(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")!;
|
|
|
|
// 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 = "";
|
|
renderDescendantStaffed();
|
|
renderDerivedMembers();
|
|
renderAttachedUnits();
|
|
return;
|
|
}
|
|
empty.style.display = "none";
|
|
|
|
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")}">
|
|
↑ ${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";
|
|
return `<tr>
|
|
<td><strong>${esc(m.user_display_name || m.user_email)}</strong>
|
|
<span class="form-hint">· ${esc(m.user_email)}${officeLabel ? " · " + esc(officeLabel) : ""}</span></td>
|
|
<td><span class="${profCls}" title="${escAttr(professionTitle)}">${esc(professionLabel)}</span></td>
|
|
<td><span class="projekt-team-responsibility">${esc(responsibilityLabel)}</span></td>
|
|
<td>${source}</td>
|
|
<td>${removeBtn}</td>
|
|
</tr>`;
|
|
})
|
|
.join("");
|
|
|
|
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();
|
|
}
|
|
});
|
|
});
|
|
|
|
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">· ${esc(m.user_email)}${officeLabel ? " · " + 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}">↓ ${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">· ${esc(m.user_email)}${officeLabel ? " · " + 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,
|
|
);
|
|
}
|
|
|
|
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
|
if (!me) return false;
|
|
if (m.user_id === me.id) return true;
|
|
return me.global_role === "global_admin";
|
|
}
|
|
|
|
function initTeamForm(id: string) {
|
|
const addBtn = document.getElementById("team-add-btn") as HTMLButtonElement | null;
|
|
const form = document.getElementById("team-form") as HTMLFormElement | null;
|
|
const cancel = document.getElementById("team-cancel") as HTMLButtonElement | null;
|
|
const input = document.getElementById("team-user-input") as HTMLInputElement | null;
|
|
const hidden = document.getElementById("team-user-id") as HTMLInputElement | null;
|
|
const sugs = document.getElementById("team-user-suggestions") as HTMLDivElement | null;
|
|
const msg = document.getElementById("team-msg") as HTMLParagraphElement | null;
|
|
const 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();
|
|
});
|