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