Files
paliad/frontend/src/client/submission-draft.ts
mAi 669764e86f mAi: #108 - t-paliad-276 submission generator language selector (DE/EN)
Per-draft `language` column drives the .docx output language for the
submission generator. The lawyer picks DE or EN on the draft editor's
sidebar; the generator selects the language-matched template variant
(falling back through {code}.{lang} → {code} → _skeleton.{lang} →
_skeleton → letterhead) and resolves language-aware variables
({{procedural_event.name}} → name_de vs name_en).

Schema (mig 130 — bumped from 129 to deconflict with atlas's #96):
- paliad.submission_drafts.language text NOT NULL DEFAULT 'de'
  CHECK IN ('de','en'). Existing rows inherit 'de' via the default,
  preserving every legacy draft's behaviour byte-for-byte.

Backend (Go):
- SubmissionVarsContext.Lang overrides the user's UI lang. Build()
  uses it when set; falls back to user.Lang otherwise — Slice 1's
  format-only /generate path keeps working unchanged.
- SubmissionDraftService.BuildRenderBag now threads draft.Language
  through. Create/EnsureLatest seed from the UI lang (DE default).
- DraftPatch.Language landed; Update validates and rejects values
  outside {de,en}. Project-scoped + global PATCH endpoints both
  surface the field.
- resolveSubmissionTemplate(ctx, code, lang) replaces the lang-less
  predecessor. Returns the matched tier (per_code_lang / per_code /
  skeleton_lang / skeleton / letterhead) so the editor knows whether
  to surface the "Fallback: universelles Skelett" notice.
- fileRegistry registers the EN skeleton sibling (`_skeleton.en.docx`)
  alongside the DE one; per-code EN variants land in a parallel
  submissionTemplateENRegistry (empty for now — EN templates land per
  HLC authoring). 404s from Gitea fall through silently.
- /api/projects/{id}/submissions/{code}/generate accepts
  `?language=de|en` query override (one-shot path, no draft row to
  pull the column from); defaults to the user's UI lang.

Frontend (TS/JSX):
- DE/EN radio above the variables list in the draft editor sidebar.
  Switching the radio PATCHes `language` and the server returns the
  freshly-resolved bag + preview HTML so the lawyer sees EN values
  immediately.
- Fallback notice ("Fallback: universelles Skelett (keine
  sprachspezifische Vorlage)") shows when the resolved tier doesn't
  match the requested language.
- 4 new i18n keys (DE + EN) + CSS for the toggle.

Tests:
- normalizeDraftLanguage covers DE/EN/case/whitespace/unknown.
- addRuleVars language-pick test pins procedural_event.name and the
  rule.name alias to the language-matched value.
- languageFallback truth table covers all 10 (lang × tier) combos.

Build hygiene: go build/vet/test clean; bun run build clean.
2026-05-25 16:39:29 +02:00

1187 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>;
last_exported_at?: string | null;
last_exported_sha?: string | null;
created_at: string;
updated_at: 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;
// 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;
}
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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// ─────────────────────────────────────────────────────────────────────
// 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[];
}
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" },
};
const VARIABLE_GROUPS: VariableGroup[] = [
{
id: "procedural_event",
label: { de: "Verfahrensschritt", en: "Procedural event" },
keys: [
"procedural_event.name",
"procedural_event.legal_source_pretty",
"procedural_event.primary_party",
"procedural_event.event_kind",
"procedural_event.code",
],
},
{
id: "parties",
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
keys: [
"parties.claimant.name",
"parties.claimant.representative",
"parties.defendant.name",
"parties.defendant.representative",
"parties.other.name",
"parties.other.representative",
],
},
{
id: "project",
label: { de: "Verfahren", en: "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",
],
},
{
id: "deadline",
label: { de: "Frist", en: "Deadline" },
keys: [
"deadline.due_date",
"deadline.due_date_long_de",
"deadline.due_date_long_en",
"deadline.computed_from",
"deadline.title",
"deadline.original_due_date",
],
},
{
id: "firm",
label: { de: "Kanzlei & Datum", en: "Firm & date" },
keys: [
"firm.name",
"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;
}
const state: State = {
parsed: null as unknown as ParsedPath,
view: null,
drafts: [],
saveTimer: null,
pendingOverrides: null,
inFlight: null,
};
// ─────────────────────────────────────────────────────────────────────
// Boot
// ─────────────────────────────────────────────────────────────────────
async function boot(): Promise<void> {
initI18n();
initSidebar();
const parsed = parsePath();
if (!parsed) {
showNotFound();
return;
}
state.parsed = parsed;
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; 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();
paintLanguageRow();
paintLanguageFallback();
paintVariables();
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);
}
// 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;
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
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 += `</section>`;
}
host.innerHTML = html;
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
inp.addEventListener("input", () => onVarChange(inp));
});
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
});
}
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-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);
}
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
// ─────────────────────────────────────────────────────────────────────
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();
}