Merge: t-paliad-287 — submission form revision (Frist drop + grouped sections + Add Party + DB picker) (m/paliad#119)
Some checks failed
Paliad CI gate / build (push) Has been cancelled
Paliad CI gate / test-go (push) Has been cancelled
Paliad CI gate / deploy (push) Has been cancelled

This commit is contained in:
mAi
2026-05-26 09:42:58 +02:00
9 changed files with 792 additions and 62 deletions

View File

@@ -1502,7 +1502,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Aus Projekt importieren",
"submissions.draft.parties.title": "Parteien",
"submissions.draft.parties.hint": "Wählen Sie aus, welche Parteien im Schriftsatz genannt werden sollen.",
"submissions.draft.parties.hint": "Wählen Sie die im Schriftsatz genannten Parteien oder fügen Sie pro Seite weitere hinzu.",
// t-paliad-276 — DE/EN language toggle on the draft editor.
"submissions.draft.language": "Sprache",
"submissions.draft.language.de": "DE",
@@ -4590,7 +4590,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-277 — import-from-project + party-picker.
"submissions.draft.import.button": "Import from project",
"submissions.draft.parties.title": "Parties",
"submissions.draft.parties.hint": "Select which parties to mention in this submission.",
"submissions.draft.parties.hint": "Pick the parties mentioned in this submission, or add more per side.",
// t-paliad-240 — global submissions drafts index page.
"submissions.index.title": "Submissions — Paliad",
"submissions.index.heading": "Submissions",

View File

@@ -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;

View File

@@ -6432,6 +6432,194 @@ dialog.modal::backdrop {
margin-left: 0.25rem;
}
/* t-paliad-287 — collapsible variable-group section (Frist + Parteien
override). The toggle button is the section header; clicking it
flips state.collapsedGroups[id] and re-renders. The visible caret
rotates via the parent's --collapsed class. */
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle {
all: unset;
display: flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0;
margin: 0 0 0.5rem 0;
cursor: pointer;
color: var(--color-text-muted);
}
.submission-draft-var-group--collapsible > .submission-draft-var-group-toggle:focus-visible {
outline: 2px solid var(--color-accent, #c6f41c);
outline-offset: 2px;
}
.submission-draft-var-group-caret {
display: inline-block;
transition: transform 120ms ease;
font-size: 0.85em;
line-height: 1;
}
.submission-draft-var-group--collapsible:not(.submission-draft-var-group--collapsed)
.submission-draft-var-group-caret {
transform: rotate(90deg);
}
.submission-draft-var-group--collapsed .submission-draft-var-group-body {
display: none;
}
/* t-paliad-287 — Add Party affordance per side. */
.submission-draft-addparty {
margin-top: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-panel {
border: 1px solid var(--color-border);
border-radius: 6px;
padding: 0.6rem;
background: var(--color-surface-alt, #f7f7f0);
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.submission-draft-addparty-tabs {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid var(--color-border);
margin-bottom: 0.25rem;
}
.submission-draft-addparty-tab {
all: unset;
cursor: pointer;
padding: 0.3rem 0.6rem;
font-size: 0.85em;
border-radius: 4px 4px 0 0;
color: var(--color-text-muted);
border-bottom: 2px solid transparent;
}
.submission-draft-addparty-tab--active {
color: var(--color-text);
border-bottom-color: var(--color-accent, #c6f41c);
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-form {
display: flex;
flex-direction: column;
gap: 0.45rem;
}
.submission-draft-addparty-form--busy {
opacity: 0.6;
pointer-events: none;
}
.submission-draft-addparty-field {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.submission-draft-addparty-field > span {
font-size: 0.82em;
color: var(--color-text-muted);
}
.submission-draft-addparty-actions {
display: flex;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search {
display: flex;
flex-direction: column;
gap: 0.4rem;
}
.submission-draft-addparty-search-results {
list-style: none;
padding: 0;
margin: 0;
max-height: 14rem;
overflow-y: auto;
border: 1px solid var(--color-border);
border-radius: 4px;
background: var(--color-surface, #fff);
}
.submission-draft-addparty-search-row {
padding: 0.45rem 0.6rem;
border-bottom: 1px solid var(--color-border);
cursor: pointer;
display: flex;
flex-wrap: wrap;
gap: 0.4rem;
align-items: center;
}
.submission-draft-addparty-search-row:last-child {
border-bottom: none;
}
.submission-draft-addparty-search-row:hover {
background: var(--color-bg-lime-tint, #f0fac6);
}
.submission-draft-addparty-search-empty {
padding: 0.6rem;
font-size: 0.85em;
color: var(--color-text-muted);
text-align: center;
}
.submission-draft-addparty-search-name {
font-weight: 500;
color: var(--color-text);
}
.submission-draft-addparty-search-rep {
font-size: 0.78em;
color: var(--color-text-muted);
}
.submission-draft-addparty-search-projwrap {
font-size: 0.78em;
color: var(--color-text-muted);
width: 100%;
}
.submission-draft-addparty-search-proj {
color: var(--color-text);
}
.submission-draft-addparty-search-projref {
margin-left: 0.3rem;
padding: 0 0.4em;
border-radius: 3px;
background: var(--color-surface-alt, #f7f7f0);
color: var(--color-text-muted);
}
.submission-draft-addparty-search-hint {
font-size: 0.78em;
color: var(--color-text-muted);
margin: 0;
}
.submission-draft-parties-empty {
font-size: 0.82em;
color: var(--color-text-muted);
margin: 0.2rem 0;
}
.checklist-instance-actions {
display: flex;
gap: 0.35rem;

View File

@@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string {
/>
</div>
{/* t-paliad-277: multi-select party picker.
{/* t-paliad-277 / t-paliad-287: multi-select party
picker plus per-side Add-Party affordance.
Populated from view.available_parties; checkbox
per party, grouped by role. Hidden when no
project or no parties on the project. */}
project is attached; visible even on empty
rosters so the lawyer can use Add Party to
populate. */}
<div
id="submission-draft-parties"
className="submission-draft-parties"