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.
1187 lines
48 KiB
TypeScript
1187 lines
48 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>;
|
||
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, "&")
|
||
.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[];
|
||
}
|
||
|
||
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();
|
||
}
|