Per the design at docs/design-submission-generator-v2-2026-05-26.md §8
and the Q2 / Q9 ratifications:
- Q2 (m, 2026-05-26): building blocks are plain text paste sources.
No building_block_id reference is stored on submission_sections.
- Q9 (m, 2026-05-26): four visibility tiers — private / team / firm
/ global.
Schema (mig 149):
- paliad.submission_building_blocks — library catalog. Columns: slug,
firm (NULL = cross-firm), section_key (binds to one section kind),
proceeding_family (NULL = any), title_de/_en + description_de/_en
+ content_md_de/_en, author_id, visibility (CHECK in 4-tier set),
is_published, created_at, updated_at, deleted_at (soft delete).
RLS: coarse-grained SELECT — every authenticated user sees
non-deleted non-private rows + own private rows. Tier-specific
predicate (private/team/firm/global) applied in Go-layer service so
semantics evolve without RLS migrations. Mutations admin-only (no
RLS write paths).
- paliad.submission_building_block_admin_versions — append-only
history per block, retention=20. Admin-side only; NOT referenced
from submission_sections (per Q2's plain-text-paste model). Exists
so accidental delete / overwrite are recoverable.
Backend:
- internal/services/submission_building_block_service.go (~510 LoC):
BuildingBlockService. ListVisible applies tier predicate at query
time (private = author_id match; firm = firm column NULL OR matches
branding.Name; team = author shares a project_team with caller via
paliad.project_teams self-join; global = open). ListAllForAdmin
drops the predicate. Create + Update + SoftDelete + RestoreVersion
all transactional; appendVersionTx writes one audit row +
GC-deletes anything past the retention=20 horizon in the same tx.
InsertIntoSection (the paste mechanic) clones content_md_<lang>
into the section row with a "\n\n" separator if section already has
content. NO building_block_id stamped per Q2.
- internal/handlers/submission_building_blocks.go (~480 LoC): nine
handlers split between the lawyer-facing picker (list, insert) and
the admin editor (list, get, create, update, delete, list-versions,
restore-version, page). buildingBlockUpdateInput uses presence-
tracking UnmarshalJSON for the four nullable fields (firm,
proceeding_family, description_de/_en) so PATCH can distinguish
"no change" from "set to null".
- Routes registered: lawyer-facing under /api/submission-building-blocks,
admin-gated under /api/admin/submission-building-blocks/* and
/admin/submission-building-blocks (page).
- Wiring: handlers.Services + dbServices + cmd/server/main.go all
gain SubmissionBuildingBlock. NewBuildingBlockService takes the
branding.Name firm hint for the visibility predicate.
Frontend:
- frontend/src/admin-submission-building-blocks.tsx (~85 LoC):
three-pane admin shell (list / editor / version log) registered
in build.ts.
- frontend/src/client/admin-submission-building-blocks.ts (~370
LoC): admin client — list paint, edit form (slug + firm +
section_key + proceeding_family + title/desc/content per lang +
visibility radio + is_published toggle), per-block version log
with restore button. Bilingual labels.
- frontend/src/client/submission-draft.ts: per-section "+ Baustein"
button on the Composer editor toolbar (Slice B substrate gets one
more affordance). openBlockPicker opens a modal filtered to the
section's section_key, 200ms-debounced search by free text against
title/description/content. Click a hit → POST insert-into-section
→ section row's content_md_<lang> gains the block's content
appended at the end (Q2's plain-text paste semantic, no lineage).
- ~240 LoC of CSS: modal overlay + picker rows with tier-colored
visibility chips + admin editor 3-pane grid + form rows + version
list.
- 12 new i18n keys × 2 langs (admin.building_blocks.*).
Tests:
- TestValidVisibility (8 cases including case-sensitivity + empty).
- TestAppendBlockContent (8 cases covering empty-existing / empty-
addition / whitespace-only / trailing newline collapse).
- TestBuildingBlockVisibilityConstants pins the 4 string literals
against drift (RLS predicate + DB CHECK depend on them).
Build hygiene: go build/vet/test -short clean; bun run build clean
(2906 i18n keys, data-i18n scan clean).
Hard rules per ratifications honoured:
- Q2: no building_block_id lineage on sections (paste is plain text).
- Q9: 4 visibility tiers (private/team/firm/global).
- NO behavior change for pre-Composer drafts (the picker just doesn't
show — section list is hidden for base_id NULL drafts).
- {{rule.X}} aliases preserved (block content goes through the same
v1 placeholder pass on export as section prose).
NOT in scope per Slice C brief:
- User-authored private blocks (Slice C ships admin curation only;
any-user create is a follow-up).
- Tier promotion review workflow (admin sets tier directly today).
- Per-section "where is this block used" reverse lookup (no lineage
to query).
- Slice D's rich-prose features (headings, lists, blockquote) still
Slice D's job; this Slice doesn't extend the MD walker.
t-paliad-315 Slice C
2485 lines
99 KiB
TypeScript
2485 lines
99 KiB
TypeScript
import { initI18n, t } from "./i18n";
|
||
import { initSidebar } from "./sidebar";
|
||
|
||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||
// Submissions/Schriftsätze editor at
|
||
// /projects/{id}/submissions/{code}/draft
|
||
// /projects/{id}/submissions/{code}/draft/{draft_id}
|
||
//
|
||
// Reads (project_id, submission_code, optional draft_id) from the URL,
|
||
// loads the editor payload (draft row + resolved bag + merged bag +
|
||
// HTML preview), and wires the sidebar / preview / autosave / export.
|
||
//
|
||
// Autosave is debounced 500ms after the lawyer stops typing in any
|
||
// variable input. Each PATCH returns a fresh editor payload, so the
|
||
// preview pane stays in lockstep with the variable overrides.
|
||
|
||
interface SubmissionDraftJSON {
|
||
id: string;
|
||
project_id: string | null;
|
||
submission_code: string;
|
||
user_id: string;
|
||
name: string;
|
||
// t-paliad-276 — per-draft output language ("de" or "en"). Drives the
|
||
// template-variant lookup and language-aware variable resolution.
|
||
language: string;
|
||
variables: Record<string, string>;
|
||
selected_parties: string[];
|
||
last_exported_at?: string | null;
|
||
last_exported_sha?: string | null;
|
||
last_imported_at?: string | null;
|
||
// t-paliad-313 Composer Slice A — base reference + Composer-side
|
||
// metadata. base_id is null on pre-Composer drafts (the v1 render
|
||
// path stays the fallback). composer_meta carries the seed-time
|
||
// section order in later slices.
|
||
base_id?: string | null;
|
||
composer_meta?: Record<string, unknown>;
|
||
created_at: string;
|
||
updated_at: string;
|
||
}
|
||
|
||
// t-paliad-313 Composer Slice A — per-draft section row, surfaced
|
||
// read-only in the editor body. Slice B adds inline edit + PATCH.
|
||
interface SubmissionSectionJSON {
|
||
id: string;
|
||
section_key: string;
|
||
order_index: number;
|
||
kind: string;
|
||
label_de: string;
|
||
label_en: string;
|
||
included: boolean;
|
||
content_md_de: string;
|
||
content_md_en: string;
|
||
}
|
||
|
||
// t-paliad-313 Composer Slice A — base catalog row, surfaced in the
|
||
// sidebar picker dropdown.
|
||
interface SubmissionBaseRow {
|
||
id: string;
|
||
slug: string;
|
||
firm?: string | null;
|
||
proceeding_family?: string | null;
|
||
label_de: string;
|
||
label_en: string;
|
||
description_de?: string | null;
|
||
description_en?: string | null;
|
||
gitea_path: string;
|
||
is_default_for: string[];
|
||
is_active: boolean;
|
||
section_count: number;
|
||
}
|
||
|
||
interface AvailablePartyJSON {
|
||
id: string;
|
||
name: string;
|
||
role?: string;
|
||
representative?: string;
|
||
}
|
||
|
||
interface SubmissionRuleSummary {
|
||
name: string;
|
||
name_en: string;
|
||
submission_code: string;
|
||
primary_party?: string;
|
||
event_type?: string;
|
||
legal_source?: string;
|
||
legal_source_pretty?: string;
|
||
}
|
||
|
||
interface SubmissionDraftView {
|
||
draft: SubmissionDraftJSON;
|
||
rule?: SubmissionRuleSummary;
|
||
resolved_bag: Record<string, string>;
|
||
merged_bag: Record<string, string>;
|
||
preview_html: string;
|
||
lang: string;
|
||
has_template: boolean;
|
||
template_missing?: boolean;
|
||
available_parties: AvailablePartyJSON[];
|
||
// t-paliad-276 — template-tier metadata used to surface the
|
||
// "Fallback: universelles Skelett" notice when the requested draft
|
||
// language has no per-firm language-matched template.
|
||
template_tier?: string;
|
||
language_fallback?: boolean;
|
||
// t-paliad-313 Composer Slice A — per-draft section stack. Empty
|
||
// for pre-Composer drafts where no rows have been seeded.
|
||
sections: SubmissionSectionJSON[];
|
||
}
|
||
|
||
interface SubmissionDraftListResponse {
|
||
project_id: string;
|
||
submission_code: string;
|
||
drafts: SubmissionDraftJSON[];
|
||
}
|
||
|
||
interface ParsedPath {
|
||
// Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
|
||
// Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
|
||
// from the loaded draft row after fetch.
|
||
projectID: string | null;
|
||
submissionCode: string | null;
|
||
draftID?: string;
|
||
// mode tracks the URL shape we were entered from. Affects redirect
|
||
// semantics when we create a new draft or navigate away.
|
||
mode: "project" | "global";
|
||
}
|
||
|
||
const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
|
||
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;
|
||
|
||
function parsePath(): ParsedPath | null {
|
||
let m = PROJECT_PATH_RE.exec(window.location.pathname);
|
||
if (m) {
|
||
return {
|
||
projectID: m[1],
|
||
submissionCode: decodeURIComponent(m[2]),
|
||
draftID: m[3],
|
||
mode: "project",
|
||
};
|
||
}
|
||
m = GLOBAL_PATH_RE.exec(window.location.pathname);
|
||
if (m) {
|
||
return {
|
||
projectID: null,
|
||
submissionCode: null,
|
||
draftID: m[1],
|
||
mode: "global",
|
||
};
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function isEN(): boolean {
|
||
return document.documentElement.lang === "en";
|
||
}
|
||
|
||
function escapeHtml(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||
// Mirrors the same shape the email-template variables sidebar uses;
|
||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface VariableLabel {
|
||
de: string;
|
||
en: string;
|
||
}
|
||
|
||
interface VariableGroup {
|
||
id: string;
|
||
label: VariableLabel;
|
||
keys: string[];
|
||
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
|
||
// initial state is collapsed iff collapsedByDefault. Used for the
|
||
// Frist section which lawyers rarely need to override (the variables
|
||
// stay resolvable in the bag for the few templates that still want
|
||
// them, but render no body content by default).
|
||
collapsible?: boolean;
|
||
collapsedByDefault?: boolean;
|
||
}
|
||
|
||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||
"today": { de: "Heute", en: "Today" },
|
||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||
"user.email": { de: "E-Mail", en: "Email" },
|
||
"user.office": { de: "Büro", en: "Office" },
|
||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||
"project.court": { de: "Gericht", en: "Court" },
|
||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||
// Procedural-event namespace (t-paliad-262 Slice A, design doc
|
||
// docs/design-procedural-events-model-2026-05-25.md). The canonical
|
||
// placeholder names are below; the `rule.*` aliases that follow are
|
||
// @deprecated but kept forever per m's Q7 lock — existing Word
|
||
// templates and saved drafts authored with the old names keep
|
||
// merging identically.
|
||
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
|
||
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
|
||
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
|
||
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
|
||
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
|
||
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
|
||
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
|
||
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
|
||
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
|
||
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
|
||
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
|
||
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
|
||
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
|
||
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
|
||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||
};
|
||
|
||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||
// (where the picker UI lives — this group only carries the manual
|
||
// {{parties.*}} overrides for power-users), then Frist collapsed by
|
||
// default (the deadline.* keys still resolve in the bag but the default
|
||
// templates don't render them in the body any more), then Sonstiges for
|
||
// the firm/date/user trim. The legacy procedural_event/rule namespaces
|
||
// fold into Mandant/Verfahren so the lawyer reads them in their natural
|
||
// context.
|
||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||
{
|
||
id: "mandant_verfahren",
|
||
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
|
||
keys: [
|
||
"project.title",
|
||
"project.case_number",
|
||
"project.court",
|
||
"project.patent_number",
|
||
"project.patent_number_upc",
|
||
"project.filing_date",
|
||
"project.grant_date",
|
||
"project.our_side",
|
||
"project.proceeding.name",
|
||
"project.client_number",
|
||
"project.matter_number",
|
||
"project.reference",
|
||
"project.instance_level",
|
||
"procedural_event.name",
|
||
"procedural_event.legal_source_pretty",
|
||
"procedural_event.primary_party",
|
||
"procedural_event.event_kind",
|
||
"procedural_event.code",
|
||
],
|
||
},
|
||
{
|
||
id: "parties",
|
||
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
|
||
// Manual overrides for {{parties.<role>.*}} placeholders — power-
|
||
// user escape hatch when the lawyer wants the rendered string to
|
||
// differ from the picker selection (e.g. honourific prefix on
|
||
// representative). Collapsed by default because the picker above
|
||
// is the canonical surface; these rows exist only as a safety
|
||
// valve.
|
||
collapsible: true,
|
||
collapsedByDefault: true,
|
||
keys: [
|
||
"parties.claimant.name",
|
||
"parties.claimant.representative",
|
||
"parties.defendant.name",
|
||
"parties.defendant.representative",
|
||
"parties.other.name",
|
||
"parties.other.representative",
|
||
],
|
||
},
|
||
{
|
||
id: "deadline",
|
||
label: { de: "Frist (intern)", en: "Deadline (internal)" },
|
||
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
|
||
// in the default skeleton body (internal context that doesn't
|
||
// belong in a court-bound submission). The values still resolve
|
||
// here so a custom template can pick them up if needed; collapsed
|
||
// because most drafts never touch them.
|
||
collapsible: true,
|
||
collapsedByDefault: true,
|
||
keys: [
|
||
"deadline.due_date",
|
||
"deadline.due_date_long_de",
|
||
"deadline.due_date_long_en",
|
||
"deadline.computed_from",
|
||
"deadline.title",
|
||
"deadline.original_due_date",
|
||
],
|
||
},
|
||
{
|
||
id: "sonstiges",
|
||
label: { de: "Sonstiges", en: "Other" },
|
||
keys: [
|
||
"firm.name",
|
||
"firm.signature_block",
|
||
"user.display_name",
|
||
"user.email",
|
||
"user.office",
|
||
"today.long_de",
|
||
"today.long_en",
|
||
],
|
||
},
|
||
];
|
||
|
||
function labelFor(key: string): string {
|
||
const entry = VARIABLE_LABELS[key];
|
||
if (!entry) return key;
|
||
return isEN() ? entry.en : entry.de;
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Module state
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface State {
|
||
parsed: ParsedPath;
|
||
view: SubmissionDraftView | null;
|
||
drafts: SubmissionDraftJSON[];
|
||
saveTimer: number | null;
|
||
pendingOverrides: Record<string, string> | null;
|
||
inFlight: AbortController | null;
|
||
// t-paliad-287 — per-section collapse memory. Sticky across repaints
|
||
// so autosave (which calls paintVariables) doesn't snap an open
|
||
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
|
||
collapsedGroups: Record<string, boolean>;
|
||
// t-paliad-287 — which side the Add-Party panel is currently open for
|
||
// (one panel can be open at a time; clicking the other side's button
|
||
// toggles). null means closed.
|
||
addPartyOpen: PartySide | null;
|
||
addPartyMode: "manual" | "search";
|
||
addPartySearchHits: PartySearchHit[];
|
||
addPartyBusy: boolean;
|
||
// t-paliad-313 Composer Slice A — base catalog fetched once on boot.
|
||
// Picker hidden until populated; empty array (after the fetch
|
||
// completes) keeps the picker hidden permanently for this load.
|
||
bases: SubmissionBaseRow[];
|
||
basesLoaded: boolean;
|
||
}
|
||
|
||
type PartySide = "claimant" | "defendant" | "other";
|
||
|
||
interface PartySearchHit {
|
||
id: string;
|
||
project_id: string;
|
||
project_title: string;
|
||
project_reference?: string | null;
|
||
name: string;
|
||
role?: string;
|
||
representative?: string;
|
||
}
|
||
|
||
const state: State = {
|
||
parsed: null as unknown as ParsedPath,
|
||
view: null,
|
||
drafts: [],
|
||
saveTimer: null,
|
||
pendingOverrides: null,
|
||
inFlight: null,
|
||
collapsedGroups: {},
|
||
addPartyOpen: null,
|
||
addPartyMode: "manual",
|
||
addPartySearchHits: [],
|
||
addPartyBusy: false,
|
||
bases: [],
|
||
basesLoaded: false,
|
||
};
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Boot
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function boot(): Promise<void> {
|
||
initI18n();
|
||
initSidebar();
|
||
|
||
const parsed = parsePath();
|
||
if (!parsed) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
state.parsed = parsed;
|
||
|
||
// t-paliad-313 Composer Slice A — kick the base catalog fetch in
|
||
// parallel with the view load. The picker hydrates when both land;
|
||
// either failing leaves the picker hidden but the editor functional.
|
||
loadBases().catch(err => {
|
||
console.warn("submission-draft: base catalog fetch failed", err);
|
||
state.basesLoaded = true;
|
||
});
|
||
|
||
try {
|
||
if (parsed.mode === "global") {
|
||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||
// list (the sidebar switcher) is scoped to the same project +
|
||
// submission_code AFTER we've loaded the draft.
|
||
if (!parsed.draftID) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
const view = await fetchGlobalView(parsed.draftID);
|
||
state.view = view;
|
||
// Backfill parsed.* from the loaded draft so the sidebar
|
||
// switcher can list peers; project-less drafts get no peer list
|
||
// beyond themselves (no useful (project, code) cross-section).
|
||
state.parsed = {
|
||
...parsed,
|
||
projectID: view.draft.project_id,
|
||
submissionCode: view.draft.submission_code,
|
||
};
|
||
if (view.draft.project_id) {
|
||
try {
|
||
const list = await fetchDrafts(state.parsed);
|
||
state.drafts = list.drafts;
|
||
} catch { state.drafts = [view.draft]; }
|
||
} else {
|
||
state.drafts = [view.draft];
|
||
}
|
||
paint();
|
||
return;
|
||
}
|
||
|
||
// Project-scoped path: same logic as before.
|
||
if (!parsed.projectID || !parsed.submissionCode) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
const list = await fetchDrafts(parsed);
|
||
state.drafts = list.drafts;
|
||
let draft: SubmissionDraftJSON | null = null;
|
||
if (parsed.draftID) {
|
||
draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
|
||
if (!draft) {
|
||
showNotFound();
|
||
return;
|
||
}
|
||
} else if (state.drafts.length > 0) {
|
||
draft = state.drafts[0];
|
||
// Redirect to the canonical /draft/{id} URL so refresh + share
|
||
// both land on the same draft.
|
||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||
window.history.replaceState({}, "", url);
|
||
state.parsed = { ...parsed, draftID: draft.id };
|
||
} else {
|
||
draft = await createProjectDraft(parsed);
|
||
state.drafts = [draft];
|
||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||
window.history.replaceState({}, "", url);
|
||
state.parsed = { ...parsed, draftID: draft.id };
|
||
}
|
||
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
|
||
state.view = view;
|
||
paint();
|
||
} catch (err) {
|
||
console.error("submission-draft boot:", err);
|
||
showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// API
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
|
||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
|
||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
|
||
if (!resp.ok) throw new Error(`create draft ${resp.status}`);
|
||
const view = (await resp.json()) as SubmissionDraftView;
|
||
return view.draft;
|
||
}
|
||
|
||
async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
|
||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
|
||
const resp = await fetch(url);
|
||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||
const resp = await fetch(`/api/submission-drafts/${draftID}`);
|
||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||
return resp.json();
|
||
}
|
||
|
||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
|
||
const p = state.parsed;
|
||
if (!p.draftID) throw new Error("no draft id");
|
||
if (state.inFlight) {
|
||
state.inFlight.abort();
|
||
state.inFlight = null;
|
||
}
|
||
const ctl = new AbortController();
|
||
state.inFlight = ctl;
|
||
// The global PATCH endpoint accepts both project-scoped and
|
||
// project-less drafts — route everything through it so attach (set
|
||
// project_id) works from both URL shapes.
|
||
const url = `/api/submission-drafts/${p.draftID}`;
|
||
try {
|
||
const resp = await fetch(url, {
|
||
method: "PATCH",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
signal: ctl.signal,
|
||
});
|
||
if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
|
||
return resp.json();
|
||
} finally {
|
||
if (state.inFlight === ctl) state.inFlight = null;
|
||
}
|
||
}
|
||
|
||
async function deleteDraft(): Promise<void> {
|
||
const p = state.parsed;
|
||
if (!p.draftID) return;
|
||
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
|
||
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Render
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
function paint(): void {
|
||
if (!state.view) return;
|
||
hide("submission-draft-loading");
|
||
hide("submission-draft-notfound");
|
||
hide("submission-draft-error");
|
||
show("submission-draft-body");
|
||
|
||
paintHeader();
|
||
paintBackLink();
|
||
paintNoProjectBanner();
|
||
paintSwitcher();
|
||
paintNameRow();
|
||
paintBasePicker();
|
||
paintImportRow();
|
||
paintPartyPicker();
|
||
paintLanguageRow();
|
||
paintLanguageFallback();
|
||
paintVariables();
|
||
paintSectionList();
|
||
paintPreview();
|
||
}
|
||
|
||
function paintHeader(): void {
|
||
const view = state.view!;
|
||
const title = document.getElementById("submission-draft-title");
|
||
if (title) {
|
||
const ruleName = view.rule?.name ?? view.draft.submission_code;
|
||
title.textContent = ruleName;
|
||
}
|
||
const subtitle = document.getElementById("submission-draft-subtitle");
|
||
if (subtitle) {
|
||
const code = view.draft.submission_code;
|
||
const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
|
||
const parts: string[] = [code];
|
||
if (source) parts.push(source);
|
||
subtitle.textContent = parts.join(" · ");
|
||
}
|
||
}
|
||
|
||
function paintBackLink(): void {
|
||
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
|
||
if (!back || !state.view) return;
|
||
if (state.view.draft.project_id) {
|
||
back.href = `/projects/${state.view.draft.project_id}/submissions`;
|
||
back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
|
||
} else {
|
||
back.href = "/submissions";
|
||
back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
|
||
}
|
||
}
|
||
|
||
// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
|
||
// banner above the editor body. The banner offers a "Projekt zuweisen"
|
||
// button that opens an inline project picker — same modal pattern the
|
||
// /submissions/new page uses. Removed once the draft has a project_id.
|
||
function paintNoProjectBanner(): void {
|
||
const body = document.getElementById("submission-draft-body");
|
||
if (!body || !state.view) return;
|
||
let banner = document.getElementById("submission-draft-noproject-banner");
|
||
|
||
if (state.view.draft.project_id) {
|
||
if (banner) banner.remove();
|
||
return;
|
||
}
|
||
|
||
const msg = isEN()
|
||
? "No project assigned — all variables are filled manually."
|
||
: "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
|
||
const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
|
||
const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
|
||
<button type="button" id="submission-draft-noproject-assign"
|
||
class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;
|
||
|
||
if (banner) {
|
||
banner.innerHTML = html;
|
||
} else {
|
||
banner = document.createElement("aside");
|
||
banner.id = "submission-draft-noproject-banner";
|
||
banner.className = "submission-draft-noproject-banner";
|
||
banner.innerHTML = html;
|
||
// Insert before the header.
|
||
const header = body.querySelector(".submission-draft-header");
|
||
if (header && header.parentElement) {
|
||
header.parentElement.insertBefore(banner, header);
|
||
} else {
|
||
body.prepend(banner);
|
||
}
|
||
}
|
||
|
||
const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
|
||
if (btn) btn.onclick = () => openProjectAssignPicker();
|
||
}
|
||
|
||
function paintSwitcher(): void {
|
||
const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
|
||
if (!sel || !state.view) return;
|
||
sel.innerHTML = state.drafts
|
||
.map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
|
||
.join("");
|
||
sel.onchange = () => {
|
||
const id = sel.value;
|
||
if (!id || !state.view) return;
|
||
if (id === state.view.draft.id) return;
|
||
const p = state.parsed;
|
||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
|
||
window.location.href = url;
|
||
};
|
||
}
|
||
|
||
function paintNameRow(): void {
|
||
const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
|
||
const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
|
||
if (input && state.view) {
|
||
input.value = state.view.draft.name;
|
||
input.onchange = () => {
|
||
const newName = input.value.trim();
|
||
if (!newName || newName === state.view!.draft.name) return;
|
||
void renameDraft(newName);
|
||
};
|
||
}
|
||
if (del) del.onclick = () => onDelete();
|
||
|
||
const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
|
||
if (newBtn) newBtn.onclick = () => onCreateNew();
|
||
|
||
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
|
||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||
}
|
||
|
||
// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.
|
||
// Hidden when the draft has no project (no project state to import).
|
||
function paintImportRow(): void {
|
||
const row = document.getElementById("submission-draft-import-row");
|
||
const btn = document.getElementById("submission-draft-import-btn") as HTMLButtonElement | null;
|
||
const stamp = document.getElementById("submission-draft-import-stamp");
|
||
if (!row || !btn || !stamp || !state.view) return;
|
||
|
||
if (!state.view.draft.project_id) {
|
||
row.style.display = "none";
|
||
return;
|
||
}
|
||
row.style.display = "";
|
||
|
||
const last = state.view.draft.last_imported_at;
|
||
if (last) {
|
||
stamp.textContent = (isEN() ? "Last imported: " : "Zuletzt importiert: ") + formatStamp(last);
|
||
} else {
|
||
stamp.textContent = isEN() ? "Never imported" : "Noch nicht importiert";
|
||
}
|
||
btn.onclick = () => { void onImportFromProject(btn); };
|
||
}
|
||
|
||
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
|
||
// Party affordance per side. Lists every party on the draft's project
|
||
// (view.available_parties), grouped by role, with one checkbox per
|
||
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
|
||
// an "+ Partei hinzufügen" button that opens an inline panel with two
|
||
// modes: manual entry (creates a fresh paliad.parties row) or DB
|
||
// picker (searches every visible project, clones the row into THIS
|
||
// project on selection). Empty selection still falls back to the
|
||
// legacy "include every party" default.
|
||
function paintPartyPicker(): void {
|
||
const block = document.getElementById("submission-draft-parties");
|
||
const list = document.getElementById("submission-draft-parties-list");
|
||
if (!block || !list || !state.view) return;
|
||
|
||
// t-paliad-287 — picker is now shown even on empty-roster projects so
|
||
// the lawyer can use Add Party to populate. Still hidden when there
|
||
// is no project attached (no row to attach a party to).
|
||
if (!state.view.draft.project_id) {
|
||
block.style.display = "none";
|
||
list.innerHTML = "";
|
||
return;
|
||
}
|
||
block.style.display = "";
|
||
|
||
const parties = state.view.available_parties ?? [];
|
||
const selected = new Set(state.view.draft.selected_parties ?? []);
|
||
// Empty selection is the implicit "all" default — pre-check every
|
||
// party so the lawyer can see what's currently being mentioned and
|
||
// then deselect what they want to drop. This matches the issue's
|
||
// "default = all parties on the project, lawyer can deselect" line.
|
||
const effective = selected.size === 0
|
||
? new Set(parties.map((p) => p.id))
|
||
: selected;
|
||
|
||
const grouped = groupPartiesByRole(parties);
|
||
let html = "";
|
||
for (const group of grouped) {
|
||
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
||
html += `<legend>${escapeHtml(group.label)}</legend>`;
|
||
if (group.parties.length === 0) {
|
||
html += `<p class="submission-draft-parties-empty">${escapeHtml(
|
||
isEN() ? "No parties yet." : "Noch keine Parteien.",
|
||
)}</p>`;
|
||
}
|
||
for (const p of group.parties) {
|
||
const checked = effective.has(p.id) ? " checked" : "";
|
||
const chip = p.role
|
||
? `<span class="submission-draft-party-chip">${escapeHtml(p.role)}</span>`
|
||
: "";
|
||
const rep = p.representative
|
||
? `<span class="submission-draft-party-rep">${escapeHtml(
|
||
(isEN() ? "Repr.: " : "Vertr.: ") + p.representative,
|
||
)}</span>`
|
||
: "";
|
||
html += `<label class="submission-draft-party-row">`;
|
||
html += `<input type="checkbox" class="submission-draft-party-check"`;
|
||
html += ` data-party-id="${escapeHtml(p.id)}"${checked} />`;
|
||
html += `<span class="submission-draft-party-name">${escapeHtml(p.name)}</span>`;
|
||
html += chip;
|
||
html += rep;
|
||
html += `</label>`;
|
||
}
|
||
html += renderAddPartyControls(group.bucket);
|
||
html += `</fieldset>`;
|
||
}
|
||
list.innerHTML = html;
|
||
|
||
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
||
inp.addEventListener("change", () => onPartySelectionChange());
|
||
});
|
||
wireAddPartyControls(list);
|
||
}
|
||
|
||
// renderAddPartyControls emits the per-side "+ Add party" button and
|
||
// (when expanded) the inline panel offering manual entry OR DB search.
|
||
// Sticky panel state lives in state.addPartyOpen so a repaint after
|
||
// search-fetch / autosave / language-switch doesn't snap the panel
|
||
// shut mid-edit.
|
||
function renderAddPartyControls(side: PartySide): string {
|
||
const open = state.addPartyOpen === side;
|
||
const mode = state.addPartyMode;
|
||
const sideLabel = sideLabelFor(side);
|
||
const btnLabel = isEN()
|
||
? `+ Add party (${sideLabel})`
|
||
: `+ Partei hinzufügen (${sideLabel})`;
|
||
|
||
let html = `<div class="submission-draft-addparty">`;
|
||
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
|
||
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
|
||
html += escapeHtml(btnLabel);
|
||
html += `</button>`;
|
||
|
||
if (!open) {
|
||
html += `</div>`;
|
||
return html;
|
||
}
|
||
|
||
// Tabs — manual / search.
|
||
html += `<div class="submission-draft-addparty-panel">`;
|
||
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
|
||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
|
||
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
|
||
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
|
||
html += `</button>`;
|
||
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
||
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
|
||
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
|
||
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
|
||
html += `</button>`;
|
||
html += `</div>`;
|
||
|
||
if (mode === "manual") {
|
||
html += renderAddPartyManualForm(side);
|
||
} else {
|
||
html += renderAddPartySearchPanel(side);
|
||
}
|
||
|
||
html += `</div></div>`;
|
||
return html;
|
||
}
|
||
|
||
function renderAddPartyManualForm(side: PartySide): string {
|
||
const defaultRole = defaultRoleFor(side);
|
||
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
|
||
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
|
||
html += `<label class="submission-draft-addparty-field">`;
|
||
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
|
||
html += `<input type="text" name="name" required class="entity-form-input"`;
|
||
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
|
||
html += `</label>`;
|
||
html += `<label class="submission-draft-addparty-field">`;
|
||
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
|
||
html += `<input type="text" name="role" class="entity-form-input"`;
|
||
html += ` value="${escapeHtml(defaultRole)}"`;
|
||
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
|
||
html += `</label>`;
|
||
html += `<label class="submission-draft-addparty-field">`;
|
||
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
|
||
html += `<input type="text" name="representative" class="entity-form-input"`;
|
||
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
|
||
html += `</label>`;
|
||
html += `<div class="submission-draft-addparty-actions">`;
|
||
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
|
||
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
|
||
html += `</button>`;
|
||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||
html += `</button>`;
|
||
html += `</div>`;
|
||
html += `</form>`;
|
||
return html;
|
||
}
|
||
|
||
function renderAddPartySearchPanel(side: PartySide): string {
|
||
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
|
||
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
|
||
html += ` data-side="${side}"`;
|
||
html += ` placeholder="${escapeHtml(
|
||
isEN()
|
||
? "Search across projects (name or representative)…"
|
||
: "In allen Projekten suchen (Name oder Vertreter)…",
|
||
)}" />`;
|
||
html += renderPartySearchResultsList();
|
||
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
|
||
isEN()
|
||
? "Picking a row clones it as a fresh party on this project — no typing."
|
||
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
|
||
)}</p>`;
|
||
html += `<div class="submission-draft-addparty-actions">`;
|
||
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
||
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
||
html += `</button>`;
|
||
html += `</div>`;
|
||
html += `</div>`;
|
||
return html;
|
||
}
|
||
|
||
function wireAddPartyControls(root: HTMLElement): void {
|
||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const side = (btn.dataset.side as PartySide) ?? "other";
|
||
if (state.addPartyOpen === side) {
|
||
// Toggle off.
|
||
state.addPartyOpen = null;
|
||
state.addPartySearchHits = [];
|
||
} else {
|
||
state.addPartyOpen = side;
|
||
state.addPartyMode = "manual";
|
||
state.addPartySearchHits = [];
|
||
}
|
||
paintPartyPicker();
|
||
});
|
||
});
|
||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const tab = btn.dataset.tab;
|
||
if (tab !== "manual" && tab !== "search") return;
|
||
state.addPartyMode = tab;
|
||
if (tab === "manual") state.addPartySearchHits = [];
|
||
paintPartyPicker();
|
||
if (tab === "search") {
|
||
// Pre-load most-recent matches with empty query so the lawyer
|
||
// sees options without typing first.
|
||
void runPartySearch("");
|
||
}
|
||
});
|
||
});
|
||
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
state.addPartyOpen = null;
|
||
state.addPartySearchHits = [];
|
||
paintPartyPicker();
|
||
});
|
||
});
|
||
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
|
||
form.addEventListener("submit", (ev) => {
|
||
ev.preventDefault();
|
||
const side = (form.dataset.side as PartySide) ?? "other";
|
||
const data = new FormData(form);
|
||
const name = String(data.get("name") ?? "").trim();
|
||
if (!name) return;
|
||
const role = String(data.get("role") ?? "").trim();
|
||
const representative = String(data.get("representative") ?? "").trim();
|
||
void onAddPartyManualSubmit(side, { name, role, representative });
|
||
});
|
||
});
|
||
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
|
||
let timer: number | null = null;
|
||
inp.addEventListener("input", () => {
|
||
if (timer !== null) window.clearTimeout(timer);
|
||
timer = window.setTimeout(() => {
|
||
void runPartySearch(inp.value.trim());
|
||
}, 200);
|
||
});
|
||
// Pre-load on first render of the search tab.
|
||
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
|
||
void runPartySearch("");
|
||
}
|
||
});
|
||
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
||
li.addEventListener("click", () => {
|
||
const hitID = li.dataset.hitId;
|
||
if (!hitID) return;
|
||
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
||
if (!hit) return;
|
||
const side = state.addPartyOpen ?? "other";
|
||
void onAddPartySearchPick(side, hit);
|
||
});
|
||
});
|
||
}
|
||
|
||
function sideLabelFor(side: PartySide): string {
|
||
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
|
||
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
|
||
return isEN() ? "Other parties" : "Weitere Parteien";
|
||
}
|
||
|
||
function defaultRoleFor(side: PartySide): string {
|
||
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
|
||
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
|
||
return "";
|
||
}
|
||
|
||
interface PartyRoleGroup {
|
||
bucket: "claimant" | "defendant" | "other";
|
||
label: string;
|
||
parties: AvailablePartyJSON[];
|
||
}
|
||
|
||
function groupPartiesByRole(parties: AvailablePartyJSON[]): PartyRoleGroup[] {
|
||
const claimants: AvailablePartyJSON[] = [];
|
||
const defendants: AvailablePartyJSON[] = [];
|
||
const others: AvailablePartyJSON[] = [];
|
||
for (const p of parties) {
|
||
const role = (p.role ?? "").trim().toLowerCase();
|
||
if (role === "claimant" || role === "kläger" || role === "klaeger"
|
||
|| role === "klägerin" || role === "klaegerin") {
|
||
claimants.push(p);
|
||
} else if (role === "defendant" || role === "beklagter" || role === "beklagte") {
|
||
defendants.push(p);
|
||
} else {
|
||
others.push(p);
|
||
}
|
||
}
|
||
return [
|
||
{
|
||
bucket: "claimant",
|
||
label: isEN() ? "Claimants" : "Klägerinnen",
|
||
parties: claimants,
|
||
},
|
||
{
|
||
bucket: "defendant",
|
||
label: isEN() ? "Defendants" : "Beklagte",
|
||
parties: defendants,
|
||
},
|
||
{
|
||
bucket: "other",
|
||
label: isEN() ? "Other parties" : "Weitere Parteien",
|
||
parties: others,
|
||
},
|
||
];
|
||
}
|
||
|
||
function formatStamp(iso: string): string {
|
||
const d = new Date(iso);
|
||
if (Number.isNaN(d.getTime())) return iso;
|
||
return d.toLocaleString(isEN() ? "en-GB" : "de-DE");
|
||
}
|
||
|
||
// paintLanguageRow syncs the DE/EN radio with the loaded draft's
|
||
// language. Switching the radio fires onLanguageChange which PATCHes
|
||
// the draft and lets the server return the freshly-resolved bag +
|
||
// preview HTML (so the lawyer sees the EN form names appear without a
|
||
// manual reload). t-paliad-276.
|
||
function paintLanguageRow(): void {
|
||
if (!state.view) return;
|
||
const lang = (state.view.draft.language || "de").toLowerCase();
|
||
const de = document.getElementById("submission-draft-language-de") as HTMLInputElement | null;
|
||
const en = document.getElementById("submission-draft-language-en") as HTMLInputElement | null;
|
||
if (de) {
|
||
de.checked = lang === "de";
|
||
de.onchange = () => { void onLanguageChange("de"); };
|
||
}
|
||
if (en) {
|
||
en.checked = lang === "en";
|
||
en.onchange = () => { void onLanguageChange("en"); };
|
||
}
|
||
}
|
||
|
||
// paintLanguageFallback shows / hides the "no language-matched
|
||
// template" notice. The server sets language_fallback=true when the
|
||
// resolved template tier doesn't match the draft's language
|
||
// (e.g. EN draft → DE per-code template, or no skeleton EN sibling).
|
||
function paintLanguageFallback(): void {
|
||
const el = document.getElementById("submission-draft-language-fallback");
|
||
if (!el) return;
|
||
const fallback = !!state.view?.language_fallback;
|
||
el.style.display = fallback ? "" : "none";
|
||
}
|
||
|
||
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
|
||
if (!state.view) return;
|
||
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
try {
|
||
const view = await patchDraft({ language: lang });
|
||
state.view = view;
|
||
// Repaint everything that depends on language: the DE/EN form
|
||
// values in the resolved bag, the localized rule name in the
|
||
// header, and the fallback notice.
|
||
paintHeader();
|
||
paintLanguageRow();
|
||
paintLanguageFallback();
|
||
paintVariables();
|
||
paintPreview();
|
||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError") return;
|
||
console.error("submission-draft language switch:", err);
|
||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||
// Revert the radio to the persisted value so the UI doesn't lie
|
||
// about which language is active.
|
||
paintLanguageRow();
|
||
}
|
||
}
|
||
|
||
|
||
function paintVariables(): void {
|
||
const host = document.getElementById("submission-draft-variables");
|
||
if (!host || !state.view) return;
|
||
const overrides = state.view.draft.variables ?? {};
|
||
const resolved = state.view.resolved_bag ?? {};
|
||
const merged = state.view.merged_bag ?? {};
|
||
|
||
let html = "";
|
||
for (const group of VARIABLE_GROUPS) {
|
||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||
// Re-use the user's prior toggle state across paintVariables calls
|
||
// (autosave / language switch trigger a repaint). Default sticky
|
||
// state lives in state.collapsedGroups; on first render the
|
||
// collapsedByDefault flag seeds it.
|
||
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
|
||
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
|
||
}
|
||
const collapsed = !!state.collapsedGroups[group.id];
|
||
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
|
||
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
|
||
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
|
||
if (group.collapsible) {
|
||
html += `<button type="button" class="submission-draft-var-group-toggle"`;
|
||
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
|
||
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
|
||
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
|
||
html += `</button>`;
|
||
} else {
|
||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||
}
|
||
html += `<div class="submission-draft-var-group-body">`;
|
||
for (const key of group.keys) {
|
||
const label = labelFor(key);
|
||
const override = overrides[key];
|
||
const resolvedVal = resolved[key] ?? "";
|
||
const mergedVal = merged[key] ?? "";
|
||
const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
|
||
html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
|
||
html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
|
||
html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
|
||
html += ` data-var="${escapeHtml(key)}"`;
|
||
html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
|
||
html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
|
||
const hintParts: string[] = [];
|
||
hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
|
||
if (overridden) {
|
||
if (override === "") {
|
||
hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
|
||
} else if (override !== resolvedVal) {
|
||
const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
|
||
hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
|
||
}
|
||
}
|
||
html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
|
||
if (overridden && override !== resolvedVal) {
|
||
html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
|
||
}
|
||
html += `</label>`;
|
||
// Visual hint: marker text appears in preview when override is "".
|
||
void mergedVal;
|
||
}
|
||
html += `</div>`;
|
||
html += `</section>`;
|
||
}
|
||
host.innerHTML = html;
|
||
|
||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const id = btn.dataset.toggleGroup;
|
||
if (!id) return;
|
||
state.collapsedGroups[id] = !state.collapsedGroups[id];
|
||
paintVariables();
|
||
});
|
||
});
|
||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||
inp.addEventListener("input", () => onVarChange(inp));
|
||
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
||
// matching .draft-var span in the preview (sticky while focused,
|
||
// clears on blur). Survives autosave repaints because paintVariables
|
||
// is called by flushAutosave and we re-bind every render.
|
||
inp.addEventListener("focusin", () => onVarFocusEnter(inp.dataset.var ?? ""));
|
||
inp.addEventListener("focusout", () => onVarFocusLeave(inp.dataset.var ?? ""));
|
||
});
|
||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
|
||
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
|
||
});
|
||
// After repaint, re-apply the active highlight if a field is still
|
||
// focused (paintVariables runs after autosave; the same input regains
|
||
// focus via restoreVarFocus and would otherwise emit focusin too
|
||
// late for our handler — re-apply explicitly).
|
||
const active = document.activeElement;
|
||
if (isVarField(active)) {
|
||
const key = active.dataset.var;
|
||
if (key) applyPreviewActiveHighlight(key);
|
||
}
|
||
}
|
||
|
||
function paintPreview(): void {
|
||
const host = document.getElementById("submission-draft-preview");
|
||
if (!host || !state.view) return;
|
||
host.innerHTML = state.view.preview_html ?? "";
|
||
wireDraftVars(host);
|
||
// t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
|
||
// so any prior --active classes are gone. Re-apply for whichever
|
||
// sidebar field is currently focused (typing in a field triggers an
|
||
// autosave round-trip that ends in paintPreview, and the user should
|
||
// see the highlight stay put across that cycle).
|
||
const active = document.activeElement;
|
||
if (isVarField(active)) {
|
||
const key = active.dataset.var;
|
||
if (key) applyPreviewActiveHighlight(key);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// t-paliad-313 Composer Slice A — base picker + section list
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function loadBases(): Promise<void> {
|
||
const res = await fetch("/api/submission-bases", { credentials: "include" });
|
||
if (!res.ok) {
|
||
throw new Error("base list HTTP " + res.status);
|
||
}
|
||
const body = await res.json() as { bases?: SubmissionBaseRow[] };
|
||
state.bases = body.bases ?? [];
|
||
state.basesLoaded = true;
|
||
// If the view has already painted, re-paint the picker so it
|
||
// hydrates as soon as the catalog lands. paint() is idempotent.
|
||
if (state.view) paintBasePicker();
|
||
}
|
||
|
||
function paintBasePicker(): void {
|
||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||
if (!row || !sel || !state.view) return;
|
||
|
||
// Hide the picker until the catalog has loaded AND the catalog has
|
||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||
if (!state.basesLoaded || state.bases.length === 0) {
|
||
row.style.display = "none";
|
||
return;
|
||
}
|
||
row.style.display = "";
|
||
|
||
// Rebuild the <option> list each paint so language toggles + base
|
||
// catalog updates flow through.
|
||
sel.innerHTML = "";
|
||
const currentBaseID = state.view.draft.base_id ?? "";
|
||
|
||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||
// to clear after they've already picked one.
|
||
if (!currentBaseID) {
|
||
const opt = document.createElement("option");
|
||
opt.value = "";
|
||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||
sel.appendChild(opt);
|
||
}
|
||
for (const b of state.bases) {
|
||
const opt = document.createElement("option");
|
||
opt.value = b.id;
|
||
opt.textContent = isEN() ? b.label_en : b.label_de;
|
||
if (b.id === currentBaseID) opt.selected = true;
|
||
sel.appendChild(opt);
|
||
}
|
||
|
||
// Wire change handler once per paint. Removing then re-adding
|
||
// keeps the binding consistent across repaints (e.g. after
|
||
// language toggle re-renders the labels).
|
||
sel.onchange = () => { onBaseChange(sel.value); };
|
||
}
|
||
|
||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||
if (!state.view) return;
|
||
const payload: Record<string, unknown> = {
|
||
// Empty string in the picker maps to null = clear.
|
||
base_id: newBaseID === "" ? null : newBaseID,
|
||
};
|
||
try {
|
||
const res = await fetch(
|
||
`/api/submission-drafts/${state.view.draft.id}`,
|
||
{
|
||
method: "PATCH",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
console.warn("base swap PATCH failed", res.status);
|
||
return;
|
||
}
|
||
const view = await res.json() as SubmissionDraftView;
|
||
state.view = view;
|
||
paint();
|
||
} catch (err) {
|
||
console.warn("base swap PATCH error", err);
|
||
}
|
||
}
|
||
|
||
// sectionAutosaveTimers — one debounce timer per section id so two
|
||
// sections autosaving simultaneously don't trample each other. Reset
|
||
// on each keystroke; 500ms after the last keystroke the patch fires.
|
||
const sectionAutosaveTimers: Record<string, number> = {};
|
||
const SECTION_AUTOSAVE_MS = 500;
|
||
|
||
function paintSectionList(): void {
|
||
const wrap = document.getElementById("submission-draft-sections-wrap");
|
||
const list = document.getElementById("submission-draft-sections-list") as HTMLOListElement | null;
|
||
if (!wrap || !list || !state.view) return;
|
||
|
||
const sections = state.view.sections ?? [];
|
||
if (sections.length === 0) {
|
||
wrap.style.display = "none";
|
||
return;
|
||
}
|
||
wrap.style.display = "";
|
||
|
||
// Don't blow away the editor if a section is currently focused —
|
||
// would steal cursor + selection mid-type. The patch round-trip
|
||
// returns the updated row, but paintSectionList only re-renders
|
||
// when the focused section isn't being edited (or the new render
|
||
// is being driven by something other than the active editor itself).
|
||
const activeID = activeSectionEditorID();
|
||
|
||
list.innerHTML = "";
|
||
const lang = state.view.draft.language || state.view.lang || "de";
|
||
for (const sec of sections) {
|
||
list.appendChild(renderSectionRow(sec, lang, activeID === sec.id));
|
||
}
|
||
}
|
||
|
||
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
|
||
const li = document.createElement("li");
|
||
li.className = "submission-draft-section";
|
||
li.dataset.sectionId = sec.id;
|
||
if (!sec.included) li.classList.add("submission-draft-section--excluded");
|
||
|
||
const head = document.createElement("header");
|
||
head.className = "submission-draft-section-head";
|
||
|
||
const title = document.createElement("h3");
|
||
title.className = "submission-draft-section-title";
|
||
title.textContent = (lang === "en" ? sec.label_en : sec.label_de) || sec.section_key;
|
||
head.appendChild(title);
|
||
|
||
const kind = document.createElement("span");
|
||
kind.className = "submission-draft-section-kind";
|
||
kind.textContent = sec.kind;
|
||
head.appendChild(kind);
|
||
|
||
if (!sec.included) {
|
||
const muted = document.createElement("span");
|
||
muted.className = "submission-draft-section-excluded-badge";
|
||
muted.textContent = isEN() ? "excluded" : "ausgeblendet";
|
||
head.appendChild(muted);
|
||
}
|
||
|
||
// Per-section "Aufnehmen" / "Ausblenden" toggle in the head — flips
|
||
// `included` via PATCH and re-paints.
|
||
const toggle = document.createElement("button");
|
||
toggle.type = "button";
|
||
toggle.className = "btn-small btn-secondary submission-draft-section-toggle";
|
||
toggle.textContent = sec.included
|
||
? (isEN() ? "Hide" : "Ausblenden")
|
||
: (isEN() ? "Include" : "Aufnehmen");
|
||
toggle.addEventListener("click", () => onSectionToggleIncluded(sec));
|
||
head.appendChild(toggle);
|
||
|
||
li.appendChild(head);
|
||
|
||
// Toolbar — shared B/I affordance per section. Slice D extends with
|
||
// headings, lists, quote.
|
||
const toolbar = document.createElement("div");
|
||
toolbar.className = "submission-draft-section-toolbar";
|
||
toolbar.appendChild(makeToolbarButton("B", isEN() ? "Bold" : "Fett", "bold"));
|
||
toolbar.appendChild(makeToolbarButton("I", isEN() ? "Italic" : "Kursiv", "italic"));
|
||
// t-paliad-315 Slice C — building-block insert button. Opens a
|
||
// picker modal filtered to this section's section_key. Paste is
|
||
// plain-text per Q2 (no lineage stamped).
|
||
const bbBtn = document.createElement("button");
|
||
bbBtn.type = "button";
|
||
bbBtn.className = "btn-small btn-secondary submission-draft-section-bb-btn";
|
||
bbBtn.textContent = isEN() ? "+ Block" : "+ Baustein";
|
||
bbBtn.title = isEN() ? "Insert a saved building block" : "Baustein einfügen";
|
||
bbBtn.addEventListener("click", () => openBlockPicker(sec));
|
||
toolbar.appendChild(bbBtn);
|
||
li.appendChild(toolbar);
|
||
|
||
const md = (lang === "en" ? sec.content_md_en : sec.content_md_de) || "";
|
||
const editor = document.createElement("div");
|
||
editor.className = "submission-draft-section-editor";
|
||
editor.contentEditable = "true";
|
||
editor.spellcheck = true;
|
||
editor.dataset.sectionId = sec.id;
|
||
editor.dataset.lang = lang;
|
||
editor.dataset.placeholder = isEN()
|
||
? "Write section content…"
|
||
: "Abschnittstext eingeben…";
|
||
// Paint the Markdown as plain text on first render — the editor's
|
||
// source of truth is Markdown, the DOM is the view. Lawyer types,
|
||
// we serialise back to MD on autosave.
|
||
editor.textContent = md;
|
||
|
||
editor.addEventListener("input", () => onSectionInput(editor));
|
||
editor.addEventListener("focus", () => {
|
||
li.classList.add("submission-draft-section--editing");
|
||
});
|
||
editor.addEventListener("blur", () => {
|
||
li.classList.remove("submission-draft-section--editing");
|
||
// Force-flush any pending autosave so we don't leave unsynced
|
||
// edits hanging when the lawyer tabs out.
|
||
flushSectionAutosave(sec.id);
|
||
});
|
||
|
||
li.appendChild(editor);
|
||
|
||
if (isActive) {
|
||
// The repaint happened while this section was focused — restore
|
||
// focus to it. Cursor placement at the end is a fair default
|
||
// (typing mid-content during a repaint is rare; the autosave path
|
||
// typically doesn't repaint at all).
|
||
queueMicrotask(() => {
|
||
const fresh = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sec.id)}"]`) as HTMLDivElement | null;
|
||
if (fresh) {
|
||
fresh.focus();
|
||
placeCaretAtEnd(fresh);
|
||
}
|
||
});
|
||
}
|
||
|
||
return li;
|
||
}
|
||
|
||
function makeToolbarButton(label: string, title: string, format: "bold" | "italic"): HTMLButtonElement {
|
||
const btn = document.createElement("button");
|
||
btn.type = "button";
|
||
btn.className = "submission-draft-section-toolbar-btn";
|
||
btn.textContent = label;
|
||
btn.title = title;
|
||
// Mousedown rather than click so the editor doesn't lose focus
|
||
// mid-command — execCommand requires the editor to be the active
|
||
// selection target.
|
||
btn.addEventListener("mousedown", (ev) => {
|
||
ev.preventDefault();
|
||
document.execCommand(format, false);
|
||
// Trigger the input handler so autosave fires.
|
||
const editor = document.activeElement as HTMLElement | null;
|
||
if (editor && editor.classList.contains("submission-draft-section-editor")) {
|
||
onSectionInput(editor as HTMLDivElement);
|
||
}
|
||
});
|
||
return btn;
|
||
}
|
||
|
||
function activeSectionEditorID(): string | null {
|
||
const active = document.activeElement as HTMLElement | null;
|
||
if (!active || !active.classList.contains("submission-draft-section-editor")) return null;
|
||
return active.dataset.sectionId ?? null;
|
||
}
|
||
|
||
function placeCaretAtEnd(el: HTMLElement): void {
|
||
const range = document.createRange();
|
||
range.selectNodeContents(el);
|
||
range.collapse(false);
|
||
const sel = window.getSelection();
|
||
if (!sel) return;
|
||
sel.removeAllRanges();
|
||
sel.addRange(range);
|
||
}
|
||
|
||
function onSectionInput(editor: HTMLDivElement): void {
|
||
const id = editor.dataset.sectionId;
|
||
if (!id) return;
|
||
if (sectionAutosaveTimers[id]) clearTimeout(sectionAutosaveTimers[id]);
|
||
sectionAutosaveTimers[id] = window.setTimeout(() => {
|
||
sectionAutosaveTimers[id] = 0;
|
||
flushSectionAutosave(id);
|
||
}, SECTION_AUTOSAVE_MS);
|
||
}
|
||
|
||
function flushSectionAutosave(sectionID: string): void {
|
||
if (sectionAutosaveTimers[sectionID]) {
|
||
clearTimeout(sectionAutosaveTimers[sectionID]);
|
||
sectionAutosaveTimers[sectionID] = 0;
|
||
}
|
||
const editor = document.querySelector(`.submission-draft-section-editor[data-section-id="${cssEscape(sectionID)}"]`) as HTMLDivElement | null;
|
||
if (!editor || !state.view) return;
|
||
const lang = editor.dataset.lang || state.view.draft.language || "de";
|
||
const md = domToMarkdown(editor);
|
||
void patchSection(sectionID, lang === "en" ? { content_md_en: md } : { content_md_de: md });
|
||
}
|
||
|
||
// domToMarkdown serialises a contentEditable's DOM tree back to
|
||
// Markdown. Walks the tree: <b>/<strong> emit `**…**`, <i>/<em> emit
|
||
// `*…*`, <br> emits a newline, block-level elements emit a blank line
|
||
// between siblings. Slice B handles only B/I + paragraphs/line breaks
|
||
// — Slice D's rich toolbar extends this to headings + lists + quote.
|
||
function domToMarkdown(root: HTMLElement): string {
|
||
return serializeNode(root).trim();
|
||
}
|
||
|
||
function serializeNode(node: Node): string {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
return node.textContent ?? "";
|
||
}
|
||
if (node.nodeType !== Node.ELEMENT_NODE) return "";
|
||
const el = node as HTMLElement;
|
||
const tag = el.tagName.toLowerCase();
|
||
let inner = "";
|
||
for (const child of Array.from(el.childNodes)) {
|
||
inner += serializeNode(child);
|
||
}
|
||
switch (tag) {
|
||
case "b":
|
||
case "strong":
|
||
return inner ? `**${inner}**` : "";
|
||
case "i":
|
||
case "em":
|
||
return inner ? `*${inner}*` : "";
|
||
case "br":
|
||
return "\n";
|
||
case "div":
|
||
case "p":
|
||
// execCommand and contentEditable insert <div> on Enter in some
|
||
// browsers, <p> in others. Both are paragraph boundaries.
|
||
return inner + "\n\n";
|
||
default:
|
||
return inner;
|
||
}
|
||
}
|
||
|
||
async function onSectionToggleIncluded(sec: SubmissionSectionJSON): Promise<void> {
|
||
await patchSection(sec.id, { included: !sec.included });
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// t-paliad-315 Slice C — building-block picker modal
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface BuildingBlockPickJSON {
|
||
id: string;
|
||
slug: string;
|
||
section_key: string;
|
||
proceeding_family?: string | null;
|
||
title_de: string;
|
||
title_en: string;
|
||
description_de?: string | null;
|
||
description_en?: string | null;
|
||
content_md_de: string;
|
||
content_md_en: string;
|
||
visibility: string;
|
||
}
|
||
|
||
let blockPickerSearchTimer: number | null = null;
|
||
|
||
function openBlockPicker(sec: SubmissionSectionJSON): void {
|
||
// Remove any prior picker.
|
||
document.getElementById("submission-bb-picker")?.remove();
|
||
|
||
const overlay = document.createElement("div");
|
||
overlay.id = "submission-bb-picker";
|
||
overlay.className = "submission-bb-picker-overlay";
|
||
overlay.addEventListener("click", (ev) => {
|
||
if (ev.target === overlay) overlay.remove();
|
||
});
|
||
|
||
const modal = document.createElement("div");
|
||
modal.className = "submission-bb-picker";
|
||
|
||
const head = document.createElement("header");
|
||
head.className = "submission-bb-picker-head";
|
||
const title = document.createElement("h2");
|
||
title.textContent = isEN() ? "Insert building block" : "Baustein einfügen";
|
||
head.appendChild(title);
|
||
const close = document.createElement("button");
|
||
close.type = "button";
|
||
close.className = "btn-small btn-secondary";
|
||
close.textContent = isEN() ? "Close" : "Schließen";
|
||
close.addEventListener("click", () => overlay.remove());
|
||
head.appendChild(close);
|
||
modal.appendChild(head);
|
||
|
||
const search = document.createElement("input");
|
||
search.type = "search";
|
||
search.placeholder = isEN() ? "Search blocks…" : "Bausteine suchen…";
|
||
search.className = "entity-form-input submission-bb-picker-search";
|
||
modal.appendChild(search);
|
||
|
||
const sectionInfo = document.createElement("p");
|
||
sectionInfo.className = "submission-bb-picker-sectioninfo";
|
||
sectionInfo.textContent = (isEN() ? "Section: " : "Abschnitt: ") + sec.section_key;
|
||
modal.appendChild(sectionInfo);
|
||
|
||
const list = document.createElement("div");
|
||
list.className = "submission-bb-picker-list";
|
||
list.textContent = isEN() ? "Loading…" : "Lädt…";
|
||
modal.appendChild(list);
|
||
|
||
overlay.appendChild(modal);
|
||
document.body.appendChild(overlay);
|
||
|
||
const fetchBlocks = async (q: string) => {
|
||
const params = new URLSearchParams();
|
||
params.set("section_key", sec.section_key);
|
||
if (q) params.set("q", q);
|
||
try {
|
||
const res = await fetch(`/api/submission-building-blocks?${params.toString()}`, { credentials: "include" });
|
||
if (!res.ok) {
|
||
list.textContent = `HTTP ${res.status}`;
|
||
return;
|
||
}
|
||
const body = await res.json() as { blocks?: BuildingBlockPickJSON[] };
|
||
paintPickerList(list, body.blocks ?? [], sec, overlay);
|
||
} catch (err) {
|
||
list.textContent = String(err);
|
||
}
|
||
};
|
||
|
||
search.addEventListener("input", () => {
|
||
if (blockPickerSearchTimer) clearTimeout(blockPickerSearchTimer);
|
||
blockPickerSearchTimer = window.setTimeout(() => {
|
||
void fetchBlocks(search.value.trim());
|
||
}, 200);
|
||
});
|
||
|
||
void fetchBlocks("");
|
||
setTimeout(() => search.focus(), 0);
|
||
}
|
||
|
||
function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec: SubmissionSectionJSON, overlay: HTMLElement): void {
|
||
host.innerHTML = "";
|
||
if (blocks.length === 0) {
|
||
const empty = document.createElement("p");
|
||
empty.className = "submission-bb-picker-empty";
|
||
empty.textContent = isEN() ? "No blocks match." : "Keine passenden Bausteine.";
|
||
host.appendChild(empty);
|
||
return;
|
||
}
|
||
const lang = state.view?.draft.language || "de";
|
||
for (const b of blocks) {
|
||
const row = document.createElement("button");
|
||
row.type = "button";
|
||
row.className = "submission-bb-picker-row";
|
||
const title = (lang === "en" ? b.title_en : b.title_de) || b.slug;
|
||
const desc = (lang === "en" ? b.description_en : b.description_de) || "";
|
||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||
row.innerHTML = `
|
||
<div class="submission-bb-picker-row-head">
|
||
<strong>${escapeHTML(title)}</strong>
|
||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||
</div>
|
||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||
row.addEventListener("click", () => {
|
||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||
});
|
||
host.appendChild(row);
|
||
}
|
||
}
|
||
|
||
async function insertBlockIntoSection(blockID: string, sectionID: string, overlay: HTMLElement): Promise<void> {
|
||
try {
|
||
const res = await fetch(
|
||
`/api/submission-building-blocks/${blockID}/insert-into/${sectionID}`,
|
||
{ method: "POST", credentials: "include" },
|
||
);
|
||
if (!res.ok) {
|
||
console.warn("insert-into PATCH failed", res.status);
|
||
return;
|
||
}
|
||
const updated = await res.json() as SubmissionSectionJSON;
|
||
if (state.view && state.view.sections) {
|
||
const idx = state.view.sections.findIndex(s => s.id === sectionID);
|
||
if (idx >= 0) state.view.sections[idx] = updated;
|
||
}
|
||
paintSectionList();
|
||
overlay.remove();
|
||
} catch (err) {
|
||
console.warn("insert block error", err);
|
||
}
|
||
}
|
||
|
||
function escapeHTML(s: string): string {
|
||
return s
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """)
|
||
.replace(/'/g, "'");
|
||
}
|
||
|
||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||
try {
|
||
const draftID = state.view?.draft.id;
|
||
if (!draftID) return;
|
||
const res = await fetch(
|
||
`/api/submission-drafts/${draftID}/sections/${sectionID}`,
|
||
{
|
||
method: "PATCH",
|
||
credentials: "include",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(payload),
|
||
},
|
||
);
|
||
if (!res.ok) {
|
||
console.warn("section PATCH failed", res.status, sectionID);
|
||
return;
|
||
}
|
||
const updated = await res.json() as SubmissionSectionJSON;
|
||
// Splice the updated row into state.view.sections. Don't re-paint
|
||
// unless we need to (avoid focus stealing during active typing).
|
||
if (state.view && state.view.sections) {
|
||
const idx = state.view.sections.findIndex(s => s.id === sectionID);
|
||
if (idx >= 0) state.view.sections[idx] = updated;
|
||
}
|
||
// Only repaint when the change has visible UI knock-on (toggle,
|
||
// label, order). content_md_* changes don't need a repaint —
|
||
// the editor already shows the lawyer's keystrokes.
|
||
if ("included" in payload || "label_de" in payload || "label_en" in payload || "order_index" in payload) {
|
||
paintSectionList();
|
||
}
|
||
} catch (err) {
|
||
console.warn("section PATCH error", err);
|
||
}
|
||
}
|
||
|
||
// t-paliad-261 (B) — click a substituted variable in the preview to
|
||
// jump to the matching sidebar input. Re-wires on every paintPreview
|
||
// since the preview HTML is replaced wholesale. The server side wraps
|
||
// each substituted placeholder (resolved OR missing marker) in
|
||
// <span class="draft-var" data-var="<key>">…</span>; clicks here scroll
|
||
// the corresponding input into view, focus + select, and flash the row.
|
||
// If the key has no matching sidebar input (derived variables not
|
||
// exposed in VARIABLE_GROUPS), the click is a silent no-op — the span
|
||
// is still rendered so the user gets the visible hint that this is a
|
||
// resolved variable.
|
||
function wireDraftVars(previewHost: HTMLElement): void {
|
||
previewHost.querySelectorAll<HTMLElement>(".draft-var").forEach((el) => {
|
||
const key = el.dataset.var;
|
||
if (!key) return;
|
||
if (findVarInput(key)) {
|
||
el.classList.add("draft-var--has-input");
|
||
el.setAttribute("role", "button");
|
||
el.setAttribute("tabindex", "0");
|
||
el.setAttribute(
|
||
"aria-label",
|
||
(isEN() ? "Edit variable " : "Variable bearbeiten: ") + labelFor(key),
|
||
);
|
||
}
|
||
el.addEventListener("click", (ev) => onDraftVarClick(key, ev));
|
||
el.addEventListener("keydown", (ev) => {
|
||
if (ev.key === "Enter" || ev.key === " ") {
|
||
ev.preventDefault();
|
||
onDraftVarClick(key, ev);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function findVarInput(key: string): HTMLInputElement | null {
|
||
const host = document.getElementById("submission-draft-variables");
|
||
if (!host) return null;
|
||
return host.querySelector<HTMLInputElement>(
|
||
`.submission-draft-var-input[data-var="${cssEscape(key)}"]`,
|
||
);
|
||
}
|
||
|
||
function cssEscape(s: string): string {
|
||
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
|
||
// older browsers may lack it; defensive fallback escapes characters
|
||
// CSS treats as special. Placeholder keys never carry whitespace or
|
||
// quotes so escaping is straightforward.
|
||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||
return CSS.escape(s);
|
||
}
|
||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||
}
|
||
|
||
function onDraftVarClick(key: string, ev: Event): void {
|
||
const input = findVarInput(key);
|
||
if (!input) return;
|
||
ev.preventDefault();
|
||
ev.stopPropagation();
|
||
// Smooth-scroll the input into view, then focus on the next tick so
|
||
// the scroll animation has started and the focus call doesn't trigger
|
||
// a second jarring jump.
|
||
input.scrollIntoView({ behavior: "smooth", block: "center" });
|
||
window.setTimeout(() => {
|
||
input.focus();
|
||
try {
|
||
input.select();
|
||
} catch {
|
||
/* select() throws on number/email inputs; safe to ignore */
|
||
}
|
||
}, 50);
|
||
flashVarRow(input);
|
||
}
|
||
|
||
// t-paliad-274 (B) — sidebar-field-focus → preview-occurrence highlight.
|
||
// Reverse direction of the click-to-jump from #92: when the user focuses
|
||
// any .submission-draft-var-input, every matching .draft-var span in the
|
||
// preview gets the --active modifier; on blur (or focus shift to a
|
||
// different field), the previous key's highlights clear and the new
|
||
// key's apply. Sticky-while-focused, not a one-shot flash — the lawyer
|
||
// can scan the preview for "where does this variable land in my prose?"
|
||
// while the field stays focused.
|
||
function onVarFocusEnter(key: string): void {
|
||
if (!key) return;
|
||
// Clear any leftover highlight before applying the new one — covers
|
||
// the focus-shift-without-blur case (Tab between fields).
|
||
clearPreviewActiveHighlight();
|
||
applyPreviewActiveHighlight(key);
|
||
}
|
||
|
||
function onVarFocusLeave(_key: string): void {
|
||
// We don't need the key here — if focus moves to a different sidebar
|
||
// input, that input's focusin will re-call apply with the new key
|
||
// (after our clearPreviewActiveHighlight). If focus leaves the sidebar
|
||
// entirely, this clears.
|
||
clearPreviewActiveHighlight();
|
||
}
|
||
|
||
function applyPreviewActiveHighlight(key: string): void {
|
||
const host = document.getElementById("submission-draft-preview");
|
||
if (!host) return;
|
||
host.querySelectorAll<HTMLElement>(
|
||
`.draft-var[data-var="${cssEscape(key)}"]`,
|
||
).forEach((el) => {
|
||
el.classList.add("draft-var--active");
|
||
});
|
||
}
|
||
|
||
function clearPreviewActiveHighlight(): void {
|
||
const host = document.getElementById("submission-draft-preview");
|
||
if (!host) return;
|
||
host.querySelectorAll<HTMLElement>(".draft-var--active").forEach((el) => {
|
||
el.classList.remove("draft-var--active");
|
||
});
|
||
}
|
||
|
||
function flashVarRow(input: HTMLElement): void {
|
||
const row = input.closest<HTMLElement>(".submission-draft-var-row");
|
||
if (!row) return;
|
||
row.classList.remove("submission-draft-var-row--flash");
|
||
// Force reflow so removing+re-adding the class restarts the animation
|
||
// even on rapid successive clicks.
|
||
void row.offsetWidth;
|
||
row.classList.add("submission-draft-var-row--flash");
|
||
window.setTimeout(() => row.classList.remove("submission-draft-var-row--flash"), 1200);
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Event handlers
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
async function onPartySelectionChange(): Promise<void> {
|
||
if (!state.view) return;
|
||
const host = document.getElementById("submission-draft-parties-list");
|
||
if (!host) return;
|
||
const checks = host.querySelectorAll<HTMLInputElement>(".submission-draft-party-check");
|
||
const selectedIDs: string[] = [];
|
||
checks.forEach((c) => {
|
||
if (c.checked && c.dataset.partyId) selectedIDs.push(c.dataset.partyId);
|
||
});
|
||
|
||
// If the lawyer has checked every party, persist that as an empty
|
||
// array so the row matches the "implicit all" default semantics — a
|
||
// future party added to the project will then be picked up
|
||
// automatically rather than silently dropped from this submission.
|
||
// If they've unchecked some, persist the actual subset.
|
||
const available = state.view.available_parties ?? [];
|
||
const allChecked = selectedIDs.length === available.length;
|
||
const payload = allChecked ? [] : selectedIDs;
|
||
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
try {
|
||
const view = await patchDraft({ selected_parties: payload });
|
||
state.view = view;
|
||
paintImportRow();
|
||
paintPartyPicker();
|
||
paintVariables();
|
||
paintPreview();
|
||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError") return;
|
||
console.error("submission-draft party selection:", err);
|
||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
async function runPartySearch(query: string): Promise<void> {
|
||
try {
|
||
const params = new URLSearchParams();
|
||
if (query) params.set("q", query);
|
||
const resp = await fetch(`/api/parties/search?${params.toString()}`);
|
||
if (!resp.ok) throw new Error(`search ${resp.status}`);
|
||
const data = (await resp.json()) as { results: PartySearchHit[] };
|
||
// Filter out parties already on THIS project — picking one of them
|
||
// would be a no-op clone that doubles the row.
|
||
const existingIDs = new Set(
|
||
(state.view?.available_parties ?? []).map((p) => p.id),
|
||
);
|
||
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
|
||
|
||
// Refresh ONLY the results <ul> in place — repainting the whole
|
||
// picker would steal focus from the search input on every
|
||
// keystroke. The input keeps its value/selection and the lawyer
|
||
// can keep typing.
|
||
const ul = document.querySelector<HTMLUListElement>(
|
||
".submission-draft-addparty-search-results",
|
||
);
|
||
if (ul) {
|
||
ul.outerHTML = renderPartySearchResultsList();
|
||
const fresh = document.querySelector<HTMLUListElement>(
|
||
".submission-draft-addparty-search-results",
|
||
);
|
||
if (fresh) {
|
||
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
||
li.addEventListener("click", () => {
|
||
const hitID = li.dataset.hitId;
|
||
if (!hitID) return;
|
||
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
||
if (!hit) return;
|
||
const side = state.addPartyOpen ?? "other";
|
||
void onAddPartySearchPick(side, hit);
|
||
});
|
||
});
|
||
}
|
||
} else {
|
||
// First load (panel just opened) — full picker paint to wire up
|
||
// every control. Subsequent keystroke updates take the cheaper
|
||
// path above.
|
||
paintPartyPicker();
|
||
}
|
||
} catch (err) {
|
||
console.error("submission-draft party-search:", err);
|
||
}
|
||
}
|
||
|
||
function renderPartySearchResultsList(): string {
|
||
let html = `<ul class="submission-draft-addparty-search-results">`;
|
||
if (state.addPartySearchHits.length === 0) {
|
||
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
|
||
isEN() ? "No matches." : "Keine Treffer.",
|
||
)}</li>`;
|
||
} else {
|
||
for (const hit of state.addPartySearchHits) {
|
||
const ref = hit.project_reference
|
||
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
|
||
: "";
|
||
const role = hit.role
|
||
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
|
||
: "";
|
||
const rep = hit.representative
|
||
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
|
||
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
|
||
)}</span>`
|
||
: "";
|
||
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
|
||
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
|
||
html += role;
|
||
html += rep;
|
||
html += `<span class="submission-draft-addparty-search-projwrap">`;
|
||
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
|
||
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
|
||
html += ref;
|
||
html += `</span>`;
|
||
html += `</li>`;
|
||
}
|
||
}
|
||
html += `</ul>`;
|
||
return html;
|
||
}
|
||
|
||
async function onAddPartyManualSubmit(
|
||
side: PartySide,
|
||
payload: { name: string; role: string; representative: string },
|
||
): Promise<void> {
|
||
if (!state.view) return;
|
||
const projectID = state.view.draft.project_id;
|
||
if (!projectID) return;
|
||
// Disable the submit button in-place rather than repainting the form
|
||
// mid-flight (a repaint would blow away the lawyer's typed values on
|
||
// error and reset focus). The post-success/-error repaint runs once
|
||
// the call settles.
|
||
const submitBtn = document.querySelector<HTMLButtonElement>(
|
||
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
|
||
);
|
||
if (submitBtn) submitBtn.disabled = true;
|
||
state.addPartyBusy = true;
|
||
try {
|
||
const body: Record<string, unknown> = { name: payload.name };
|
||
if (payload.role) body.role = payload.role;
|
||
if (payload.representative) body.representative = payload.representative;
|
||
const resp = await fetch(`/api/projects/${projectID}/parties`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(body),
|
||
});
|
||
if (!resp.ok) throw new Error(`create party ${resp.status}`);
|
||
const created = (await resp.json()) as { id: string };
|
||
await refreshDraftViewAndSelect(created.id);
|
||
state.addPartyOpen = null;
|
||
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
|
||
state.addPartyBusy = false;
|
||
paintPartyPicker();
|
||
} catch (err) {
|
||
console.error("submission-draft add-party manual:", err);
|
||
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
|
||
if (submitBtn) submitBtn.disabled = false;
|
||
state.addPartyBusy = false;
|
||
}
|
||
}
|
||
|
||
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
|
||
// DB picks clone the row into the current project — the simplest
|
||
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
|
||
// The lawyer asked for "no manual re-typing"; this honours that
|
||
// without bending the data model.
|
||
await onAddPartyManualSubmit(side, {
|
||
name: hit.name,
|
||
role: hit.role ?? defaultRoleFor(side),
|
||
representative: hit.representative ?? "",
|
||
});
|
||
}
|
||
|
||
// refreshDraftViewAndSelect refetches the editor payload (so
|
||
// available_parties picks up the new row) and ensures the newly-added
|
||
// party is checked in selected_parties. If the lawyer was on the
|
||
// implicit-all default (empty selected_parties), the new party comes
|
||
// in pre-selected via the "empty=all" rule and no PATCH is needed.
|
||
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
|
||
if (!state.view) return;
|
||
const draftID = state.view.draft.id;
|
||
const view = state.view.draft.project_id
|
||
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
|
||
: await fetchGlobalView(draftID);
|
||
state.view = view;
|
||
|
||
// If the previous draft had a non-empty selected_parties subset,
|
||
// explicitly add the new party so it isn't silently dropped from the
|
||
// submission. Empty selected_parties = "all" → no PATCH needed.
|
||
const currentSel = state.view.draft.selected_parties ?? [];
|
||
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
|
||
const next = [...currentSel, newPartyID];
|
||
try {
|
||
const patched = await patchDraft({ selected_parties: next });
|
||
state.view = patched;
|
||
} catch (err) {
|
||
console.error("submission-draft select new party:", err);
|
||
}
|
||
}
|
||
|
||
paintImportRow();
|
||
paintPartyPicker();
|
||
paintVariables();
|
||
paintPreview();
|
||
}
|
||
|
||
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
|
||
if (!state.view) return;
|
||
const draftID = state.view.draft.id;
|
||
const originalLabel = btn.textContent ?? "";
|
||
btn.disabled = true;
|
||
btn.textContent = isEN() ? "Importing…" : "Importiert…";
|
||
setSaveStatus(isEN() ? "Importing from project…" : "Importiere aus Projekt…");
|
||
try {
|
||
const resp = await fetch(`/api/submission-drafts/${draftID}/import-from-project`, {
|
||
method: "POST",
|
||
});
|
||
if (!resp.ok) throw new Error(`import ${resp.status}`);
|
||
const view = (await resp.json()) as SubmissionDraftView;
|
||
state.view = view;
|
||
paintImportRow();
|
||
paintPartyPicker();
|
||
paintVariables();
|
||
paintPreview();
|
||
setSaveStatus(isEN() ? "Imported" : "Importiert");
|
||
} catch (err) {
|
||
console.error("submission-draft import-from-project:", err);
|
||
setSaveStatus(isEN() ? "Import failed" : "Import fehlgeschlagen", true);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = originalLabel;
|
||
}
|
||
}
|
||
|
||
function onVarChange(input: HTMLInputElement): void {
|
||
const key = input.dataset.var;
|
||
if (!key || !state.view) return;
|
||
// Stage the override on the draft view so paintPreview reflects.
|
||
const overrides = { ...state.view.draft.variables };
|
||
overrides[key] = input.value;
|
||
state.view.draft.variables = overrides;
|
||
state.pendingOverrides = overrides;
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||
state.saveTimer = window.setTimeout(() => {
|
||
void flushAutosave();
|
||
}, 500);
|
||
}
|
||
|
||
function onVarReset(key: string): void {
|
||
if (!state.view) return;
|
||
const overrides = { ...state.view.draft.variables };
|
||
delete overrides[key];
|
||
state.view.draft.variables = overrides;
|
||
state.pendingOverrides = overrides;
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||
state.saveTimer = window.setTimeout(() => {
|
||
void flushAutosave();
|
||
}, 500);
|
||
}
|
||
|
||
async function flushAutosave(): Promise<void> {
|
||
if (!state.pendingOverrides) return;
|
||
const payload = { variables: state.pendingOverrides };
|
||
state.pendingOverrides = null;
|
||
// t-paliad-261 (A) — paintVariables() below replaces every input in
|
||
// the sidebar via innerHTML, which blows away the active-element
|
||
// reference. Capture the focused input's key + selection range before
|
||
// the repaint and restore on the new element after, so the user can
|
||
// keep typing without clicking back into the field.
|
||
const focusSnap = captureVarFocus();
|
||
try {
|
||
const view = await patchDraft(payload);
|
||
state.view = view;
|
||
paintVariables();
|
||
paintPreview();
|
||
restoreVarFocus(focusSnap);
|
||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError") return;
|
||
console.error("submission-draft autosave:", err);
|
||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
// captureVarFocus / restoreVarFocus — focus-preservation across the
|
||
// paintVariables() innerHTML-replace cycle (t-paliad-261 part A).
|
||
// Tracks selection start/end/direction so the cursor lands exactly
|
||
// where it was before the repaint, including any active selection
|
||
// range. Handles both <input> and <textarea> via the shared
|
||
// HTMLInputElement|HTMLTextAreaElement contract for selectionStart /
|
||
// selectionEnd / selectionDirection / setSelectionRange.
|
||
|
||
interface VarFocusSnapshot {
|
||
key: string;
|
||
start: number | null;
|
||
end: number | null;
|
||
dir: "forward" | "backward" | "none";
|
||
}
|
||
|
||
type SelectableEl = HTMLInputElement | HTMLTextAreaElement;
|
||
|
||
function isVarField(el: Element | null): el is SelectableEl {
|
||
if (!el) return false;
|
||
if (!(el instanceof HTMLInputElement) && !(el instanceof HTMLTextAreaElement)) {
|
||
return false;
|
||
}
|
||
return el.classList.contains("submission-draft-var-input");
|
||
}
|
||
|
||
function captureVarFocus(): VarFocusSnapshot | null {
|
||
const active = document.activeElement;
|
||
if (!isVarField(active)) return null;
|
||
const key = active.dataset.var;
|
||
if (!key) return null;
|
||
return {
|
||
key,
|
||
start: active.selectionStart,
|
||
end: active.selectionEnd,
|
||
dir: (active.selectionDirection as "forward" | "backward" | "none" | null) ?? "forward",
|
||
};
|
||
}
|
||
|
||
function restoreVarFocus(snap: VarFocusSnapshot | null): void {
|
||
if (!snap) return;
|
||
const host = document.getElementById("submission-draft-variables");
|
||
if (!host) return;
|
||
const next = host.querySelector<SelectableEl>(
|
||
`.submission-draft-var-input[data-var="${cssEscape(snap.key)}"]`,
|
||
);
|
||
if (!next) return;
|
||
next.focus();
|
||
if (snap.start !== null && snap.end !== null) {
|
||
try {
|
||
next.setSelectionRange(snap.start, snap.end, snap.dir);
|
||
} catch {
|
||
/* setSelectionRange throws on inputs whose type doesn't support
|
||
selection ranges (number, email, etc.); safe to ignore — the
|
||
focus() call above is enough for those. */
|
||
}
|
||
}
|
||
}
|
||
|
||
async function renameDraft(newName: string): Promise<void> {
|
||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||
try {
|
||
const view = await patchDraft({ name: newName });
|
||
state.view = view;
|
||
// Refresh the draft list cache.
|
||
const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
|
||
if (idx >= 0) state.drafts[idx].name = view.draft.name;
|
||
paintSwitcher();
|
||
setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
|
||
} catch (err) {
|
||
if ((err as Error).name === "AbortError") return;
|
||
console.error("submission-draft rename:", err);
|
||
const msg = (err as Error).message?.includes("409")
|
||
? (isEN() ? "Name already in use" : "Name bereits vergeben")
|
||
: (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
|
||
setSaveStatus(msg, true);
|
||
}
|
||
}
|
||
|
||
async function onCreateNew(): Promise<void> {
|
||
const p = state.parsed;
|
||
// From a project-less draft, "Neuer Entwurf" can't auto-pick a
|
||
// (project, code) cross-section — kick the user out to the global
|
||
// picker instead.
|
||
if (!p.projectID || !p.submissionCode) {
|
||
window.location.href = "/submissions/new";
|
||
return;
|
||
}
|
||
try {
|
||
const fresh = await createProjectDraft(p);
|
||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
|
||
window.location.href = url;
|
||
} catch (err) {
|
||
console.error("submission-draft new:", err);
|
||
setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
async function onDelete(): Promise<void> {
|
||
if (!state.view) return;
|
||
const msg = isEN()
|
||
? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
|
||
: `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
|
||
if (!window.confirm(msg)) return;
|
||
try {
|
||
await deleteDraft();
|
||
const p = state.parsed;
|
||
const url = p.projectID && p.submissionCode
|
||
? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
|
||
: "/submissions";
|
||
window.location.href = url;
|
||
} catch (err) {
|
||
console.error("submission-draft delete:", err);
|
||
setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
async function onExport(btn: HTMLButtonElement): Promise<void> {
|
||
if (!state.view) return;
|
||
const p = state.parsed;
|
||
if (!p.draftID) return;
|
||
const originalLabel = btn.textContent ?? "";
|
||
btn.disabled = true;
|
||
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
|
||
try {
|
||
// Use the global export endpoint for both project-scoped and
|
||
// project-less drafts; the handler routes audit + project_events
|
||
// writes based on the draft row's project_id.
|
||
const url = `/api/submission-drafts/${p.draftID}/export`;
|
||
const resp = await fetch(url, { method: "POST" });
|
||
if (!resp.ok) {
|
||
let detail = "";
|
||
try {
|
||
const data = (await resp.json()) as { error?: string };
|
||
detail = data.error ?? "";
|
||
} catch { /* fallthrough */ }
|
||
alert((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
|
||
return;
|
||
}
|
||
const blob = await resp.blob();
|
||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
|
||
triggerDownload(blob, filename);
|
||
} finally {
|
||
btn.disabled = false;
|
||
btn.textContent = originalLabel;
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Project assign picker (project-less → project-scoped)
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
interface PickerProjectRow {
|
||
id: string;
|
||
title: string;
|
||
reference?: string | null;
|
||
}
|
||
|
||
let assignPickerProjects: PickerProjectRow[] = [];
|
||
let assignPickerLoaded = false;
|
||
|
||
function openProjectAssignPicker(): void {
|
||
ensureAssignPickerDOM();
|
||
const modal = document.getElementById("submission-draft-assign-modal");
|
||
if (modal) modal.style.display = "";
|
||
if (!assignPickerLoaded) {
|
||
void loadAssignPickerProjects();
|
||
} else {
|
||
renderAssignPickerList();
|
||
}
|
||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||
if (searchInput) {
|
||
searchInput.value = "";
|
||
setTimeout(() => searchInput.focus(), 50);
|
||
}
|
||
}
|
||
|
||
function closeProjectAssignPicker(): void {
|
||
const modal = document.getElementById("submission-draft-assign-modal");
|
||
if (modal) modal.style.display = "none";
|
||
}
|
||
|
||
function ensureAssignPickerDOM(): void {
|
||
if (document.getElementById("submission-draft-assign-modal")) return;
|
||
const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
|
||
const placeholder = isEN()
|
||
? "Search project (title or reference)…"
|
||
: "Projekt suchen (Titel oder Aktenzeichen)…";
|
||
const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
|
||
const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";
|
||
|
||
const modal = document.createElement("div");
|
||
modal.id = "submission-draft-assign-modal";
|
||
modal.className = "modal-overlay";
|
||
modal.setAttribute("role", "dialog");
|
||
modal.setAttribute("aria-modal", "true");
|
||
modal.style.display = "none";
|
||
modal.innerHTML = `
|
||
<div class="modal-card">
|
||
<header class="modal-header">
|
||
<h2>${escapeHtml(titleTxt)}</h2>
|
||
<button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
|
||
</header>
|
||
<div class="modal-body">
|
||
<input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
|
||
<ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
|
||
<p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
|
||
<p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
|
||
</div>
|
||
</div>`;
|
||
document.body.appendChild(modal);
|
||
|
||
modal.addEventListener("click", (e) => {
|
||
if (e.target === modal) closeProjectAssignPicker();
|
||
});
|
||
const closeBtn = document.getElementById("submission-draft-assign-close");
|
||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
|
||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||
if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
|
||
document.addEventListener("keydown", (e) => {
|
||
if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
|
||
});
|
||
}
|
||
|
||
async function loadAssignPickerProjects(): Promise<void> {
|
||
const loadingEl = document.getElementById("submission-draft-assign-loading");
|
||
if (loadingEl) loadingEl.style.display = "";
|
||
try {
|
||
const resp = await fetch("/api/projects?status=active");
|
||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||
const rows = (await resp.json()) as PickerProjectRow[];
|
||
assignPickerProjects = rows ?? [];
|
||
assignPickerLoaded = true;
|
||
} catch (err) {
|
||
console.error("submission-draft assignPicker:", err);
|
||
assignPickerProjects = [];
|
||
} finally {
|
||
if (loadingEl) loadingEl.style.display = "none";
|
||
}
|
||
renderAssignPickerList();
|
||
}
|
||
|
||
function renderAssignPickerList(): void {
|
||
const list = document.getElementById("submission-draft-assign-list");
|
||
const empty = document.getElementById("submission-draft-assign-empty");
|
||
if (!list || !empty) return;
|
||
|
||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||
|
||
const matches = assignPickerProjects.filter((p) => {
|
||
if (term === "") return true;
|
||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||
return hay.includes(term);
|
||
}).slice(0, 50);
|
||
|
||
if (matches.length === 0) {
|
||
list.innerHTML = "";
|
||
empty.style.display = "";
|
||
return;
|
||
}
|
||
empty.style.display = "none";
|
||
|
||
list.innerHTML = matches.map((p) => {
|
||
const ref = p.reference ? `<span class="entity-ref">${escapeHtml(p.reference)}</span> ` : "";
|
||
return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
|
||
}).join("");
|
||
|
||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||
li.addEventListener("click", () => {
|
||
const pid = li.dataset.id;
|
||
if (pid) void onAssignProject(pid);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function onAssignProject(projectID: string): Promise<void> {
|
||
closeProjectAssignPicker();
|
||
setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
|
||
try {
|
||
const view = await patchDraft({ project_id: projectID });
|
||
state.view = view;
|
||
setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
|
||
// Redirect to the project-scoped URL so the editor's URL matches the
|
||
// attached project and the project-scoped draft list (sidebar
|
||
// switcher) loads on refresh.
|
||
const code = view.draft.submission_code;
|
||
window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
|
||
} catch (err) {
|
||
console.error("submission-draft assign:", err);
|
||
setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
// Helpers
|
||
// ─────────────────────────────────────────────────────────────────────
|
||
|
||
function show(id: string): void {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = "";
|
||
}
|
||
|
||
function hide(id: string): void {
|
||
const el = document.getElementById(id);
|
||
if (el) el.style.display = "none";
|
||
}
|
||
|
||
function showNotFound(): void {
|
||
hide("submission-draft-loading");
|
||
hide("submission-draft-body");
|
||
show("submission-draft-notfound");
|
||
}
|
||
|
||
function showError(msg: string): void {
|
||
hide("submission-draft-loading");
|
||
hide("submission-draft-body");
|
||
const el = document.getElementById("submission-draft-error");
|
||
if (el) {
|
||
el.textContent = msg;
|
||
el.style.display = "";
|
||
}
|
||
}
|
||
|
||
function setSaveStatus(msg: string, errorState: boolean = false): void {
|
||
const el = document.getElementById("submission-draft-savestatus");
|
||
if (!el) return;
|
||
el.textContent = msg;
|
||
el.classList.toggle("submission-draft-savestatus--error", errorState);
|
||
}
|
||
|
||
function parseFilename(header: string): string | null {
|
||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||
return m ? m[1] : null;
|
||
}
|
||
|
||
function triggerDownload(blob: Blob, filename: string): void {
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement("a");
|
||
a.href = url;
|
||
a.download = filename;
|
||
document.body.appendChild(a);
|
||
a.click();
|
||
document.body.removeChild(a);
|
||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||
}
|
||
|
||
// Keep t() referenced so the bundler doesn't tree-shake it; future
|
||
// affordances will use the per-page i18n keys.
|
||
void t;
|
||
|
||
if (document.readyState === "loading") {
|
||
document.addEventListener("DOMContentLoaded", () => { void boot(); });
|
||
} else {
|
||
void boot();
|
||
}
|