|
|
|
|
@@ -137,6 +137,13 @@ interface VariableGroup {
|
|
|
|
|
id: string;
|
|
|
|
|
label: VariableLabel;
|
|
|
|
|
keys: string[];
|
|
|
|
|
// t-paliad-287 — render with a click-to-toggle disclosure caret; the
|
|
|
|
|
// initial state is collapsed iff collapsedByDefault. Used for the
|
|
|
|
|
// Frist section which lawyers rarely need to override (the variables
|
|
|
|
|
// stay resolvable in the bag for the few templates that still want
|
|
|
|
|
// them, but render no body content by default).
|
|
|
|
|
collapsible?: boolean;
|
|
|
|
|
collapsedByDefault?: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
|
|
|
|
@@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
|
|
|
|
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
|
|
|
|
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
|
|
|
|
// (where the picker UI lives — this group only carries the manual
|
|
|
|
|
// {{parties.*}} overrides for power-users), then Frist collapsed by
|
|
|
|
|
// default (the deadline.* keys still resolve in the bag but the default
|
|
|
|
|
// templates don't render them in the body any more), then Sonstiges for
|
|
|
|
|
// the firm/date/user trim. The legacy procedural_event/rule namespaces
|
|
|
|
|
// fold into Mandant/Verfahren so the lawyer reads them in their natural
|
|
|
|
|
// context.
|
|
|
|
|
const VARIABLE_GROUPS: VariableGroup[] = [
|
|
|
|
|
{
|
|
|
|
|
id: "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" },
|
|
|
|
|
id: "mandant_verfahren",
|
|
|
|
|
label: { de: "Mandant & Verfahren", en: "Client & proceeding" },
|
|
|
|
|
keys: [
|
|
|
|
|
"project.title",
|
|
|
|
|
"project.case_number",
|
|
|
|
|
@@ -246,11 +239,43 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
|
|
|
|
"project.matter_number",
|
|
|
|
|
"project.reference",
|
|
|
|
|
"project.instance_level",
|
|
|
|
|
"procedural_event.name",
|
|
|
|
|
"procedural_event.legal_source_pretty",
|
|
|
|
|
"procedural_event.primary_party",
|
|
|
|
|
"procedural_event.event_kind",
|
|
|
|
|
"procedural_event.code",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "parties",
|
|
|
|
|
label: { de: "Parteien (Variablen)", en: "Parties (variables)" },
|
|
|
|
|
// Manual overrides for {{parties.<role>.*}} placeholders — power-
|
|
|
|
|
// user escape hatch when the lawyer wants the rendered string to
|
|
|
|
|
// differ from the picker selection (e.g. honourific prefix on
|
|
|
|
|
// representative). Collapsed by default because the picker above
|
|
|
|
|
// is the canonical surface; these rows exist only as a safety
|
|
|
|
|
// valve.
|
|
|
|
|
collapsible: true,
|
|
|
|
|
collapsedByDefault: true,
|
|
|
|
|
keys: [
|
|
|
|
|
"parties.claimant.name",
|
|
|
|
|
"parties.claimant.representative",
|
|
|
|
|
"parties.defendant.name",
|
|
|
|
|
"parties.defendant.representative",
|
|
|
|
|
"parties.other.name",
|
|
|
|
|
"parties.other.representative",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "deadline",
|
|
|
|
|
label: { de: "Frist", en: "Deadline" },
|
|
|
|
|
label: { de: "Frist (intern)", en: "Deadline (internal)" },
|
|
|
|
|
// t-paliad-287 — the {{deadline.*}} placeholders no longer render
|
|
|
|
|
// in the default skeleton body (internal context that doesn't
|
|
|
|
|
// belong in a court-bound submission). The values still resolve
|
|
|
|
|
// here so a custom template can pick them up if needed; collapsed
|
|
|
|
|
// because most drafts never touch them.
|
|
|
|
|
collapsible: true,
|
|
|
|
|
collapsedByDefault: true,
|
|
|
|
|
keys: [
|
|
|
|
|
"deadline.due_date",
|
|
|
|
|
"deadline.due_date_long_de",
|
|
|
|
|
@@ -261,10 +286,11 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
id: "firm",
|
|
|
|
|
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
|
|
|
|
id: "sonstiges",
|
|
|
|
|
label: { de: "Sonstiges", en: "Other" },
|
|
|
|
|
keys: [
|
|
|
|
|
"firm.name",
|
|
|
|
|
"firm.signature_block",
|
|
|
|
|
"user.display_name",
|
|
|
|
|
"user.email",
|
|
|
|
|
"user.office",
|
|
|
|
|
@@ -291,6 +317,29 @@ interface State {
|
|
|
|
|
saveTimer: number | null;
|
|
|
|
|
pendingOverrides: Record<string, string> | null;
|
|
|
|
|
inFlight: AbortController | null;
|
|
|
|
|
// t-paliad-287 — per-section collapse memory. Sticky across repaints
|
|
|
|
|
// so autosave (which calls paintVariables) doesn't snap an open
|
|
|
|
|
// section shut. Seeded lazily from VARIABLE_GROUPS.collapsedByDefault.
|
|
|
|
|
collapsedGroups: Record<string, boolean>;
|
|
|
|
|
// t-paliad-287 — which side the Add-Party panel is currently open for
|
|
|
|
|
// (one panel can be open at a time; clicking the other side's button
|
|
|
|
|
// toggles). null means closed.
|
|
|
|
|
addPartyOpen: PartySide | null;
|
|
|
|
|
addPartyMode: "manual" | "search";
|
|
|
|
|
addPartySearchHits: PartySearchHit[];
|
|
|
|
|
addPartyBusy: boolean;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type PartySide = "claimant" | "defendant" | "other";
|
|
|
|
|
|
|
|
|
|
interface PartySearchHit {
|
|
|
|
|
id: string;
|
|
|
|
|
project_id: string;
|
|
|
|
|
project_title: string;
|
|
|
|
|
project_reference?: string | null;
|
|
|
|
|
name: string;
|
|
|
|
|
role?: string;
|
|
|
|
|
representative?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const state: State = {
|
|
|
|
|
@@ -300,6 +349,11 @@ const state: State = {
|
|
|
|
|
saveTimer: null,
|
|
|
|
|
pendingOverrides: null,
|
|
|
|
|
inFlight: null,
|
|
|
|
|
collapsedGroups: {},
|
|
|
|
|
addPartyOpen: null,
|
|
|
|
|
addPartyMode: "manual",
|
|
|
|
|
addPartySearchHits: [],
|
|
|
|
|
addPartyBusy: false,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ─────────────────────────────────────────────────────────────────────
|
|
|
|
|
@@ -607,24 +661,31 @@ function paintImportRow(): void {
|
|
|
|
|
btn.onclick = () => { void onImportFromProject(btn); };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// t-paliad-277 — multi-select party picker. Lists every party on the
|
|
|
|
|
// draft's project (view.available_parties), grouped by role, with one
|
|
|
|
|
// checkbox per party. Checked = include in the variable bag. Empty
|
|
|
|
|
// selection falls back to the legacy "include every party" default
|
|
|
|
|
// (consistent with the migration default).
|
|
|
|
|
// t-paliad-277 / t-paliad-287 — multi-select party picker plus Add-
|
|
|
|
|
// Party affordance per side. Lists every party on the draft's project
|
|
|
|
|
// (view.available_parties), grouped by role, with one checkbox per
|
|
|
|
|
// party. Each side (Klägerseite / Beklagtenseite / Sonstige) carries
|
|
|
|
|
// an "+ Partei hinzufügen" button that opens an inline panel with two
|
|
|
|
|
// modes: manual entry (creates a fresh paliad.parties row) or DB
|
|
|
|
|
// picker (searches every visible project, clones the row into THIS
|
|
|
|
|
// project on selection). Empty selection still falls back to the
|
|
|
|
|
// legacy "include every party" default.
|
|
|
|
|
function paintPartyPicker(): void {
|
|
|
|
|
const block = document.getElementById("submission-draft-parties");
|
|
|
|
|
const list = document.getElementById("submission-draft-parties-list");
|
|
|
|
|
if (!block || !list || !state.view) return;
|
|
|
|
|
|
|
|
|
|
const parties = state.view.available_parties ?? [];
|
|
|
|
|
if (!state.view.draft.project_id || parties.length === 0) {
|
|
|
|
|
// t-paliad-287 — picker is now shown even on empty-roster projects so
|
|
|
|
|
// the lawyer can use Add Party to populate. Still hidden when there
|
|
|
|
|
// is no project attached (no row to attach a party to).
|
|
|
|
|
if (!state.view.draft.project_id) {
|
|
|
|
|
block.style.display = "none";
|
|
|
|
|
list.innerHTML = "";
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
block.style.display = "";
|
|
|
|
|
|
|
|
|
|
const parties = state.view.available_parties ?? [];
|
|
|
|
|
const selected = new Set(state.view.draft.selected_parties ?? []);
|
|
|
|
|
// Empty selection is the implicit "all" default — pre-check every
|
|
|
|
|
// party so the lawyer can see what's currently being mentioned and
|
|
|
|
|
@@ -637,9 +698,13 @@ function paintPartyPicker(): void {
|
|
|
|
|
const grouped = groupPartiesByRole(parties);
|
|
|
|
|
let html = "";
|
|
|
|
|
for (const group of grouped) {
|
|
|
|
|
if (group.parties.length === 0) continue;
|
|
|
|
|
html += `<fieldset class="submission-draft-parties-group" data-role-bucket="${group.bucket}">`;
|
|
|
|
|
html += `<legend>${escapeHtml(group.label)}</legend>`;
|
|
|
|
|
if (group.parties.length === 0) {
|
|
|
|
|
html += `<p class="submission-draft-parties-empty">${escapeHtml(
|
|
|
|
|
isEN() ? "No parties yet." : "Noch keine Parteien.",
|
|
|
|
|
)}</p>`;
|
|
|
|
|
}
|
|
|
|
|
for (const p of group.parties) {
|
|
|
|
|
const checked = effective.has(p.id) ? " checked" : "";
|
|
|
|
|
const chip = p.role
|
|
|
|
|
@@ -658,6 +723,7 @@ function paintPartyPicker(): void {
|
|
|
|
|
html += rep;
|
|
|
|
|
html += `</label>`;
|
|
|
|
|
}
|
|
|
|
|
html += renderAddPartyControls(group.bucket);
|
|
|
|
|
html += `</fieldset>`;
|
|
|
|
|
}
|
|
|
|
|
list.innerHTML = html;
|
|
|
|
|
@@ -665,6 +731,198 @@ function paintPartyPicker(): void {
|
|
|
|
|
list.querySelectorAll<HTMLInputElement>(".submission-draft-party-check").forEach((inp) => {
|
|
|
|
|
inp.addEventListener("change", () => onPartySelectionChange());
|
|
|
|
|
});
|
|
|
|
|
wireAddPartyControls(list);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// renderAddPartyControls emits the per-side "+ Add party" button and
|
|
|
|
|
// (when expanded) the inline panel offering manual entry OR DB search.
|
|
|
|
|
// Sticky panel state lives in state.addPartyOpen so a repaint after
|
|
|
|
|
// search-fetch / autosave / language-switch doesn't snap the panel
|
|
|
|
|
// shut mid-edit.
|
|
|
|
|
function renderAddPartyControls(side: PartySide): string {
|
|
|
|
|
const open = state.addPartyOpen === side;
|
|
|
|
|
const mode = state.addPartyMode;
|
|
|
|
|
const sideLabel = sideLabelFor(side);
|
|
|
|
|
const btnLabel = isEN()
|
|
|
|
|
? `+ Add party (${sideLabel})`
|
|
|
|
|
: `+ Partei hinzufügen (${sideLabel})`;
|
|
|
|
|
|
|
|
|
|
let html = `<div class="submission-draft-addparty">`;
|
|
|
|
|
html += `<button type="button" class="btn-small btn-secondary submission-draft-addparty-toggle"`;
|
|
|
|
|
html += ` data-side="${side}" aria-expanded="${open ? "true" : "false"}">`;
|
|
|
|
|
html += escapeHtml(btnLabel);
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
|
|
|
|
|
if (!open) {
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Tabs — manual / search.
|
|
|
|
|
html += `<div class="submission-draft-addparty-panel">`;
|
|
|
|
|
html += `<div class="submission-draft-addparty-tabs" role="tablist">`;
|
|
|
|
|
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
|
|
|
|
if (mode === "manual") html += ` submission-draft-addparty-tab--active`;
|
|
|
|
|
html += `" data-tab="manual" data-side="${side}" aria-selected="${mode === "manual"}">`;
|
|
|
|
|
html += escapeHtml(isEN() ? "Manual entry" : "Manuell");
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
html += `<button type="button" role="tab" class="submission-draft-addparty-tab`;
|
|
|
|
|
if (mode === "search") html += ` submission-draft-addparty-tab--active`;
|
|
|
|
|
html += `" data-tab="search" data-side="${side}" aria-selected="${mode === "search"}">`;
|
|
|
|
|
html += escapeHtml(isEN() ? "From DB" : "Aus DB übernehmen");
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
|
|
|
|
|
if (mode === "manual") {
|
|
|
|
|
html += renderAddPartyManualForm(side);
|
|
|
|
|
} else {
|
|
|
|
|
html += renderAddPartySearchPanel(side);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
html += `</div></div>`;
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderAddPartyManualForm(side: PartySide): string {
|
|
|
|
|
const defaultRole = defaultRoleFor(side);
|
|
|
|
|
const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : "";
|
|
|
|
|
let html = `<form class="submission-draft-addparty-form${busyCls}" data-side="${side}" data-mode="manual">`;
|
|
|
|
|
html += `<label class="submission-draft-addparty-field">`;
|
|
|
|
|
html += `<span>${escapeHtml(isEN() ? "Name" : "Name")}</span>`;
|
|
|
|
|
html += `<input type="text" name="name" required class="entity-form-input"`;
|
|
|
|
|
html += ` placeholder="${escapeHtml(isEN() ? "Acme Inc." : "z. B. Acme GmbH")}" />`;
|
|
|
|
|
html += `</label>`;
|
|
|
|
|
html += `<label class="submission-draft-addparty-field">`;
|
|
|
|
|
html += `<span>${escapeHtml(isEN() ? "Role" : "Rolle")}</span>`;
|
|
|
|
|
html += `<input type="text" name="role" class="entity-form-input"`;
|
|
|
|
|
html += ` value="${escapeHtml(defaultRole)}"`;
|
|
|
|
|
html += ` placeholder="${escapeHtml(isEN() ? "claimant / defendant / intervenor / …" : "Klägerin / Beklagte / Streithelferin / …")}" />`;
|
|
|
|
|
html += `</label>`;
|
|
|
|
|
html += `<label class="submission-draft-addparty-field">`;
|
|
|
|
|
html += `<span>${escapeHtml(isEN() ? "Representative (optional)" : "Vertreter:in (optional)")}</span>`;
|
|
|
|
|
html += `<input type="text" name="representative" class="entity-form-input"`;
|
|
|
|
|
html += ` placeholder="${escapeHtml(isEN() ? "Dr. Müller, …" : "RA Dr. Müller, …")}" />`;
|
|
|
|
|
html += `</label>`;
|
|
|
|
|
html += `<div class="submission-draft-addparty-actions">`;
|
|
|
|
|
html += `<button type="submit" class="btn-small btn-primary"${state.addPartyBusy ? " disabled" : ""}>`;
|
|
|
|
|
html += escapeHtml(isEN() ? "Add party" : "Hinzufügen");
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
|
|
|
|
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
html += `</form>`;
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderAddPartySearchPanel(side: PartySide): string {
|
|
|
|
|
let html = `<div class="submission-draft-addparty-search" data-side="${side}" data-mode="search">`;
|
|
|
|
|
html += `<input type="search" class="entity-form-input submission-draft-addparty-search-input"`;
|
|
|
|
|
html += ` data-side="${side}"`;
|
|
|
|
|
html += ` placeholder="${escapeHtml(
|
|
|
|
|
isEN()
|
|
|
|
|
? "Search across projects (name or representative)…"
|
|
|
|
|
: "In allen Projekten suchen (Name oder Vertreter)…",
|
|
|
|
|
)}" />`;
|
|
|
|
|
html += renderPartySearchResultsList();
|
|
|
|
|
html += `<p class="submission-draft-addparty-search-hint">${escapeHtml(
|
|
|
|
|
isEN()
|
|
|
|
|
? "Picking a row clones it as a fresh party on this project — no typing."
|
|
|
|
|
: "Auswählen kopiert die Partei in dieses Projekt — kein erneutes Tippen.",
|
|
|
|
|
)}</p>`;
|
|
|
|
|
html += `<div class="submission-draft-addparty-actions">`;
|
|
|
|
|
html += `<button type="button" class="btn-small btn-link submission-draft-addparty-cancel">`;
|
|
|
|
|
html += escapeHtml(isEN() ? "Cancel" : "Abbrechen");
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function wireAddPartyControls(root: HTMLElement): void {
|
|
|
|
|
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-toggle").forEach((btn) => {
|
|
|
|
|
btn.addEventListener("click", () => {
|
|
|
|
|
const side = (btn.dataset.side as PartySide) ?? "other";
|
|
|
|
|
if (state.addPartyOpen === side) {
|
|
|
|
|
// Toggle off.
|
|
|
|
|
state.addPartyOpen = null;
|
|
|
|
|
state.addPartySearchHits = [];
|
|
|
|
|
} else {
|
|
|
|
|
state.addPartyOpen = side;
|
|
|
|
|
state.addPartyMode = "manual";
|
|
|
|
|
state.addPartySearchHits = [];
|
|
|
|
|
}
|
|
|
|
|
paintPartyPicker();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-tab").forEach((btn) => {
|
|
|
|
|
btn.addEventListener("click", () => {
|
|
|
|
|
const tab = btn.dataset.tab;
|
|
|
|
|
if (tab !== "manual" && tab !== "search") return;
|
|
|
|
|
state.addPartyMode = tab;
|
|
|
|
|
if (tab === "manual") state.addPartySearchHits = [];
|
|
|
|
|
paintPartyPicker();
|
|
|
|
|
if (tab === "search") {
|
|
|
|
|
// Pre-load most-recent matches with empty query so the lawyer
|
|
|
|
|
// sees options without typing first.
|
|
|
|
|
void runPartySearch("");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
root.querySelectorAll<HTMLButtonElement>(".submission-draft-addparty-cancel").forEach((btn) => {
|
|
|
|
|
btn.addEventListener("click", () => {
|
|
|
|
|
state.addPartyOpen = null;
|
|
|
|
|
state.addPartySearchHits = [];
|
|
|
|
|
paintPartyPicker();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
root.querySelectorAll<HTMLFormElement>(".submission-draft-addparty-form").forEach((form) => {
|
|
|
|
|
form.addEventListener("submit", (ev) => {
|
|
|
|
|
ev.preventDefault();
|
|
|
|
|
const side = (form.dataset.side as PartySide) ?? "other";
|
|
|
|
|
const data = new FormData(form);
|
|
|
|
|
const name = String(data.get("name") ?? "").trim();
|
|
|
|
|
if (!name) return;
|
|
|
|
|
const role = String(data.get("role") ?? "").trim();
|
|
|
|
|
const representative = String(data.get("representative") ?? "").trim();
|
|
|
|
|
void onAddPartyManualSubmit(side, { name, role, representative });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
root.querySelectorAll<HTMLInputElement>(".submission-draft-addparty-search-input").forEach((inp) => {
|
|
|
|
|
let timer: number | null = null;
|
|
|
|
|
inp.addEventListener("input", () => {
|
|
|
|
|
if (timer !== null) window.clearTimeout(timer);
|
|
|
|
|
timer = window.setTimeout(() => {
|
|
|
|
|
void runPartySearch(inp.value.trim());
|
|
|
|
|
}, 200);
|
|
|
|
|
});
|
|
|
|
|
// Pre-load on first render of the search tab.
|
|
|
|
|
if (state.addPartyMode === "search" && state.addPartySearchHits.length === 0) {
|
|
|
|
|
void runPartySearch("");
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
root.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
|
|
|
|
li.addEventListener("click", () => {
|
|
|
|
|
const hitID = li.dataset.hitId;
|
|
|
|
|
if (!hitID) return;
|
|
|
|
|
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
|
|
|
|
if (!hit) return;
|
|
|
|
|
const side = state.addPartyOpen ?? "other";
|
|
|
|
|
void onAddPartySearchPick(side, hit);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function sideLabelFor(side: PartySide): string {
|
|
|
|
|
if (side === "claimant") return isEN() ? "Claimant side" : "Klägerseite";
|
|
|
|
|
if (side === "defendant") return isEN() ? "Defendant side" : "Beklagtenseite";
|
|
|
|
|
return isEN() ? "Other parties" : "Weitere Parteien";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function defaultRoleFor(side: PartySide): string {
|
|
|
|
|
if (side === "claimant") return isEN() ? "claimant" : "Klägerin";
|
|
|
|
|
if (side === "defendant") return isEN() ? "defendant" : "Beklagte";
|
|
|
|
|
return "";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
interface PartyRoleGroup {
|
|
|
|
|
@@ -781,8 +1039,27 @@ function paintVariables(): void {
|
|
|
|
|
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>`;
|
|
|
|
|
// Re-use the user's prior toggle state across paintVariables calls
|
|
|
|
|
// (autosave / language switch trigger a repaint). Default sticky
|
|
|
|
|
// state lives in state.collapsedGroups; on first render the
|
|
|
|
|
// collapsedByDefault flag seeds it.
|
|
|
|
|
if (!Object.prototype.hasOwnProperty.call(state.collapsedGroups, group.id)) {
|
|
|
|
|
state.collapsedGroups[group.id] = !!(group.collapsible && group.collapsedByDefault);
|
|
|
|
|
}
|
|
|
|
|
const collapsed = !!state.collapsedGroups[group.id];
|
|
|
|
|
const collapsibleCls = group.collapsible ? " submission-draft-var-group--collapsible" : "";
|
|
|
|
|
const collapsedCls = collapsed ? " submission-draft-var-group--collapsed" : "";
|
|
|
|
|
html += `<section class="submission-draft-var-group${collapsibleCls}${collapsedCls}" data-group="${group.id}">`;
|
|
|
|
|
if (group.collapsible) {
|
|
|
|
|
html += `<button type="button" class="submission-draft-var-group-toggle"`;
|
|
|
|
|
html += ` data-toggle-group="${escapeHtml(group.id)}" aria-expanded="${collapsed ? "false" : "true"}">`;
|
|
|
|
|
html += `<span class="submission-draft-var-group-caret" aria-hidden="true">▸</span>`;
|
|
|
|
|
html += `<span class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</span>`;
|
|
|
|
|
html += `</button>`;
|
|
|
|
|
} else {
|
|
|
|
|
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
|
|
|
|
}
|
|
|
|
|
html += `<div class="submission-draft-var-group-body">`;
|
|
|
|
|
for (const key of group.keys) {
|
|
|
|
|
const label = labelFor(key);
|
|
|
|
|
const override = overrides[key];
|
|
|
|
|
@@ -813,10 +1090,19 @@ function paintVariables(): void {
|
|
|
|
|
// Visual hint: marker text appears in preview when override is "".
|
|
|
|
|
void mergedVal;
|
|
|
|
|
}
|
|
|
|
|
html += `</div>`;
|
|
|
|
|
html += `</section>`;
|
|
|
|
|
}
|
|
|
|
|
host.innerHTML = html;
|
|
|
|
|
|
|
|
|
|
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-group-toggle").forEach((btn) => {
|
|
|
|
|
btn.addEventListener("click", () => {
|
|
|
|
|
const id = btn.dataset.toggleGroup;
|
|
|
|
|
if (!id) return;
|
|
|
|
|
state.collapsedGroups[id] = !state.collapsedGroups[id];
|
|
|
|
|
paintVariables();
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
|
|
|
|
inp.addEventListener("input", () => onVarChange(inp));
|
|
|
|
|
// t-paliad-274 (B) — focus into a sidebar field highlights every
|
|
|
|
|
@@ -1021,6 +1307,175 @@ async function onPartySelectionChange(): Promise<void> {
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function runPartySearch(query: string): Promise<void> {
|
|
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams();
|
|
|
|
|
if (query) params.set("q", query);
|
|
|
|
|
const resp = await fetch(`/api/parties/search?${params.toString()}`);
|
|
|
|
|
if (!resp.ok) throw new Error(`search ${resp.status}`);
|
|
|
|
|
const data = (await resp.json()) as { results: PartySearchHit[] };
|
|
|
|
|
// Filter out parties already on THIS project — picking one of them
|
|
|
|
|
// would be a no-op clone that doubles the row.
|
|
|
|
|
const existingIDs = new Set(
|
|
|
|
|
(state.view?.available_parties ?? []).map((p) => p.id),
|
|
|
|
|
);
|
|
|
|
|
state.addPartySearchHits = (data.results ?? []).filter((h) => !existingIDs.has(h.id));
|
|
|
|
|
|
|
|
|
|
// Refresh ONLY the results <ul> in place — repainting the whole
|
|
|
|
|
// picker would steal focus from the search input on every
|
|
|
|
|
// keystroke. The input keeps its value/selection and the lawyer
|
|
|
|
|
// can keep typing.
|
|
|
|
|
const ul = document.querySelector<HTMLUListElement>(
|
|
|
|
|
".submission-draft-addparty-search-results",
|
|
|
|
|
);
|
|
|
|
|
if (ul) {
|
|
|
|
|
ul.outerHTML = renderPartySearchResultsList();
|
|
|
|
|
const fresh = document.querySelector<HTMLUListElement>(
|
|
|
|
|
".submission-draft-addparty-search-results",
|
|
|
|
|
);
|
|
|
|
|
if (fresh) {
|
|
|
|
|
fresh.querySelectorAll<HTMLLIElement>(".submission-draft-addparty-search-row").forEach((li) => {
|
|
|
|
|
li.addEventListener("click", () => {
|
|
|
|
|
const hitID = li.dataset.hitId;
|
|
|
|
|
if (!hitID) return;
|
|
|
|
|
const hit = state.addPartySearchHits.find((h) => h.id === hitID);
|
|
|
|
|
if (!hit) return;
|
|
|
|
|
const side = state.addPartyOpen ?? "other";
|
|
|
|
|
void onAddPartySearchPick(side, hit);
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
// First load (panel just opened) — full picker paint to wire up
|
|
|
|
|
// every control. Subsequent keystroke updates take the cheaper
|
|
|
|
|
// path above.
|
|
|
|
|
paintPartyPicker();
|
|
|
|
|
}
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("submission-draft party-search:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderPartySearchResultsList(): string {
|
|
|
|
|
let html = `<ul class="submission-draft-addparty-search-results">`;
|
|
|
|
|
if (state.addPartySearchHits.length === 0) {
|
|
|
|
|
html += `<li class="submission-draft-addparty-search-empty">${escapeHtml(
|
|
|
|
|
isEN() ? "No matches." : "Keine Treffer.",
|
|
|
|
|
)}</li>`;
|
|
|
|
|
} else {
|
|
|
|
|
for (const hit of state.addPartySearchHits) {
|
|
|
|
|
const ref = hit.project_reference
|
|
|
|
|
? `<span class="submission-draft-addparty-search-projref">${escapeHtml(hit.project_reference)}</span>`
|
|
|
|
|
: "";
|
|
|
|
|
const role = hit.role
|
|
|
|
|
? `<span class="submission-draft-party-chip">${escapeHtml(hit.role)}</span>`
|
|
|
|
|
: "";
|
|
|
|
|
const rep = hit.representative
|
|
|
|
|
? `<span class="submission-draft-addparty-search-rep">${escapeHtml(
|
|
|
|
|
(isEN() ? "Repr.: " : "Vertr.: ") + hit.representative,
|
|
|
|
|
)}</span>`
|
|
|
|
|
: "";
|
|
|
|
|
html += `<li class="submission-draft-addparty-search-row" data-hit-id="${escapeHtml(hit.id)}">`;
|
|
|
|
|
html += `<span class="submission-draft-addparty-search-name">${escapeHtml(hit.name)}</span>`;
|
|
|
|
|
html += role;
|
|
|
|
|
html += rep;
|
|
|
|
|
html += `<span class="submission-draft-addparty-search-projwrap">`;
|
|
|
|
|
html += escapeHtml(isEN() ? "Project: " : "Projekt: ");
|
|
|
|
|
html += `<span class="submission-draft-addparty-search-proj">${escapeHtml(hit.project_title)}</span>`;
|
|
|
|
|
html += ref;
|
|
|
|
|
html += `</span>`;
|
|
|
|
|
html += `</li>`;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
html += `</ul>`;
|
|
|
|
|
return html;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onAddPartyManualSubmit(
|
|
|
|
|
side: PartySide,
|
|
|
|
|
payload: { name: string; role: string; representative: string },
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
if (!state.view) return;
|
|
|
|
|
const projectID = state.view.draft.project_id;
|
|
|
|
|
if (!projectID) return;
|
|
|
|
|
// Disable the submit button in-place rather than repainting the form
|
|
|
|
|
// mid-flight (a repaint would blow away the lawyer's typed values on
|
|
|
|
|
// error and reset focus). The post-success/-error repaint runs once
|
|
|
|
|
// the call settles.
|
|
|
|
|
const submitBtn = document.querySelector<HTMLButtonElement>(
|
|
|
|
|
`.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`,
|
|
|
|
|
);
|
|
|
|
|
if (submitBtn) submitBtn.disabled = true;
|
|
|
|
|
state.addPartyBusy = true;
|
|
|
|
|
try {
|
|
|
|
|
const body: Record<string, unknown> = { name: payload.name };
|
|
|
|
|
if (payload.role) body.role = payload.role;
|
|
|
|
|
if (payload.representative) body.representative = payload.representative;
|
|
|
|
|
const resp = await fetch(`/api/projects/${projectID}/parties`, {
|
|
|
|
|
method: "POST",
|
|
|
|
|
headers: { "Content-Type": "application/json" },
|
|
|
|
|
body: JSON.stringify(body),
|
|
|
|
|
});
|
|
|
|
|
if (!resp.ok) throw new Error(`create party ${resp.status}`);
|
|
|
|
|
const created = (await resp.json()) as { id: string };
|
|
|
|
|
await refreshDraftViewAndSelect(created.id);
|
|
|
|
|
state.addPartyOpen = null;
|
|
|
|
|
setSaveStatus(isEN() ? "Party added" : "Partei hinzugefügt");
|
|
|
|
|
state.addPartyBusy = false;
|
|
|
|
|
paintPartyPicker();
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("submission-draft add-party manual:", err);
|
|
|
|
|
setSaveStatus(isEN() ? "Add party failed" : "Hinzufügen fehlgeschlagen", true);
|
|
|
|
|
if (submitBtn) submitBtn.disabled = false;
|
|
|
|
|
state.addPartyBusy = false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onAddPartySearchPick(side: PartySide, hit: PartySearchHit): Promise<void> {
|
|
|
|
|
// DB picks clone the row into the current project — the simplest
|
|
|
|
|
// semantics that survive paliad.parties' project_id-NOT-NULL schema.
|
|
|
|
|
// The lawyer asked for "no manual re-typing"; this honours that
|
|
|
|
|
// without bending the data model.
|
|
|
|
|
await onAddPartyManualSubmit(side, {
|
|
|
|
|
name: hit.name,
|
|
|
|
|
role: hit.role ?? defaultRoleFor(side),
|
|
|
|
|
representative: hit.representative ?? "",
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// refreshDraftViewAndSelect refetches the editor payload (so
|
|
|
|
|
// available_parties picks up the new row) and ensures the newly-added
|
|
|
|
|
// party is checked in selected_parties. If the lawyer was on the
|
|
|
|
|
// implicit-all default (empty selected_parties), the new party comes
|
|
|
|
|
// in pre-selected via the "empty=all" rule and no PATCH is needed.
|
|
|
|
|
async function refreshDraftViewAndSelect(newPartyID: string): Promise<void> {
|
|
|
|
|
if (!state.view) return;
|
|
|
|
|
const draftID = state.view.draft.id;
|
|
|
|
|
const view = state.view.draft.project_id
|
|
|
|
|
? await fetchView(state.view.draft.project_id, state.view.draft.submission_code, draftID)
|
|
|
|
|
: await fetchGlobalView(draftID);
|
|
|
|
|
state.view = view;
|
|
|
|
|
|
|
|
|
|
// If the previous draft had a non-empty selected_parties subset,
|
|
|
|
|
// explicitly add the new party so it isn't silently dropped from the
|
|
|
|
|
// submission. Empty selected_parties = "all" → no PATCH needed.
|
|
|
|
|
const currentSel = state.view.draft.selected_parties ?? [];
|
|
|
|
|
if (currentSel.length > 0 && !currentSel.includes(newPartyID)) {
|
|
|
|
|
const next = [...currentSel, newPartyID];
|
|
|
|
|
try {
|
|
|
|
|
const patched = await patchDraft({ selected_parties: next });
|
|
|
|
|
state.view = patched;
|
|
|
|
|
} catch (err) {
|
|
|
|
|
console.error("submission-draft select new party:", err);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
paintImportRow();
|
|
|
|
|
paintPartyPicker();
|
|
|
|
|
paintVariables();
|
|
|
|
|
paintPreview();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function onImportFromProject(btn: HTMLButtonElement): Promise<void> {
|
|
|
|
|
if (!state.view) return;
|
|
|
|
|
const draftID = state.view.draft.id;
|
|
|
|
|
|