diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 55a5827..2deafed 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1502,7 +1502,7 @@ const translations: Record> = { // 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> = { // 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", diff --git a/frontend/src/client/submission-draft.ts b/frontend/src/client/submission-draft.ts index 5b74585..60e59cb 100644 --- a/frontend/src/client/submission-draft.ts +++ b/frontend/src/client/submission-draft.ts @@ -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 = { @@ -205,33 +212,19 @@ const VARIABLE_LABELS: Record = { "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..*}} 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 | 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; + // 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 += `
`; html += `${escapeHtml(group.label)}`; + if (group.parties.length === 0) { + html += `

${escapeHtml( + isEN() ? "No parties yet." : "Noch keine Parteien.", + )}

`; + } 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 += ``; } + html += renderAddPartyControls(group.bucket); html += `
`; } list.innerHTML = html; @@ -665,6 +731,198 @@ function paintPartyPicker(): void { list.querySelectorAll(".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 = `
`; + html += ``; + + if (!open) { + html += `
`; + return html; + } + + // Tabs — manual / search. + html += `
`; + html += `
`; + html += ``; + html += ``; + html += `
`; + + if (mode === "manual") { + html += renderAddPartyManualForm(side); + } else { + html += renderAddPartySearchPanel(side); + } + + html += `
`; + return html; +} + +function renderAddPartyManualForm(side: PartySide): string { + const defaultRole = defaultRoleFor(side); + const busyCls = state.addPartyBusy ? " submission-draft-addparty-form--busy" : ""; + let html = `
`; + html += ``; + html += ``; + html += ``; + html += `
`; + html += ``; + html += ``; + html += `
`; + html += `
`; + return html; +} + +function renderAddPartySearchPanel(side: PartySide): string { + let html = ``; + return html; +} + +function wireAddPartyControls(root: HTMLElement): void { + root.querySelectorAll(".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(".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(".submission-draft-addparty-cancel").forEach((btn) => { + btn.addEventListener("click", () => { + state.addPartyOpen = null; + state.addPartySearchHits = []; + paintPartyPicker(); + }); + }); + root.querySelectorAll(".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(".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(".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 += `
`; - html += `

${escapeHtml(groupLabel)}

`; + // 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 += `
`; + if (group.collapsible) { + html += ``; + } else { + html += `

${escapeHtml(groupLabel)}

`; + } + html += `
`; 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 += `
`; html += `
`; } host.innerHTML = html; + host.querySelectorAll(".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(".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 { } } +async function runPartySearch(query: string): Promise { + 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
    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( + ".submission-draft-addparty-search-results", + ); + if (ul) { + ul.outerHTML = renderPartySearchResultsList(); + const fresh = document.querySelector( + ".submission-draft-addparty-search-results", + ); + if (fresh) { + fresh.querySelectorAll(".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 = `
      `; + if (state.addPartySearchHits.length === 0) { + html += `
    • ${escapeHtml( + isEN() ? "No matches." : "Keine Treffer.", + )}
    • `; + } else { + for (const hit of state.addPartySearchHits) { + const ref = hit.project_reference + ? `${escapeHtml(hit.project_reference)}` + : ""; + const role = hit.role + ? `${escapeHtml(hit.role)}` + : ""; + const rep = hit.representative + ? `${escapeHtml( + (isEN() ? "Repr.: " : "Vertr.: ") + hit.representative, + )}` + : ""; + html += `
    • `; + html += `${escapeHtml(hit.name)}`; + html += role; + html += rep; + html += ``; + html += escapeHtml(isEN() ? "Project: " : "Projekt: "); + html += `${escapeHtml(hit.project_title)}`; + html += ref; + html += ``; + html += `
    • `; + } + } + html += `
    `; + return html; +} + +async function onAddPartyManualSubmit( + side: PartySide, + payload: { name: string; role: string; representative: string }, +): Promise { + 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( + `.submission-draft-addparty-form[data-side="${side}"] button[type="submit"]`, + ); + if (submitBtn) submitBtn.disabled = true; + state.addPartyBusy = true; + try { + const body: Record = { 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 { + // 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 { + 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 { if (!state.view) return; const draftID = state.view.draft.id; diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index 1294c70..fa8223e 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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; diff --git a/frontend/src/submission-draft.tsx b/frontend/src/submission-draft.tsx index 5fd63da..62473c8 100644 --- a/frontend/src/submission-draft.tsx +++ b/frontend/src/submission-draft.tsx @@ -172,10 +172,13 @@ export function renderSubmissionDraft(): string { /> - {/* 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. */}
    50 { + limit = 25 + } + q := strings.TrimSpace(query) + args := []any{userID} + conds := []string{visibilityPredicatePositional("p", 1)} + if q != "" { + args = append(args, "%"+q+"%") + conds = append(conds, + fmt.Sprintf(`(pa.name ILIKE $%d OR COALESCE(pa.representative,'') ILIKE $%d)`, + len(args), len(args))) + } + args = append(args, limit) + sqlStr := ` + SELECT pa.id, pa.project_id, p.title AS project_title, + p.reference AS project_reference, + pa.name, pa.role, pa.representative + FROM paliad.parties pa + JOIN paliad.projects p ON p.id = pa.project_id + WHERE ` + strings.Join(conds, " AND ") + ` + ORDER BY pa.updated_at DESC + LIMIT $` + fmt.Sprintf("%d", len(args)) + hits := []PartySearchHit{} + if err := s.db.SelectContext(ctx, &hits, sqlStr, args...); err != nil { + return nil, fmt.Errorf("search parties: %w", err) + } + return hits, nil +} + // ListForProject returns all Parties for the Project, visibility-checked. func (s *PartyService) ListForProject(ctx context.Context, userID, projectID uuid.UUID) ([]models.Party, error) { if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil { diff --git a/scripts/gen-hl-skeleton-template/main.go b/scripts/gen-hl-skeleton-template/main.go index e75fe1b..e23c17a 100644 --- a/scripts/gen-hl-skeleton-template/main.go +++ b/scripts/gen-hl-skeleton-template/main.go @@ -315,11 +315,11 @@ func buildDocumentXML() string { body0(&b, "Rechtsgrundlage: {{procedural_event.legal_source_pretty}} ({{procedural_event.legal_source}})") body0(&b, "Typische Partei: {{procedural_event.primary_party}} · Schriftsatz-Typ: {{procedural_event.event_kind}}") - headerSubsection(&b, "Frist") - body0(&b, "Frist-Bezeichnung: {{deadline.title}}") - body0(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})") - body0(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}") - body0(&b, "Berechnet aus: {{deadline.computed_from}} · Quelle: {{deadline.source}}") + // t-paliad-287 — the dedicated Frist block was removed in 2026-05. + // {{deadline.*}} placeholders stay resolvable in the variable bag + // for custom templates that want them, but the default HL skeleton + // no longer renders them in the submission body: the deadline is + // internal/admin context and has no place in a court-bound document. heading(&b, "HLpat-Heading-H2", "I. Sachverhalt") body0(&b, "[Hier folgt der Sachverhalt. Diese Vorlage ist eine Skelett-Fassung — bitte gemäß Schriftsatz-Typ ({{procedural_event.name}}) ausformulieren.]") @@ -349,7 +349,6 @@ func buildDocumentXML() string { body1(&b, "EN long date: {{today.long_en}} · Today (bare alias): {{today}}") body1(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}") body1(&b, "Proceeding (DE): {{project.proceeding.name_de}}") - body1(&b, "Deadline EN long: {{deadline.due_date_long_en}}") body1(&b, "Procedural event name (DE): {{procedural_event.name_de}} · (EN): {{procedural_event.name_en}}") body1(&b, "Rule legacy aliases — name: {{rule.name}}, name_de: {{rule.name_de}}, name_en: {{rule.name_en}}") body1(&b, "Rule legacy aliases — code: {{rule.submission_code}}, legal_source: {{rule.legal_source}}, legal_source_pretty: {{rule.legal_source_pretty}}") diff --git a/scripts/gen-skeleton-submission-template/main.go b/scripts/gen-skeleton-submission-template/main.go index 175a9eb..c5d2bf4 100644 --- a/scripts/gen-skeleton-submission-template/main.go +++ b/scripts/gen-skeleton-submission-template/main.go @@ -137,14 +137,19 @@ const stylesXML = ` ` // Document body — a code-agnostic Schriftsatz skeleton: firm letterhead + -// case caption + parties + submission heading + deadline + a single -// neutral body block. Mirrors the variable bag from SubmissionVarsService -// (48 keys across firm.* / today.* / user.* / project.* / parties.* / -// rule.* / deadline.*) without baking in DE-LG-Klageerwiderung-specific -// structure. A lawyer customising this template for a UPC SoC, EPO -// opposition, or DPMA appeal replaces the [Schriftsatztext] block and -// renames the party labels — every placeholder still resolves regardless -// of the submission_code chosen. +// case caption + parties + submission heading + a single neutral body +// block. Mirrors the variable bag from SubmissionVarsService (firm.* / +// today.* / user.* / project.* / parties.* / rule.*) without baking in +// DE-LG-Klageerwiderung-specific structure. A lawyer customising this +// template for a UPC SoC, EPO opposition, or DPMA appeal replaces the +// [Schriftsatztext] block and renames the party labels — every +// placeholder still resolves regardless of the submission_code chosen. +// +// The {{deadline.*}} placeholders are deliberately NOT rendered by the +// default skeleton (t-paliad-287). The deadline is internal context for +// the lawyer, not text that belongs in a court-bound submission. The +// keys stay resolvable in the bag so a custom template can still +// reference them where it actually wants them. // // Every placeholder occupies its own run so the renderer's pass-1 // (format-preserving, single-run) substitution catches it. The @@ -194,11 +199,12 @@ func buildDocumentXML() string { plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})") plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}") - heading2(&b, "Frist") - plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}") - plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})") - plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}") - plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}") + // t-paliad-287 — the dedicated Frist block was removed in 2026-05. + // {{deadline.*}} placeholders stay resolvable in the variable bag + // (lawyer can still drop them into a custom paragraph) but the + // default skeleton no longer renders them in the submission body: + // the deadline is internal/admin context and has no place in a + // document going out to court. heading2(&b, "Schriftsatztext") plain(&b, "[Hier folgt der eigentliche Schriftsatztext. Diese Skelett-Vorlage enthält keine vorgefertigte Struktur — bitte gemäß Schriftsatz-Typ ({{rule.name}}) ergänzen.]") @@ -217,7 +223,7 @@ func buildDocumentXML() string { // the bare {{today}} alias. A lawyer customising the template can // delete this block; the renderer round-trips it cleanly today. heading2(&b, "Locale-aware variants (SKELETON)") - plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}") + plain(&b, "EN long date: {{today.long_en}}") plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}") plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}") plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")