Compare commits
22 Commits
938222d602
...
mai/farada
| Author | SHA1 | Date | |
|---|---|---|---|
| d834b36313 | |||
| 4092c889c4 | |||
| db1040968f | |||
| f292338919 | |||
| 2b240e7dd0 | |||
| c945cbd330 | |||
| 639ff4f672 | |||
| 264cc39a6b | |||
| 28d860a07d | |||
| d913f4fc30 | |||
| e091716f48 | |||
| 8763ab013c | |||
| e1e8db7fc9 | |||
| b746ec36c7 | |||
| 28aaafeb05 | |||
| f9331e9bb9 | |||
| e53bcf8cc2 | |||
| 68fcbc6fbf | |||
| 31e15d4b20 | |||
| a111a82640 | |||
| 63a9bedf7e | |||
| b8709b903d |
@@ -174,6 +174,9 @@ func main() {
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-315 Slice C — building-block library.
|
||||
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store
|
||||
// (Postgres bytea) backing the authoring surface.
|
||||
templateStoreSvc := services.NewPgTemplateStore(pool)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -190,6 +193,7 @@ func main() {
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
TemplateStore: templateStoreSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -252,7 +256,7 @@ func main() {
|
||||
// Akte-mode dual-write: project-backed scenarios write through
|
||||
// to paliad.projects.scenario_flags + paliad.deadlines via the
|
||||
// injected project + scenarioFlags services.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc)),
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool, projectSvc, services.NewScenarioFlagsService(pool, projectSvc), services.NewFristenrechnerService(rules, holidays, courts)),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
@@ -509,14 +509,14 @@ Helper function `paliad.can_see_scenario(scenario_id)` mirrors the existing `pal
|
||||
Transaction:
|
||||
1. INSERT into paliad.projects (carrying step-2 + step-3 payloads, + scenario notes)
|
||||
SET origin_scenario_id = <scenario.id>
|
||||
2. INSERT into paliad.project_parties from step-2 payload
|
||||
2. INSERT into paliad.parties from step-2 payload
|
||||
3. For each scenario_proceeding (depth-first, parent before child):
|
||||
a. INSERT scenario_flags as projects.scenario_flags (parent-level only;
|
||||
children become sub-projects via parent_project_id)
|
||||
b. For each filed scenario_event: INSERT paliad.deadlines row with
|
||||
status='done', completed_at=actual_date, audit_reason='via Litigation Builder promotion'
|
||||
c. For each planned scenario_event: INSERT paliad.deadlines row with
|
||||
status='open', due_date=computed (or actual_date override)
|
||||
status='pending', due_date=computed (or actual_date override)
|
||||
d. Skipped events: not inserted (no deadline row)
|
||||
4. UPDATE paliad.scenarios SET status='promoted', promoted_project_id=<new>
|
||||
5. Navigate to /projects/<new>
|
||||
@@ -636,7 +636,7 @@ Dead code to delete (verify with grep before deletion):
|
||||
- `frontend/src/client/verfahrensablauf.ts` (replaced by builder.ts orchestration)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.ts` (replaced by scenario-backed state)
|
||||
- `frontend/src/client/views/verfahrensablauf-state.test.ts`
|
||||
- `frontend/src/client/verfahrensablauf-detail-mode.ts` (replaced by per-triplet Detailgrad)
|
||||
- ~~`frontend/src/client/verfahrensablauf-detail-mode.ts`~~ — KEEP. Builder imports `filterByDetailMode` from it; per-triplet Detailgrad reuses this module.
|
||||
- Existing scratch tab content in `frontend/src/client/procedures.ts` (4-tab toggling logic, mode routing)
|
||||
|
||||
**Kept**:
|
||||
|
||||
@@ -18,6 +18,7 @@ import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderTemplatesAuthoring } from "./src/templates-authoring";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
@@ -255,6 +256,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/templates-authoring.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
@@ -382,6 +384,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
|
||||
370
frontend/src/client/builder-promote.ts
Normal file
370
frontend/src/client/builder-promote.ts
Normal file
@@ -0,0 +1,370 @@
|
||||
// Litigation Builder — promote-to-project wizard (m/paliad#153 PRD §2.4
|
||||
// + §5.4, B5).
|
||||
//
|
||||
// 3 steps: Bestätigen (read-only summary) → Parteien ergänzen (party
|
||||
// names) → Akte-Metadaten (title, reference, case number, our_side,
|
||||
// litigation parent, team). Commit POSTs the merged payload to
|
||||
// /api/builder/scenarios/{id}/promote — a single server-side transaction
|
||||
// (no partial promotions) that creates the paliad.projects 'case' row,
|
||||
// cascades deadlines, and flips the scenario to 'promoted'. On success
|
||||
// the wizard navigates to /projects/{new-id}.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
interface ProjectOption {
|
||||
id: string;
|
||||
title: string;
|
||||
type: string;
|
||||
reference?: string;
|
||||
}
|
||||
|
||||
interface UserOption {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
interface PartyRow {
|
||||
name: string;
|
||||
role: string;
|
||||
representative: string;
|
||||
}
|
||||
|
||||
export interface PromoteContext {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
proceedingLabel: string;
|
||||
filedCount: number;
|
||||
plannedCount: number;
|
||||
flagCount: number;
|
||||
extraTopLevel: number;
|
||||
defaultOurSide: "claimant" | "defendant" | null;
|
||||
defaultTitle: string;
|
||||
onSuccess: (projectId: string) => void;
|
||||
}
|
||||
|
||||
export async function openPromoteWizard(ctx: PromoteContext): Promise<void> {
|
||||
// Parallel fetch: litigation parents + HLC users (both optional pickers).
|
||||
const [parents, users] = await Promise.all([
|
||||
fetchProjects("litigation"),
|
||||
fetchUsers(),
|
||||
]);
|
||||
|
||||
let step = 1;
|
||||
const parties: PartyRow[] = [];
|
||||
const meta = {
|
||||
title: ctx.defaultTitle || "",
|
||||
reference: "",
|
||||
caseNumber: "",
|
||||
clientNumber: "",
|
||||
ourSide: (ctx.defaultOurSide ?? "") as "" | "claimant" | "defendant",
|
||||
parentId: "",
|
||||
teamIds: new Set<string>(),
|
||||
};
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
const modal = document.createElement("div");
|
||||
modal.className = "builder-modal builder-promote-modal";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-label", t("builder.promote.title"));
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
function stepHeader(): string {
|
||||
const steps = [
|
||||
t("builder.promote.step1"),
|
||||
t("builder.promote.step2"),
|
||||
t("builder.promote.step3"),
|
||||
];
|
||||
const dots = steps.map((label, i) => {
|
||||
const n = i + 1;
|
||||
const cls = n === step ? " is-active" : n < step ? " is-done" : "";
|
||||
return `<li class="builder-promote-step${cls}"><span class="builder-promote-step-n">${n}</span>` +
|
||||
`<span class="builder-promote-step-label">${escHtml(label)}</span></li>`;
|
||||
}).join("");
|
||||
return `<ol class="builder-promote-steps">${dots}</ol>`;
|
||||
}
|
||||
|
||||
function renderStep1(): string {
|
||||
const rows = [
|
||||
`<li><span>${escHtml(t("builder.promote.summary.proceeding"))}</span><strong>${escHtml(ctx.proceedingLabel)}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_filed"))}</span><strong>${ctx.filedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.events_planned"))}</span><strong>${ctx.plannedCount}</strong></li>`,
|
||||
`<li><span>${escHtml(t("builder.promote.summary.flags"))}</span><strong>${ctx.flagCount}</strong></li>`,
|
||||
].join("");
|
||||
const extra = ctx.extraTopLevel > 0
|
||||
? `<p class="builder-promote-note">${escHtml(
|
||||
t("builder.promote.summary.note_extra").replace("{n}", String(ctx.extraTopLevel)),
|
||||
)}</p>`
|
||||
: "";
|
||||
return (
|
||||
`<h3 class="builder-promote-section-title">${escHtml(t("builder.promote.summary.heading"))}</h3>` +
|
||||
`<ul class="builder-promote-summary">${rows}</ul>${extra}`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep2(): string {
|
||||
const list = parties.length === 0
|
||||
? `<p class="builder-promote-empty">${escHtml(t("builder.promote.parties.empty"))}</p>`
|
||||
: parties.map((p, i) => (
|
||||
`<div class="builder-promote-party" data-idx="${i}">` +
|
||||
`<input class="builder-promote-party-name" placeholder="${escAttr(t("builder.promote.parties.name"))}" value="${escAttr(p.name)}" />` +
|
||||
`<input class="builder-promote-party-role" placeholder="${escAttr(t("builder.promote.parties.role"))}" value="${escAttr(p.role)}" />` +
|
||||
`<input class="builder-promote-party-rep" placeholder="${escAttr(t("builder.promote.parties.representative"))}" value="${escAttr(p.representative)}" />` +
|
||||
`<button type="button" class="builder-promote-party-remove" aria-label="${escAttr(t("builder.promote.parties.remove"))}">×</button>` +
|
||||
`</div>`
|
||||
)).join("");
|
||||
return (
|
||||
`<p class="builder-promote-hint">${escHtml(t("builder.promote.parties.hint"))}</p>` +
|
||||
`<div class="builder-promote-parties">${list}</div>` +
|
||||
`<button type="button" class="builder-promote-party-add">${escHtml(t("builder.promote.parties.add"))}</button>`
|
||||
);
|
||||
}
|
||||
|
||||
function renderStep3(): string {
|
||||
const parentOpts = [`<option value="">${escHtml(t("builder.promote.meta.parent.none"))}</option>`]
|
||||
.concat(parents.map((p) => {
|
||||
const sel = p.id === meta.parentId ? " selected" : "";
|
||||
const label = p.reference ? `${p.title} (${p.reference})` : p.title;
|
||||
return `<option value="${escAttr(p.id)}"${sel}>${escHtml(label)}</option>`;
|
||||
})).join("");
|
||||
const sideSel = (v: string) => (meta.ourSide === v ? " selected" : "");
|
||||
const team = users
|
||||
.filter((u) => u.id !== ctx.ownerId)
|
||||
.slice(0, 40)
|
||||
.map((u) => {
|
||||
const checked = meta.teamIds.has(u.id) ? " checked" : "";
|
||||
const label = (u.display_name || "").trim()
|
||||
? ((u.office ? `${u.display_name} · ${u.office}` : u.display_name) as string)
|
||||
: u.email;
|
||||
return (
|
||||
`<label class="builder-promote-team-item">` +
|
||||
`<input type="checkbox" class="builder-promote-team-cb" data-user-id="${escAttr(u.id)}"${checked} />` +
|
||||
`<span>${escHtml(label)}</span></label>`
|
||||
);
|
||||
}).join("");
|
||||
return (
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.title"))}</span>` +
|
||||
`<input class="builder-promote-title" placeholder="${escAttr(t("builder.promote.meta.title.placeholder"))}" value="${escAttr(meta.title)}" /></label>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.reference"))}</span>` +
|
||||
`<input class="builder-promote-reference" value="${escAttr(meta.reference)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.case_number"))}</span>` +
|
||||
`<input class="builder-promote-casenumber" value="${escAttr(meta.caseNumber)}" /></label>` +
|
||||
`</div>` +
|
||||
`<div class="builder-promote-field-row">` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.client_number"))}</span>` +
|
||||
`<input class="builder-promote-clientnumber" value="${escAttr(meta.clientNumber)}" /></label>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.our_side"))}</span>` +
|
||||
`<select class="builder-promote-ourside">` +
|
||||
`<option value=""${sideSel("")}>${escHtml(t("builder.promote.meta.our_side.none"))}</option>` +
|
||||
`<option value="claimant"${sideSel("claimant")}>${escHtml(t("builder.promote.meta.our_side.claimant"))}</option>` +
|
||||
`<option value="defendant"${sideSel("defendant")}>${escHtml(t("builder.promote.meta.our_side.defendant"))}</option>` +
|
||||
`</select></label>` +
|
||||
`</div>` +
|
||||
`<label class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.parent"))}</span>` +
|
||||
`<select class="builder-promote-parent">${parentOpts}</select></label>` +
|
||||
`<div class="builder-promote-field"><span>${escHtml(t("builder.promote.meta.team"))}</span>` +
|
||||
`<p class="builder-promote-team-hint">${escHtml(t("builder.promote.meta.team.hint"))}</p>` +
|
||||
`<div class="builder-promote-team">${team}</div></div>` +
|
||||
`<p class="builder-promote-error" hidden></p>`
|
||||
);
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
let body = "";
|
||||
if (step === 1) body = renderStep1();
|
||||
else if (step === 2) body = renderStep2();
|
||||
else body = renderStep3();
|
||||
|
||||
const backLabel = t("builder.promote.back");
|
||||
const cancelLabel = t("builder.promote.cancel");
|
||||
const nextLabel = step < 3 ? t("builder.promote.next") : t("builder.promote.commit");
|
||||
|
||||
modal.innerHTML = `
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.promote.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(cancelLabel)}">×</button>
|
||||
</header>
|
||||
${stepHeader()}
|
||||
<div class="builder-promote-body">${body}</div>
|
||||
<footer class="builder-promote-footer">
|
||||
<button type="button" class="builder-promote-cancel">${escHtml(cancelLabel)}</button>
|
||||
<span class="builder-promote-footer-spacer"></span>
|
||||
${step > 1 ? `<button type="button" class="builder-promote-backbtn">${escHtml(backLabel)}</button>` : ""}
|
||||
<button type="button" class="builder-promote-nextbtn builder-action-btn--primary">${escHtml(nextLabel)}</button>
|
||||
</footer>`;
|
||||
wire();
|
||||
}
|
||||
|
||||
function captureStep2(): void {
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party").forEach((row) => {
|
||||
const idx = Number(row.getAttribute("data-idx"));
|
||||
if (Number.isNaN(idx) || !parties[idx]) return;
|
||||
parties[idx].name = (row.querySelector(".builder-promote-party-name") as HTMLInputElement).value;
|
||||
parties[idx].role = (row.querySelector(".builder-promote-party-role") as HTMLInputElement).value;
|
||||
parties[idx].representative = (row.querySelector(".builder-promote-party-rep") as HTMLInputElement).value;
|
||||
});
|
||||
}
|
||||
|
||||
function captureStep3(): void {
|
||||
const get = (sel: string) => (modal.querySelector(sel) as HTMLInputElement | null)?.value ?? "";
|
||||
meta.title = get(".builder-promote-title");
|
||||
meta.reference = get(".builder-promote-reference");
|
||||
meta.caseNumber = get(".builder-promote-casenumber");
|
||||
meta.clientNumber = get(".builder-promote-clientnumber");
|
||||
meta.ourSide = ((modal.querySelector(".builder-promote-ourside") as HTMLSelectElement)?.value || "") as typeof meta.ourSide;
|
||||
meta.parentId = (modal.querySelector(".builder-promote-parent") as HTMLSelectElement)?.value || "";
|
||||
meta.teamIds = new Set(
|
||||
Array.from(modal.querySelectorAll<HTMLInputElement>(".builder-promote-team-cb:checked"))
|
||||
.map((cb) => cb.getAttribute("data-user-id") || "")
|
||||
.filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
function wire(): void {
|
||||
modal.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-cancel")?.addEventListener("click", close);
|
||||
modal.querySelector(".builder-promote-backbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step === 3) captureStep3();
|
||||
step = Math.max(1, step - 1);
|
||||
render();
|
||||
});
|
||||
modal.querySelector(".builder-promote-nextbtn")?.addEventListener("click", () => {
|
||||
if (step === 2) captureStep2();
|
||||
if (step < 3) {
|
||||
step += 1;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
captureStep3();
|
||||
void commit();
|
||||
});
|
||||
if (step === 2) {
|
||||
modal.querySelector(".builder-promote-party-add")?.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
parties.push({ name: "", role: "", representative: "" });
|
||||
render();
|
||||
});
|
||||
modal.querySelectorAll<HTMLElement>(".builder-promote-party-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
captureStep2();
|
||||
const row = btn.closest(".builder-promote-party") as HTMLElement;
|
||||
const idx = Number(row?.getAttribute("data-idx"));
|
||||
if (!Number.isNaN(idx)) parties.splice(idx, 1);
|
||||
render();
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function commit(): Promise<void> {
|
||||
const errEl = modal.querySelector(".builder-promote-error") as HTMLElement | null;
|
||||
const showErr = (msg: string) => {
|
||||
if (errEl) {
|
||||
errEl.textContent = msg;
|
||||
errEl.hidden = false;
|
||||
}
|
||||
};
|
||||
if (!meta.title.trim()) {
|
||||
showErr(t("builder.promote.error.title_required"));
|
||||
return;
|
||||
}
|
||||
const nextBtn = modal.querySelector(".builder-promote-nextbtn") as HTMLButtonElement | null;
|
||||
if (nextBtn) nextBtn.disabled = true;
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
title: meta.title.trim(),
|
||||
reference: meta.reference.trim() || undefined,
|
||||
case_number: meta.caseNumber.trim() || undefined,
|
||||
client_number: meta.clientNumber.trim() || undefined,
|
||||
our_side: meta.ourSide || undefined,
|
||||
parent_id: meta.parentId || undefined,
|
||||
parties: parties
|
||||
.filter((p) => p.name.trim())
|
||||
.map((p) => ({
|
||||
name: p.name.trim(),
|
||||
role: p.role.trim() || undefined,
|
||||
representative: p.representative.trim() || undefined,
|
||||
})),
|
||||
team_members: Array.from(meta.teamIds).map((id) => ({ user_id: id })),
|
||||
};
|
||||
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(ctx.scenarioId) + "/promote",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
return;
|
||||
}
|
||||
const out = (await resp.json()) as { project_id: string };
|
||||
const body = modal.querySelector(".builder-promote-body") as HTMLElement;
|
||||
if (body) body.innerHTML = `<p class="builder-promote-success">${escHtml(t("builder.promote.success"))}</p>`;
|
||||
ctx.onSuccess(out.project_id);
|
||||
} catch {
|
||||
if (nextBtn) nextBtn.disabled = false;
|
||||
showErr(t("builder.promote.error.generic"));
|
||||
}
|
||||
}
|
||||
|
||||
render();
|
||||
document.body.appendChild(backdrop);
|
||||
(modal.querySelector(".builder-promote-nextbtn") as HTMLElement | null)?.focus();
|
||||
}
|
||||
|
||||
async function fetchProjects(type: string): Promise<ProjectOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=" + encodeURIComponent(type));
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ProjectOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUsers(): Promise<UserOption[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as UserOption[];
|
||||
return Array.isArray(data) ? data : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
229
frontend/src/client/builder-shares.ts
Normal file
229
frontend/src/client/builder-shares.ts
Normal file
@@ -0,0 +1,229 @@
|
||||
// Litigation Builder — share-with-team UI (m/paliad#153 PRD §2.5, B5).
|
||||
//
|
||||
// "Teilen" opens a modal with an HLC user picker. Picking a colleague +
|
||||
// "Schreibgeschützt teilen" POSTs a paliad.scenario_shares row; the owner
|
||||
// stays sole editor. Existing shares are listed with a revoke affordance.
|
||||
// The sharee sees the scenario in their "Geteilt mit mir" bucket (read-
|
||||
// only) — that side is handled by builder.ts.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ShareUser {
|
||||
id: string;
|
||||
email: string;
|
||||
display_name?: string;
|
||||
office?: string;
|
||||
}
|
||||
|
||||
export interface BuilderShareRow {
|
||||
id: string;
|
||||
scenario_id: string;
|
||||
shared_with_user_id: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface ShareModalOpts {
|
||||
scenarioId: string;
|
||||
ownerId?: string;
|
||||
currentShares: BuilderShareRow[];
|
||||
// Called after a successful add/revoke with the fresh share list so the
|
||||
// caller can update state.active.shares + re-render side panel buckets.
|
||||
onChanged: (shares: BuilderShareRow[]) => void;
|
||||
}
|
||||
|
||||
let allUsers: ShareUser[] | null = null;
|
||||
|
||||
async function fetchUsers(): Promise<ShareUser[]> {
|
||||
if (allUsers) return allUsers;
|
||||
try {
|
||||
const resp = await fetch("/api/users");
|
||||
if (!resp.ok) return [];
|
||||
const data = (await resp.json()) as ShareUser[];
|
||||
allUsers = Array.isArray(data) ? data : [];
|
||||
return allUsers;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function userLabel(u: ShareUser): string {
|
||||
const name = (u.display_name || "").trim();
|
||||
if (name) return u.office ? `${name} · ${u.office}` : name;
|
||||
return u.email;
|
||||
}
|
||||
|
||||
export async function openShareModal(opts: ShareModalOpts): Promise<void> {
|
||||
const users = await fetchUsers();
|
||||
let shares = [...opts.currentShares];
|
||||
|
||||
const backdrop = document.createElement("div");
|
||||
backdrop.className = "builder-modal-backdrop";
|
||||
backdrop.innerHTML = `
|
||||
<div class="builder-modal builder-share-modal" role="dialog" aria-modal="true"
|
||||
aria-label="${escAttr(t("builder.share.title"))}">
|
||||
<header class="builder-modal-header">
|
||||
<h2 class="builder-modal-title">${escHtml(t("builder.share.title"))}</h2>
|
||||
<button type="button" class="builder-modal-close" aria-label="${escAttr(t("builder.share.close"))}">×</button>
|
||||
</header>
|
||||
<p class="builder-modal-subtitle">${escHtml(t("builder.share.subtitle"))}</p>
|
||||
<div class="builder-share-pickerbox">
|
||||
<input type="search" class="builder-share-search" autocomplete="off" spellcheck="false"
|
||||
placeholder="${escAttr(t("builder.share.search.placeholder"))}" />
|
||||
<ul class="builder-share-results" aria-label="${escAttr(t("builder.share.title"))}"></ul>
|
||||
</div>
|
||||
<div class="builder-share-current">
|
||||
<h3 class="builder-share-current-title">${escHtml(t("builder.share.current.title"))}</h3>
|
||||
<ul class="builder-share-current-list"></ul>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
const close = () => {
|
||||
document.removeEventListener("keydown", onEsc, true);
|
||||
backdrop.remove();
|
||||
};
|
||||
const onEsc = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") close();
|
||||
};
|
||||
backdrop.addEventListener("click", (e) => {
|
||||
if (e.target === backdrop) close();
|
||||
});
|
||||
backdrop.querySelector(".builder-modal-close")?.addEventListener("click", close);
|
||||
document.addEventListener("keydown", onEsc, true);
|
||||
|
||||
const searchEl = backdrop.querySelector(".builder-share-search") as HTMLInputElement;
|
||||
const resultsEl = backdrop.querySelector(".builder-share-results") as HTMLElement;
|
||||
const currentEl = backdrop.querySelector(".builder-share-current-list") as HTMLElement;
|
||||
|
||||
function renderCurrent(): void {
|
||||
if (shares.length === 0) {
|
||||
currentEl.innerHTML = `<li class="builder-share-current-empty">${escHtml(t("builder.share.current.empty"))}</li>`;
|
||||
return;
|
||||
}
|
||||
currentEl.innerHTML = shares.map((sh) => {
|
||||
const u = users.find((x) => x.id === sh.shared_with_user_id);
|
||||
const label = u ? userLabel(u) : sh.shared_with_user_id;
|
||||
return (
|
||||
`<li class="builder-share-current-item" data-share-id="${escAttr(sh.id)}">` +
|
||||
`<span class="builder-share-current-name">${escHtml(label)}</span>` +
|
||||
`<button type="button" class="builder-share-revoke">${escHtml(t("builder.share.revoke"))}</button>` +
|
||||
`</li>`
|
||||
);
|
||||
}).join("");
|
||||
currentEl.querySelectorAll<HTMLElement>(".builder-share-current-item").forEach((li) => {
|
||||
const id = li.getAttribute("data-share-id");
|
||||
if (!id) return;
|
||||
li.querySelector(".builder-share-revoke")?.addEventListener("click", () => {
|
||||
void revoke(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderResults(): void {
|
||||
const q = searchEl.value.trim().toLowerCase();
|
||||
const sharedIds = new Set(shares.map((s) => s.shared_with_user_id));
|
||||
const matches = users
|
||||
.filter((u) => u.id !== opts.ownerId && !sharedIds.has(u.id))
|
||||
.filter((u) => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
(u.display_name || "").toLowerCase().includes(q) ||
|
||||
u.email.toLowerCase().includes(q) ||
|
||||
(u.office || "").toLowerCase().includes(q)
|
||||
);
|
||||
})
|
||||
.slice(0, 12);
|
||||
if (matches.length === 0) {
|
||||
resultsEl.innerHTML = `<li class="builder-share-result-empty">${escHtml(t("builder.share.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
resultsEl.innerHTML = matches.map((u) => (
|
||||
`<li class="builder-share-result" data-user-id="${escAttr(u.id)}">` +
|
||||
`<span class="builder-share-result-name">${escHtml(userLabel(u))}</span>` +
|
||||
`<button type="button" class="builder-share-add">${escHtml(t("builder.share.button"))}</button>` +
|
||||
`</li>`
|
||||
)).join("");
|
||||
resultsEl.querySelectorAll<HTMLElement>(".builder-share-result").forEach((li) => {
|
||||
const uid = li.getAttribute("data-user-id");
|
||||
if (!uid) return;
|
||||
li.querySelector(".builder-share-add")?.addEventListener("click", () => {
|
||||
void add(uid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function add(userId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) + "/shares",
|
||||
{
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ shared_with_user_id: userId }),
|
||||
},
|
||||
);
|
||||
if (!resp.ok) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
const row = (await resp.json()) as BuilderShareRow;
|
||||
shares = [...shares.filter((s) => s.id !== row.id), row];
|
||||
searchEl.value = "";
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
async function revoke(shareId: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/builder/scenarios/" + encodeURIComponent(opts.scenarioId) +
|
||||
"/shares/" + encodeURIComponent(shareId),
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
if (!resp.ok && resp.status !== 204) {
|
||||
flashError();
|
||||
return;
|
||||
}
|
||||
shares = shares.filter((s) => s.id !== shareId);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
opts.onChanged(shares);
|
||||
} catch {
|
||||
flashError();
|
||||
}
|
||||
}
|
||||
|
||||
function flashError(): void {
|
||||
const box = backdrop.querySelector(".builder-share-pickerbox") as HTMLElement;
|
||||
let err = box.querySelector(".builder-share-error") as HTMLElement | null;
|
||||
if (!err) {
|
||||
err = document.createElement("p");
|
||||
err.className = "builder-share-error";
|
||||
box.appendChild(err);
|
||||
}
|
||||
err.textContent = t("builder.share.error");
|
||||
}
|
||||
|
||||
searchEl.addEventListener("input", renderResults);
|
||||
renderResults();
|
||||
renderCurrent();
|
||||
document.body.appendChild(backdrop);
|
||||
searchEl.focus();
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
SCENARIO_FLAG_CHANGED_EVENT,
|
||||
type ScenarioFlagChangedDetail,
|
||||
} from "./scenario-flags";
|
||||
import { openShareModal, type BuilderShareRow } from "./builder-shares";
|
||||
import { openPromoteWizard } from "./builder-promote";
|
||||
|
||||
// Spawn map (PRD §3.6). When a scenario_flag transitions OFF → ON on a
|
||||
// parent proceeding, the builder auto-creates a child proceeding row
|
||||
@@ -125,6 +127,14 @@ type EntryMode = "cold" | "event" | "akte";
|
||||
interface State {
|
||||
active: BuilderScenarioDeep | null;
|
||||
list: BuilderScenario[];
|
||||
// B5 — scenarios shared read-only with me (the "Geteilt mit mir"
|
||||
// bucket). Disjoint from `list` (which is owner-scoped). readonly is
|
||||
// true when the active scenario is one of these OR is promoted —
|
||||
// either way every mutating affordance is disabled + a watermark shows.
|
||||
shared: BuilderScenario[];
|
||||
readonly: boolean;
|
||||
// owner display-name cache for the read-only watermark.
|
||||
ownerNameById: Map<string, string>;
|
||||
procTypes: ProceedingTypeMeta[];
|
||||
procTypesById: Map<number, ProceedingTypeMeta>;
|
||||
procTypesByCode: Map<string, ProceedingTypeMeta>;
|
||||
@@ -151,6 +161,9 @@ interface State {
|
||||
const state: State = {
|
||||
active: null,
|
||||
list: [],
|
||||
shared: [],
|
||||
readonly: false,
|
||||
ownerNameById: new Map(),
|
||||
procTypes: [],
|
||||
procTypesById: new Map(),
|
||||
procTypesByCode: new Map(),
|
||||
@@ -184,10 +197,29 @@ async function fetchJSON<T>(input: RequestInfo, init?: RequestInit): Promise<T |
|
||||
}
|
||||
|
||||
async function fetchScenarios(): Promise<BuilderScenario[]> {
|
||||
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=active");
|
||||
// B5 — pull every status so the side panel can bucket into Aktiv /
|
||||
// Promoted / Archiviert. The picker + recent list filter to active.
|
||||
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios?status=all");
|
||||
return Array.isArray(out) ? out : [];
|
||||
}
|
||||
|
||||
async function fetchSharedScenarios(): Promise<BuilderScenario[]> {
|
||||
const out = await fetchJSON<BuilderScenario[]>("/api/builder/scenarios/shared");
|
||||
return Array.isArray(out) ? out : [];
|
||||
}
|
||||
|
||||
// fetchOwnerNames lazily loads the user directory once so the read-only
|
||||
// watermark can render "Geteilt von <Name>". Failures degrade to showing
|
||||
// the owner uuid; the watermark is informational, not load-bearing.
|
||||
async function ensureOwnerNames(): Promise<void> {
|
||||
if (state.ownerNameById.size > 0) return;
|
||||
const users = await fetchJSON<Array<{ id: string; display_name?: string; email: string }>>("/api/users");
|
||||
if (!Array.isArray(users)) return;
|
||||
for (const u of users) {
|
||||
state.ownerNameById.set(u.id, (u.display_name || "").trim() || u.email);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchScenarioDeep(id: string): Promise<BuilderScenarioDeep | null> {
|
||||
return await fetchJSON<BuilderScenarioDeep>("/api/builder/scenarios/" + encodeURIComponent(id));
|
||||
}
|
||||
@@ -396,20 +428,32 @@ async function flushAutoSave(): Promise<void> {
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function refreshScenarioList(): Promise<void> {
|
||||
state.list = await fetchScenarios();
|
||||
// Owned (all statuses) + shared-with-me run in parallel.
|
||||
const [owned, shared] = await Promise.all([fetchScenarios(), fetchSharedScenarios()]);
|
||||
state.list = owned;
|
||||
state.shared = shared;
|
||||
renderScenarioList();
|
||||
renderScenarioPicker();
|
||||
}
|
||||
|
||||
function renderScenarioList(): void {
|
||||
const ul = document.getElementById("builder-scenario-list-active");
|
||||
// renderBucket paints one side-panel bucket UL + toggles its wrapper's
|
||||
// hidden attribute when empty. The Aktiv bucket always renders (shows the
|
||||
// empty hint); the others hide when they have no rows.
|
||||
function renderBucket(listId: string, wrapId: string | null, scenarios: BuilderScenario[], alwaysShow: boolean): void {
|
||||
const ul = document.getElementById(listId);
|
||||
if (!ul) return;
|
||||
if (state.list.length === 0) {
|
||||
ul.innerHTML = `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`;
|
||||
if (wrapId) {
|
||||
const wrap = document.getElementById(wrapId);
|
||||
if (wrap) wrap.hidden = !alwaysShow && scenarios.length === 0;
|
||||
}
|
||||
if (scenarios.length === 0) {
|
||||
ul.innerHTML = alwaysShow
|
||||
? `<li class="builder-scenario-list-empty" data-i18n="builder.panel.empty">Noch keine Szenarien.</li>`
|
||||
: "";
|
||||
return;
|
||||
}
|
||||
const activeId = state.active?.id;
|
||||
ul.innerHTML = state.list.map((sc) => {
|
||||
ul.innerHTML = scenarios.map((sc) => {
|
||||
const isActive = sc.id === activeId;
|
||||
return (
|
||||
`<li class="builder-scenario-list-item${isActive ? " is-active" : ""}"` +
|
||||
@@ -428,12 +472,32 @@ function renderScenarioList(): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderScenarioList(): void {
|
||||
renderBucket("builder-scenario-list-active", null,
|
||||
state.list.filter((s) => s.status === "active"), true);
|
||||
renderBucket("builder-scenario-list-shared", "builder-bucket-shared", state.shared, false);
|
||||
renderBucket("builder-scenario-list-promoted", "builder-bucket-promoted",
|
||||
state.list.filter((s) => s.status === "promoted"), false);
|
||||
renderBucket("builder-scenario-list-archived", "builder-bucket-archived",
|
||||
state.list.filter((s) => s.status === "archived"), false);
|
||||
}
|
||||
|
||||
function renderScenarioPicker(): void {
|
||||
const sel = document.getElementById("builder-scenario-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const placeholderText = t("builder.picker.placeholder");
|
||||
const opts: string[] = [`<option value="">${escHtml(placeholderText)}</option>`];
|
||||
for (const sc of state.list) {
|
||||
// Picker shows openable scenarios: active owned + shared-with-me.
|
||||
const pickable = [
|
||||
...state.list.filter((s) => s.status === "active"),
|
||||
...state.shared,
|
||||
];
|
||||
// Ensure the currently-active scenario is selectable even if promoted/
|
||||
// archived (so the dropdown reflects reality when one is open).
|
||||
if (state.active && !pickable.some((s) => s.id === state.active!.id)) {
|
||||
pickable.unshift(state.active);
|
||||
}
|
||||
for (const sc of pickable) {
|
||||
const selected = sc.id === state.active?.id ? " selected" : "";
|
||||
opts.push(`<option value="${escAttr(sc.id)}"${selected}>${escHtml(sc.name)}</option>`);
|
||||
}
|
||||
@@ -977,13 +1041,16 @@ async function loadScenario(id: string): Promise<void> {
|
||||
if (!Array.isArray(deep.shares)) deep.shares = [];
|
||||
state.active = deep;
|
||||
state.pending = {};
|
||||
// B5 — read-only when the scenario is shared with me (I'm not the
|
||||
// owner) or already promoted (server blocks mutations either way).
|
||||
const isShared = state.shared.some((s) => s.id === id);
|
||||
state.readonly = isShared || deep.status === "promoted";
|
||||
writeScenarioToUrl(id);
|
||||
setSaveState("saved");
|
||||
// Sync header inputs to scenario state.
|
||||
const stichtagInput = document.getElementById("builder-stichtag-input") as HTMLInputElement | null;
|
||||
if (stichtagInput && deep.stichtag) stichtagInput.value = deep.stichtag.slice(0, 10);
|
||||
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
|
||||
if (rename) rename.disabled = false;
|
||||
await applyScenarioChrome(deep, isShared);
|
||||
// B4 — reflect the scenario's Akte link on the page-header picker
|
||||
// + banner. Project-backed scenarios reveal the source project so
|
||||
// the user knows the builder writes feed into that Akte; non-Akte
|
||||
@@ -1040,6 +1107,114 @@ function openAddProceedingPicker(anchor: HTMLElement): void {
|
||||
});
|
||||
}
|
||||
|
||||
// applyScenarioChrome sets the page-header action buttons + read-only
|
||||
// watermark + body class for the freshly-loaded scenario. Editable
|
||||
// scenarios get rename / share / promote enabled; read-only ones (shared
|
||||
// with me, or promoted) lock all three and show the watermark. The body
|
||||
// class drives the CSS that neutralises in-canvas mutating affordances.
|
||||
async function applyScenarioChrome(deep: BuilderScenarioDeep, isShared: boolean): Promise<void> {
|
||||
document.body.classList.toggle("builder-readonly", state.readonly);
|
||||
const rename = document.getElementById("builder-rename-btn") as HTMLButtonElement | null;
|
||||
const share = document.getElementById("builder-share-btn") as HTMLButtonElement | null;
|
||||
const promote = document.getElementById("builder-promote-btn") as HTMLButtonElement | null;
|
||||
if (rename) rename.disabled = state.readonly;
|
||||
if (share) share.disabled = state.readonly;
|
||||
if (promote) promote.disabled = state.readonly;
|
||||
|
||||
const wm = document.getElementById("builder-readonly-watermark");
|
||||
if (!wm) return;
|
||||
if (!state.readonly) {
|
||||
wm.hidden = true;
|
||||
wm.textContent = "";
|
||||
return;
|
||||
}
|
||||
if (isShared) {
|
||||
await ensureOwnerNames();
|
||||
const owner = (deep.owner_id && state.ownerNameById.get(deep.owner_id)) || deep.owner_id || "?";
|
||||
wm.textContent = t("builder.readonly.watermark").replace("{owner}", owner);
|
||||
} else {
|
||||
// Promoted (owned) scenario — read-only reference.
|
||||
wm.textContent = t("builder.bucket.promoted");
|
||||
}
|
||||
wm.hidden = false;
|
||||
}
|
||||
|
||||
// resetScenarioChrome clears the page-header action state + watermark
|
||||
// when no scenario is active (cold-open / picker cleared).
|
||||
function resetScenarioChrome(): void {
|
||||
document.body.classList.remove("builder-readonly");
|
||||
for (const id of ["builder-rename-btn", "builder-share-btn", "builder-promote-btn"]) {
|
||||
const b = document.getElementById(id) as HTMLButtonElement | null;
|
||||
if (b) b.disabled = true;
|
||||
}
|
||||
const wm = document.getElementById("builder-readonly-watermark");
|
||||
if (wm) {
|
||||
wm.hidden = true;
|
||||
wm.textContent = "";
|
||||
}
|
||||
}
|
||||
|
||||
// onShareClick opens the share modal for the active (owned, editable)
|
||||
// scenario. PRD §2.5.
|
||||
function onShareClick(): void {
|
||||
if (!state.active || state.readonly) return;
|
||||
void openShareModal({
|
||||
scenarioId: state.active.id,
|
||||
ownerId: state.active.owner_id,
|
||||
currentShares: (state.active.shares as BuilderShareRow[]) ?? [],
|
||||
onChanged: (shares) => {
|
||||
if (state.active) state.active.shares = shares;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// onPromoteClick gathers the summary numbers for wizard step 1 and opens
|
||||
// the promote-to-project wizard. PRD §2.4. The primary proceeding (lowest-
|
||||
// ordinal top-level) + its spawned descendants are what the server
|
||||
// promotes into one case file; additional standalone proceedings are
|
||||
// reported in the summary as staying behind.
|
||||
function onPromoteClick(): void {
|
||||
if (!state.active || state.readonly) return;
|
||||
const sc = state.active;
|
||||
const topLevel = sc.proceedings
|
||||
.filter((p) => !p.parent_scenario_proceeding_id)
|
||||
.sort((a, b) => a.ordinal - b.ordinal);
|
||||
const primary = topLevel[0];
|
||||
if (!primary) {
|
||||
setSaveState("error");
|
||||
return;
|
||||
}
|
||||
// Collect primary + descendants to scope the event counts.
|
||||
const subtree = new Set<string>([primary.id]);
|
||||
for (let changed = true; changed; ) {
|
||||
changed = false;
|
||||
for (const p of sc.proceedings) {
|
||||
if (p.parent_scenario_proceeding_id && subtree.has(p.parent_scenario_proceeding_id) && !subtree.has(p.id)) {
|
||||
subtree.add(p.id);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
const evs = sc.events.filter((e) => subtree.has(e.scenario_proceeding_id));
|
||||
const meta = state.procTypesById.get(primary.proceeding_type_id);
|
||||
const label = meta ? meta.name || meta.code : "?";
|
||||
const defaultParty = (primary.primary_party as "claimant" | "defendant" | undefined) ?? null;
|
||||
void openPromoteWizard({
|
||||
scenarioId: sc.id,
|
||||
ownerId: sc.owner_id,
|
||||
proceedingLabel: label,
|
||||
filedCount: evs.filter((e) => e.state === "filed").length,
|
||||
plannedCount: evs.filter((e) => e.state === "planned").length,
|
||||
flagCount: Object.values(primary.scenario_flags).filter((v) => v === true).length,
|
||||
extraTopLevel: topLevel.length - 1,
|
||||
defaultOurSide: defaultParty,
|
||||
defaultTitle: sc.name && sc.name !== "Unbenanntes Szenario" ? sc.name : "",
|
||||
onSuccess: (projectId) => {
|
||||
window.location.href = "/projects/" + encodeURIComponent(projectId);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function onRenameClick(): Promise<void> {
|
||||
if (!state.active) return;
|
||||
const current = state.active.name;
|
||||
@@ -1194,6 +1369,12 @@ function wirePageHeader(): void {
|
||||
document.getElementById("builder-rename-btn")?.addEventListener("click", () => {
|
||||
void onRenameClick();
|
||||
});
|
||||
document.getElementById("builder-share-btn")?.addEventListener("click", () => {
|
||||
onShareClick();
|
||||
});
|
||||
document.getElementById("builder-promote-btn")?.addEventListener("click", () => {
|
||||
onPromoteClick();
|
||||
});
|
||||
document.getElementById("builder-new-scenario-btn")?.addEventListener("click", () => {
|
||||
void onNewScenarioClick();
|
||||
});
|
||||
@@ -1206,7 +1387,9 @@ function wirePageHeader(): void {
|
||||
if (id) void loadScenario(id);
|
||||
else {
|
||||
state.active = null;
|
||||
state.readonly = false;
|
||||
writeScenarioToUrl(null);
|
||||
resetScenarioChrome();
|
||||
renderCanvas();
|
||||
}
|
||||
});
|
||||
@@ -1247,11 +1430,80 @@ function wireScenarioFlagsListener(): void {
|
||||
});
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// B6 — mobile basic-read guard (PRD §10 + §7.1)
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Mutating affordances that get gated on narrow viewports. Reading
|
||||
// (open a scenario from the side panel, switch via the picker, search,
|
||||
// switch entry mode) stays fully functional — only scenario-mutating
|
||||
// taps are intercepted.
|
||||
const MOBILE_MUTATING_SELECTOR = [
|
||||
"#builder-rename-btn",
|
||||
"#builder-share-btn",
|
||||
"#builder-promote-btn",
|
||||
"#builder-new-scenario-btn",
|
||||
"#builder-cta-new",
|
||||
"#builder-add-proceeding-btn",
|
||||
".builder-triplet-host button",
|
||||
".builder-triplet-host input",
|
||||
".builder-triplet-host select",
|
||||
].join(",");
|
||||
|
||||
function isNarrowViewport(): boolean {
|
||||
return typeof window.matchMedia === "function" &&
|
||||
window.matchMedia("(max-width: 640px)").matches;
|
||||
}
|
||||
|
||||
let mobileToastTimer: number | null = null;
|
||||
|
||||
function showMobileBlockedToast(): void {
|
||||
let toast = document.getElementById("builder-mobile-toast");
|
||||
if (!toast) {
|
||||
toast = document.createElement("div");
|
||||
toast.id = "builder-mobile-toast";
|
||||
toast.className = "builder-mobile-toast";
|
||||
toast.setAttribute("role", "status");
|
||||
toast.setAttribute("aria-live", "polite");
|
||||
document.body.appendChild(toast);
|
||||
}
|
||||
toast.textContent = t("builder.mobile.blocked");
|
||||
toast.classList.add("is-visible");
|
||||
if (mobileToastTimer !== null) window.clearTimeout(mobileToastTimer);
|
||||
mobileToastTimer = window.setTimeout(() => {
|
||||
document.getElementById("builder-mobile-toast")?.classList.remove("is-visible");
|
||||
}, 2600);
|
||||
}
|
||||
|
||||
// wireMobileGuard intercepts taps on mutating affordances when the
|
||||
// viewport is narrow (<640px), surfacing the "Auf größerem Bildschirm
|
||||
// öffnen" toast instead of running the action. Capture phase so it
|
||||
// pre-empts the control's own (bubble-phase) handler; calling
|
||||
// preventDefault on a checkbox click also blocks its toggle + change
|
||||
// event. Desktop is untouched — the guard early-returns unless the media
|
||||
// query matches, so the desktop interaction code paths stay identical
|
||||
// (PRD §10).
|
||||
function wireMobileGuard(): void {
|
||||
document.addEventListener(
|
||||
"click",
|
||||
(e) => {
|
||||
if (!isNarrowViewport()) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target || !target.closest(MOBILE_MUTATING_SELECTOR)) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
showMobileBlockedToast();
|
||||
},
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
export async function mountBuilder(): Promise<void> {
|
||||
wirePageHeader();
|
||||
wireModeBar();
|
||||
wireSearch();
|
||||
wireScenarioFlagsListener();
|
||||
wireMobileGuard();
|
||||
// Parallel boot — proceeding type catalog (Forum=UPC, Kind=proceeding)
|
||||
// for the add-proceeding picker + scenario_flag_catalog for the
|
||||
// per-triplet flag strip. PRD §0.4 — UPC v1.
|
||||
@@ -1277,8 +1529,15 @@ export async function mountBuilder(): Promise<void> {
|
||||
});
|
||||
|
||||
const requested = readScenarioFromUrl();
|
||||
if (requested && state.list.some((s) => s.id === requested)) {
|
||||
await loadScenario(requested);
|
||||
// Deep-link auto-load covers both owned scenarios and ones shared with
|
||||
// me (so a "Geteilt mit mir" link opens straight into the read-only
|
||||
// view, not the cold-open canvas). loadScenario derives read-only from
|
||||
// state.shared, so the share watermark + locked affordances apply.
|
||||
const isKnown =
|
||||
requested != null &&
|
||||
(state.list.some((s) => s.id === requested) || state.shared.some((s) => s.id === requested));
|
||||
if (isKnown) {
|
||||
await loadScenario(requested as string);
|
||||
} else {
|
||||
renderCanvas();
|
||||
}
|
||||
|
||||
@@ -1,507 +0,0 @@
|
||||
// Fristenrechner overhaul Mode A — "Direkt suchen" (design §3.1).
|
||||
//
|
||||
// Power-user surface: a filter strip (Forum / Verfahren / Was passierte /
|
||||
// Partei) over a free-text search box over a result list of
|
||||
// procedural_events. Clicking a row locks the event as the trigger and
|
||||
// transitions to the shared result view (S2). Inbox channel chip lives
|
||||
// as a secondary "Erweitert" toggle per design §3.3 — picking CMS / beA
|
||||
// / Postal auto-sets the Forum chip.
|
||||
//
|
||||
// Section-split visual hierarchy per m §11.Q3: filter strip on top
|
||||
// ("Filter (eingrenzen)") with the four chip groups, search box and
|
||||
// result list below — clicking a result row IS the qualifier action.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/search?kind=events.
|
||||
// Mirrors services.EventSearchResponse server-side.
|
||||
interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
description?: string;
|
||||
primary_party?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
concept_id?: string;
|
||||
score: number;
|
||||
}
|
||||
|
||||
interface EventSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProceedingChip {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
// Module-local state — single Mode A surface at a time.
|
||||
interface ModeAState {
|
||||
jurisdiction: string; // "" = Alle
|
||||
proc: string; // proceeding_types.code, "" = Alle
|
||||
eventKind: string; // "" = Alle
|
||||
party: string; // "" = Alle (Mode A's filter semantics, §11.Q8)
|
||||
q: string; // free-text query
|
||||
inbox: string; // CMS / bea / postal / "" — secondary, design §3.3
|
||||
inboxOpen: boolean;
|
||||
}
|
||||
|
||||
const state: ModeAState = {
|
||||
jurisdiction: "",
|
||||
proc: "",
|
||||
eventKind: "",
|
||||
party: "",
|
||||
q: "",
|
||||
inbox: "",
|
||||
inboxOpen: false,
|
||||
};
|
||||
|
||||
// Debounce token for search input — avoid hammering the server on
|
||||
// every keystroke.
|
||||
let searchSeq = 0;
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
// Chip data — static. Forum and event-kind are closed-set per design;
|
||||
// party is closed-set with "Beide" option (Mode A is filter mode,
|
||||
// §11.Q8). Inbox secondary chip set per §3.3.
|
||||
const FORUMS = ["UPC", "DE", "EPA", "DPMA"] as const;
|
||||
const EVENT_KINDS = ["filing", "hearing", "decision", "order"] as const;
|
||||
const PARTIES = ["claimant", "defendant", "both"] as const;
|
||||
|
||||
// Forum auto-derivation from inbox chip per §3.3: CMS → UPC, beA → DE,
|
||||
// Postal → no narrowing (postal arrives at every jurisdiction).
|
||||
const INBOX_TO_FORUM: Record<string, string> = {
|
||||
cms: "UPC",
|
||||
bea: "DE",
|
||||
postal: "",
|
||||
};
|
||||
|
||||
// MODE_A_HOST_ID is the DOM id of the container Mode A renders into.
|
||||
// The mode shell (fristenrechner-result.mountModeShell) creates this
|
||||
// element under the overhaul root and hands it to Mode A; Mode A
|
||||
// otherwise has no opinion about its placement on the page.
|
||||
const MODE_A_HOST_ID = "fristen-overhaul-mode-host";
|
||||
|
||||
export function isModeASurfaceMounted(): boolean {
|
||||
return !!document.getElementById("fristen-mode-a-root");
|
||||
}
|
||||
|
||||
// mountModeA renders the Mode A surface into the overhaul root. Reads
|
||||
// initial state from URL params so deep links restore the previous
|
||||
// filter / search state.
|
||||
export async function mountModeA(): Promise<void> {
|
||||
const root = document.getElementById(MODE_A_HOST_ID);
|
||||
if (!root) return;
|
||||
|
||||
// Hydrate state from URL.
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.jurisdiction = (params.get("forum") || "").toUpperCase();
|
||||
state.proc = params.get("pt") || "";
|
||||
state.eventKind = params.get("kind") || "";
|
||||
state.party = params.get("party") || "";
|
||||
state.q = params.get("q") || "";
|
||||
|
||||
renderShell();
|
||||
await loadProceedingChips();
|
||||
void runSearch();
|
||||
}
|
||||
|
||||
// renderShell builds the Mode A markup. Idempotent re-call from the
|
||||
// boot path; row-level rewrites use renderResults / renderFilterStrip
|
||||
// for finer-grained updates.
|
||||
function renderShell(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.innerHTML = `
|
||||
<div id="fristen-mode-a-root" class="fristen-mode-a-root">
|
||||
<section class="fristen-mode-a-filters" aria-label="${escAttr(t("deadlines.overhaul.modea.filters.label"))}">
|
||||
<header class="fristen-mode-a-filters-header">
|
||||
<span class="fristen-mode-a-filters-title">${escHtml(t("deadlines.overhaul.modea.filters.heading"))}</span>
|
||||
</header>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="forum">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.forum"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-forum"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="proc">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.proc"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-proc"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="kind">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.kind"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-kind"></div>
|
||||
</div>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="party">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.party"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-party"></div>
|
||||
</div>
|
||||
<details class="fristen-mode-a-inbox" ${state.inboxOpen ? "open" : ""}>
|
||||
<summary class="fristen-mode-a-inbox-summary">${escHtml(t("deadlines.overhaul.modea.inbox.summary"))}</summary>
|
||||
<div class="fristen-mode-a-chip-row" data-axis="inbox">
|
||||
<span class="fristen-mode-a-axis-label">${escHtml(t("deadlines.overhaul.modea.axis.inbox"))}</span>
|
||||
<div class="fristen-mode-a-chips" id="fristen-mode-a-chips-inbox"></div>
|
||||
</div>
|
||||
</details>
|
||||
</section>
|
||||
|
||||
<section class="fristen-mode-a-search" aria-label="${escAttr(t("deadlines.overhaul.modea.search.label"))}">
|
||||
<div class="fristen-mode-a-search-input-wrap">
|
||||
<svg class="fristen-mode-a-search-icon" width="18" height="18" viewBox="0 0 24 24" fill="none"
|
||||
stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<circle cx="11" cy="11" r="7"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
<input type="search" id="fristen-mode-a-search-input"
|
||||
class="fristen-mode-a-search-input"
|
||||
autocomplete="off" spellcheck="false"
|
||||
data-i18n-placeholder="deadlines.overhaul.modea.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
value="${escAttr(state.q)}" />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="fristen-mode-a-results" aria-label="${escAttr(t("deadlines.overhaul.modea.results.label"))}">
|
||||
<header class="fristen-mode-a-results-header">
|
||||
<span class="fristen-mode-a-results-title">${escHtml(t("deadlines.overhaul.modea.results.heading"))}</span>
|
||||
<span class="fristen-mode-a-results-count" id="fristen-mode-a-results-count"></span>
|
||||
</header>
|
||||
<ul class="fristen-mode-a-result-list" id="fristen-mode-a-result-list" role="listbox" aria-live="polite"></ul>
|
||||
</section>
|
||||
</div>
|
||||
`;
|
||||
|
||||
renderForumChips();
|
||||
renderKindChips();
|
||||
renderPartyChips();
|
||||
renderInboxChips();
|
||||
// Proceeding chips render later, after fetch.
|
||||
|
||||
// Wire search input.
|
||||
const input = document.getElementById("fristen-mode-a-search-input") as HTMLInputElement | null;
|
||||
if (input) {
|
||||
input.addEventListener("input", () => {
|
||||
state.q = input.value;
|
||||
scheduleSearch(180);
|
||||
});
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
scheduleSearch(0);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Filter-strip chip renderers ----------------------------------------
|
||||
|
||||
function renderForumChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-forum");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("forum", "", t("deadlines.overhaul.modea.chip.all"), state.jurisdiction === ""),
|
||||
...FORUMS.map((j) => chipHtml("forum", j, j, state.jurisdiction === j)),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.value || "";
|
||||
state.jurisdiction = v;
|
||||
// Forum change invalidates the proc pick if it falls outside.
|
||||
state.proc = "";
|
||||
syncUrl();
|
||||
renderForumChips();
|
||||
void loadProceedingChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderKindChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-kind");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("kind", "", t("deadlines.overhaul.modea.chip.all"), state.eventKind === ""),
|
||||
...EVENT_KINDS.map((k) => chipHtml("kind", k, t(`deadlines.overhaul.kind.${k}` as never), state.eventKind === k, eventKindIconForChip(k))),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.eventKind = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderKindChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderPartyChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-party");
|
||||
if (!host) return;
|
||||
const chips = [
|
||||
chipHtml("party", "", t("deadlines.overhaul.modea.chip.all"), state.party === ""),
|
||||
...PARTIES.map((p) => chipHtml("party", p, t(`deadlines.party.${p}` as never), state.party === p)),
|
||||
];
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.party = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderPartyChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderInboxChips(): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-inbox");
|
||||
if (!host) return;
|
||||
const opts = [
|
||||
{ v: "", label: t("deadlines.overhaul.modea.chip.all") },
|
||||
{ v: "cms", label: "CMS" },
|
||||
{ v: "bea", label: "beA" },
|
||||
{ v: "postal", label: t("deadlines.overhaul.modea.inbox.postal") },
|
||||
];
|
||||
host.innerHTML = opts.map((o) => chipHtml("inbox", o.v, o.label, state.inbox === o.v)).join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const v = btn.dataset.value || "";
|
||||
state.inbox = v;
|
||||
// Auto-nudge forum from inbox per design §3.3.
|
||||
const nudge = INBOX_TO_FORUM[v];
|
||||
if (nudge !== undefined && nudge !== "") {
|
||||
state.jurisdiction = nudge;
|
||||
state.proc = "";
|
||||
renderForumChips();
|
||||
void loadProceedingChips();
|
||||
}
|
||||
renderInboxChips();
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Proceeding chips — dynamic fetch.
|
||||
|
||||
let lastProcFetchKey = "";
|
||||
|
||||
async function loadProceedingChips(): Promise<void> {
|
||||
const host = document.getElementById("fristen-mode-a-chips-proc");
|
||||
if (!host) return;
|
||||
const key = `j=${state.jurisdiction}`;
|
||||
if (lastProcFetchKey === key) return; // cached for current jurisdiction
|
||||
lastProcFetchKey = key;
|
||||
host.innerHTML = `<span class="fristen-mode-a-chip-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</span>`;
|
||||
|
||||
const url = new URL("/api/tools/proceeding-types", window.location.origin);
|
||||
url.searchParams.set("kind", "proceeding");
|
||||
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
|
||||
|
||||
let chips: ProceedingChip[] = [];
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (resp.ok) {
|
||||
const data = (await resp.json()) as ProceedingChip[] | null;
|
||||
chips = data || [];
|
||||
}
|
||||
} catch {
|
||||
// Soft-fail: chip strip just hides; search still runs without
|
||||
// proceeding narrowing.
|
||||
}
|
||||
|
||||
renderProceedingChips(chips);
|
||||
}
|
||||
|
||||
function renderProceedingChips(chips: ProceedingChip[]): void {
|
||||
const host = document.getElementById("fristen-mode-a-chips-proc");
|
||||
if (!host) return;
|
||||
const lang = getLang();
|
||||
if (chips.length === 0) {
|
||||
host.innerHTML = `<span class="fristen-mode-a-chip-empty">${escHtml(t("deadlines.overhaul.modea.no_proceedings"))}</span>`;
|
||||
return;
|
||||
}
|
||||
const rendered = [
|
||||
chipHtml("proc", "", t("deadlines.overhaul.modea.chip.all"), state.proc === ""),
|
||||
...chips.map((c) => {
|
||||
const label = lang === "en" ? c.nameEN || c.name : c.name;
|
||||
return chipHtml("proc", c.code, label, state.proc === c.code, undefined, c.code);
|
||||
}),
|
||||
];
|
||||
host.innerHTML = rendered.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
state.proc = btn.dataset.value || "";
|
||||
syncUrl();
|
||||
renderProceedingChips(chips);
|
||||
scheduleSearch(0);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Search ------------------------------------------------------------
|
||||
|
||||
function scheduleSearch(delayMs: number): void {
|
||||
if (searchTimer !== null) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(() => {
|
||||
searchTimer = null;
|
||||
void runSearch();
|
||||
}, delayMs);
|
||||
}
|
||||
|
||||
async function runSearch(): Promise<void> {
|
||||
searchSeq++;
|
||||
const mySeq = searchSeq;
|
||||
|
||||
const list = document.getElementById("fristen-mode-a-result-list");
|
||||
const count = document.getElementById("fristen-mode-a-results-count");
|
||||
if (!list || !count) return;
|
||||
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-loading">${escHtml(t("deadlines.overhaul.modea.loading"))}</li>`;
|
||||
count.textContent = "";
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("kind", "events");
|
||||
if (state.q) url.searchParams.set("q", state.q);
|
||||
if (state.jurisdiction) url.searchParams.set("jurisdiction", state.jurisdiction);
|
||||
if (state.proc) url.searchParams.set("proc", state.proc);
|
||||
if (state.eventKind) url.searchParams.set("event_kind", state.eventKind);
|
||||
if (state.party) url.searchParams.set("party", state.party);
|
||||
|
||||
let data: EventSearchResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
if (mySeq === searchSeq) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as EventSearchResponse;
|
||||
} catch {
|
||||
if (mySeq === searchSeq) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-error">${escHtml(t("deadlines.overhaul.modea.search_error"))}</li>`;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (mySeq !== searchSeq) return; // stale response
|
||||
|
||||
renderResults(data);
|
||||
}
|
||||
|
||||
function renderResults(data: EventSearchResponse): void {
|
||||
const list = document.getElementById("fristen-mode-a-result-list");
|
||||
const count = document.getElementById("fristen-mode-a-results-count");
|
||||
if (!list || !count) return;
|
||||
count.textContent = tDyn("deadlines.overhaul.modea.results.count").replace("{n}", String(data.total));
|
||||
|
||||
if (data.events.length === 0) {
|
||||
list.innerHTML = `<li class="fristen-mode-a-result-empty">${escHtml(t("deadlines.overhaul.modea.no_results"))}</li>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const lang = getLang();
|
||||
list.innerHTML = data.events.map((e) => {
|
||||
const name = lang === "en" ? e.name_en || e.name_de : e.name_de;
|
||||
const pt = e.proceeding_type;
|
||||
const ptName = lang === "en" ? pt.name_en || pt.name_de : pt.name_de;
|
||||
const icon = eventKindIconForChip(e.event_kind);
|
||||
const followUps = tDyn("deadlines.overhaul.modea.row.followups").replace("{n}", String(e.follow_up_count));
|
||||
const juris = pt.jurisdiction || "";
|
||||
return `
|
||||
<li class="fristen-mode-a-result" data-event-code="${escAttr(e.code)}" tabindex="0" role="option">
|
||||
<span class="fristen-mode-a-result-icon" aria-hidden="true">${icon}</span>
|
||||
<div class="fristen-mode-a-result-body">
|
||||
<div class="fristen-mode-a-result-title">${escHtml(name)}</div>
|
||||
<div class="fristen-mode-a-result-meta">
|
||||
<span class="fristen-mode-a-result-pt">${escHtml(pt.code)}</span>
|
||||
<span class="fristen-mode-a-result-pt-name">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-mode-a-result-juris">${escHtml(juris)}</span>` : ""}
|
||||
<span class="fristen-mode-a-result-followups">${escHtml(followUps)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="fristen-mode-a-result-cta" aria-hidden="true">→</span>
|
||||
</li>
|
||||
`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".fristen-mode-a-result").forEach((li) => {
|
||||
li.addEventListener("click", () => commitEvent(li.dataset.eventCode || ""));
|
||||
li.addEventListener("keydown", (e) => {
|
||||
const k = (e as KeyboardEvent).key;
|
||||
if (k === "Enter" || k === " ") {
|
||||
e.preventDefault();
|
||||
commitEvent(li.dataset.eventCode || "");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Commit — user picked a result; lock the event as trigger and
|
||||
// transition to the §4 result view (S2).
|
||||
function commitEvent(code: string): void {
|
||||
if (!code) return;
|
||||
// Reflect in URL before re-mounting so the result view's deep link
|
||||
// is consistent.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", code);
|
||||
// Preserve project / forum / kind filters so a back-navigation
|
||||
// brings Mode A back with the same filters.
|
||||
history.pushState(null, "", url.pathname + url.search + url.hash);
|
||||
void mountResultView({
|
||||
eventRef: code,
|
||||
party: state.party || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
// Helpers -----------------------------------------------------------
|
||||
|
||||
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
|
||||
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
|
||||
const t = title ? ` title="${escAttr(title)}"` : "";
|
||||
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
|
||||
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${t}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
|
||||
}
|
||||
|
||||
function eventKindIconForChip(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
default: return "🔍";
|
||||
}
|
||||
}
|
||||
|
||||
// syncUrl writes the active filter set into the URL so the deep link
|
||||
// restores Mode A in the same state.
|
||||
function syncUrl(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
setOrClear(url, "forum", state.jurisdiction);
|
||||
setOrClear(url, "pt", state.proc);
|
||||
setOrClear(url, "kind", state.eventKind);
|
||||
setOrClear(url, "party", state.party);
|
||||
setOrClear(url, "q", state.q);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
function setOrClear(url: URL, key: string, val: string): void {
|
||||
if (val) url.searchParams.set(key, val);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
@@ -1,72 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
defaultChecked,
|
||||
groupFollowUps,
|
||||
type FollowUpRule,
|
||||
} from "./fristenrechner-result";
|
||||
|
||||
// Pure helpers exercised here; the DOM-driven render path is covered
|
||||
// by the live page test path (S2 is mount-on-deep-link, S3+S4 add the
|
||||
// entry-mode UIs in later slices).
|
||||
|
||||
function mk(partial: Partial<FollowUpRule>): FollowUpRule {
|
||||
return {
|
||||
rule_id: "r" + Math.random().toString(36).slice(2, 8),
|
||||
event_code: "evt",
|
||||
title_de: "Frist",
|
||||
title_en: "Deadline",
|
||||
priority: "mandatory",
|
||||
is_court_set: false,
|
||||
is_spawn: false,
|
||||
is_bilateral: false,
|
||||
has_condition: false,
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
describe("groupFollowUps — design §4.2 priority+condition buckets", () => {
|
||||
test("groups by priority; conditional takes precedence over priority", () => {
|
||||
const rows = [
|
||||
mk({ priority: "mandatory" }),
|
||||
mk({ priority: "recommended" }),
|
||||
mk({ priority: "optional" }),
|
||||
mk({ priority: "mandatory", has_condition: true }), // → conditional
|
||||
mk({ priority: "optional", has_condition: true }), // → conditional
|
||||
];
|
||||
const g = groupFollowUps(rows);
|
||||
expect(g.mandatory.length).toBe(1);
|
||||
expect(g.recommended.length).toBe(1);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.conditional.length).toBe(2);
|
||||
});
|
||||
|
||||
test("unknown priority falls through to optional", () => {
|
||||
const g = groupFollowUps([mk({ priority: "informational" })]);
|
||||
expect(g.optional.length).toBe(1);
|
||||
expect(g.mandatory.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("defaultChecked — pre-checks mandatory + recommended, not conditional/court-set", () => {
|
||||
test("mandatory rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory" }))).toBe(true);
|
||||
});
|
||||
test("recommended rules pre-checked", () => {
|
||||
expect(defaultChecked(mk({ priority: "recommended" }))).toBe(true);
|
||||
});
|
||||
test("optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional" }))).toBe(false);
|
||||
});
|
||||
test("conditional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", has_condition: true }))).toBe(false);
|
||||
});
|
||||
test("court-set rules unchecked even when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_court_set: true }))).toBe(false);
|
||||
});
|
||||
test("spawned rules pre-checked when mandatory", () => {
|
||||
expect(defaultChecked(mk({ priority: "mandatory", is_spawn: true }))).toBe(true);
|
||||
});
|
||||
test("spawned optional rules unchecked", () => {
|
||||
expect(defaultChecked(mk({ priority: "optional", is_spawn: true }))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,693 +0,0 @@
|
||||
// Fristenrechner overhaul — shared result view (design §4).
|
||||
//
|
||||
// Given a locked trigger event + a trigger date, this module renders
|
||||
// the result surface: a sticky trigger card on top, then four priority
|
||||
// groups (mandatory / recommended / optional / conditional) of follow-up
|
||||
// rules with computed dates, then a write-back footer that calls the
|
||||
// existing POST /api/projects/{id}/deadlines/bulk.
|
||||
//
|
||||
// The two future entry paths (Mode A "Direkt suchen" in S3, Mode B
|
||||
// wizard in S4) both land here once they've identified a trigger
|
||||
// procedural_event. S2 mounts the surface under `?overhaul=1` and is
|
||||
// deep-linkable on its own via `?overhaul=1&event=<code>&trigger_date=…`.
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
|
||||
// Wire shape from GET /api/tools/fristenrechner/follow-ups. Mirrors
|
||||
// services.FollowUpsResponse server-side.
|
||||
export interface FollowUpRule {
|
||||
rule_id: string;
|
||||
event_code: string;
|
||||
title_de: string;
|
||||
title_en: string;
|
||||
priority: string;
|
||||
primary_party?: string;
|
||||
// m/paliad#149 Phase 2 S1 (design §2.4) — true when the rule's
|
||||
// primary_party is the side opposite the perspective. Drives the
|
||||
// Gegenseitig badge + muted style + unchecked default.
|
||||
is_cross_party: boolean;
|
||||
duration_value?: number;
|
||||
duration_unit?: string;
|
||||
timing?: string;
|
||||
due_date?: string;
|
||||
original_due_date?: string;
|
||||
was_adjusted?: boolean;
|
||||
is_court_set: boolean;
|
||||
is_spawn: boolean;
|
||||
is_bilateral: boolean;
|
||||
has_condition: boolean;
|
||||
rule_code?: string;
|
||||
legal_source?: string;
|
||||
legal_source_display?: string;
|
||||
legal_source_url?: string;
|
||||
notes_de?: string;
|
||||
notes_en?: string;
|
||||
spawn_label?: string;
|
||||
spawn_proceeding_code?: string;
|
||||
concept_id?: string;
|
||||
}
|
||||
|
||||
export interface FollowUpsResponse {
|
||||
trigger: {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
anchor_rule_id: string;
|
||||
};
|
||||
trigger_date: string;
|
||||
party?: string;
|
||||
follow_ups: FollowUpRule[];
|
||||
}
|
||||
|
||||
// Per-rule UI state — checkbox, optional date override.
|
||||
interface RuleSelection {
|
||||
checked: boolean;
|
||||
override?: string;
|
||||
}
|
||||
|
||||
// Module-local state. Single result view at a time; the surface
|
||||
// re-renders in place when the user changes the trigger date or
|
||||
// re-locks a different event.
|
||||
let currentResponse: FollowUpsResponse | null = null;
|
||||
const selections = new Map<string, RuleSelection>();
|
||||
let currentProjectId: string | null = null;
|
||||
|
||||
// Public API ----------------------------------------------------------
|
||||
|
||||
// isOverhaulMode reports whether the page is in overhaul mode.
|
||||
// After Slice S5 (t-paliad-323), overhaul is the default; the legacy
|
||||
// wizard / row-stack / cascade is only reachable via `?legacy=1` for
|
||||
// a two-week deprecation window. The `?overhaul=1` deep links from
|
||||
// S2-S4 still work — they're now redundant with the default but kept
|
||||
// alive so bookmarks don't 302 / lose state.
|
||||
export function isOverhaulMode(): boolean {
|
||||
return new URLSearchParams(window.location.search).get("legacy") !== "1";
|
||||
}
|
||||
|
||||
// resolveProjectId reads the active Akte from the URL query string.
|
||||
// Returns null when in kontextfrei mode (no project picked).
|
||||
function resolveProjectId(): string | null {
|
||||
const p = new URLSearchParams(window.location.search).get("project");
|
||||
return p && p.length > 0 ? p : null;
|
||||
}
|
||||
|
||||
// MODE_TAB_KEYS — the two entry-mode tabs landed by S3 + S4. S2's deep
|
||||
// link path bypasses these (jumps straight to the result view via
|
||||
// ?event=); the tabs appear when no event is locked yet.
|
||||
export type ModeTab = "search" | "wizard";
|
||||
|
||||
// mountModeShell renders the mode-tab pair under the page header and
|
||||
// hosts whichever mode panel is currently active. Called from the boot
|
||||
// path when no `?event=` is present. S3 wires Mode A; S4 will add
|
||||
// Mode B and the actual tab switching.
|
||||
export async function mountModeShell(activeTab: ModeTab): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
// Defer to the per-mode module to render into the root. The tab
|
||||
// strip itself is a small header above the mode panel — for S3 we
|
||||
// render the shell + Mode A in one shot.
|
||||
// S4 will replace this with a real tab switcher.
|
||||
const tabs = `
|
||||
<nav class="fristen-mode-tabs" role="tablist" aria-label="${escAttr(t("deadlines.overhaul.modes.label"))}">
|
||||
<button type="button" class="fristen-mode-tab${activeTab === "search" ? " is-active" : ""}" role="tab"
|
||||
aria-selected="${activeTab === "search"}" data-tab="search">
|
||||
<span class="fristen-mode-tab-icon" aria-hidden="true">⚡</span>
|
||||
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.search"))}</span>
|
||||
</button>
|
||||
<button type="button" class="fristen-mode-tab${activeTab === "wizard" ? " is-active" : ""}" role="tab"
|
||||
aria-selected="${activeTab === "wizard"}" data-tab="wizard">
|
||||
<span class="fristen-mode-tab-icon" aria-hidden="true">🧭</span>
|
||||
<span class="fristen-mode-tab-label">${escHtml(t("deadlines.overhaul.modes.wizard"))}</span>
|
||||
</button>
|
||||
</nav>
|
||||
<div id="fristen-overhaul-mode-host"></div>
|
||||
`;
|
||||
root.innerHTML = tabs;
|
||||
|
||||
// Wire tab switching. S3 only has Mode A wired; Mode B is a
|
||||
// placeholder until S4.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-mode-tab").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const tab = (btn.dataset.tab || "search") as ModeTab;
|
||||
void mountModeShell(tab);
|
||||
});
|
||||
});
|
||||
|
||||
// Mount the active mode panel into the host. S3 only routes "search";
|
||||
// "wizard" renders a placeholder until S4 lands.
|
||||
const host = document.getElementById("fristen-overhaul-mode-host");
|
||||
if (!host) return;
|
||||
if (activeTab === "search") {
|
||||
// Lazy import to keep the bundle layered and avoid a circular ref
|
||||
// between fristenrechner-result.ts ↔ fristenrechner-mode-a.ts.
|
||||
const mod = await import("./fristenrechner-mode-a");
|
||||
await mod.mountModeA();
|
||||
} else {
|
||||
const mod = await import("./fristenrechner-wizard");
|
||||
await mod.mountWizard();
|
||||
}
|
||||
}
|
||||
|
||||
// MountOptions configures the surface entry. Both entry-mode paths
|
||||
// (Mode A in S3, Mode B in S4) call mount() with the event reference
|
||||
// that the user committed.
|
||||
export interface MountOptions {
|
||||
// eventRef is the procedural_event code OR its uuid OR the anchor
|
||||
// sequencing_rule id. Resolved server-side; the wire returns the
|
||||
// canonical code so the URL bookmark is stable.
|
||||
eventRef: string;
|
||||
// triggerDate is YYYY-MM-DD. Defaults to today when omitted.
|
||||
triggerDate?: string;
|
||||
// party is "claimant" | "defendant"; mode A may pass "both" or
|
||||
// "court". When omitted, follow-ups are returned without party
|
||||
// narrowing.
|
||||
party?: string;
|
||||
// courtId selects the holiday calendar for the per-rule date
|
||||
// adjustment. Optional.
|
||||
courtId?: string;
|
||||
}
|
||||
|
||||
// mountResultView fetches /follow-ups and renders the result surface
|
||||
// into the host container. Re-callable: replaces previous state.
|
||||
export async function mountResultView(opts: MountOptions): Promise<void> {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root) return;
|
||||
root.hidden = false;
|
||||
|
||||
const triggerDate = opts.triggerDate || todayIso();
|
||||
currentProjectId = resolveProjectId();
|
||||
|
||||
// Show a quick "loading…" placeholder so the user sees something
|
||||
// immediately, even on a cold fetch.
|
||||
root.innerHTML = `<div class="fristen-overhaul-loading">${escHtml(t("deadlines.overhaul.loading"))}</div>`;
|
||||
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", opts.eventRef);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
if (opts.party) url.searchParams.set("party", opts.party);
|
||||
if (opts.courtId) url.searchParams.set("court_id", opts.courtId);
|
||||
|
||||
let data: FollowUpsResponse;
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(body.error || t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
data = (await resp.json()) as FollowUpsResponse;
|
||||
} catch (err) {
|
||||
root.innerHTML = `<div class="fristen-overhaul-error">${escHtml(t("deadlines.overhaul.load_error"))}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
currentResponse = data;
|
||||
selections.clear();
|
||||
for (const r of data.follow_ups) {
|
||||
selections.set(r.rule_id, { checked: defaultChecked(r) });
|
||||
}
|
||||
|
||||
renderSurface();
|
||||
// Reflect the canonical event code + trigger date in the URL so the
|
||||
// deep-link survives a reload.
|
||||
syncUrlState(data.trigger.code, data.trigger_date);
|
||||
}
|
||||
|
||||
// Render --------------------------------------------------------------
|
||||
|
||||
function renderSurface(): void {
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (!root || !currentResponse) return;
|
||||
|
||||
const lang = getLang();
|
||||
const trig = currentResponse.trigger;
|
||||
const triggerName = lang === "en" ? trig.name_en || trig.name_de : trig.name_de;
|
||||
const ptName = lang === "en" ? trig.proceeding_type.name_en || trig.proceeding_type.name_de : trig.proceeding_type.name_de;
|
||||
const juris = trig.proceeding_type.jurisdiction || "";
|
||||
const kindIcon = eventKindIcon(trig.event_kind);
|
||||
|
||||
const triggerCard = `
|
||||
<section class="fristen-overhaul-trigger" aria-label="${escAttr(t("deadlines.overhaul.trigger.label"))}">
|
||||
<header class="fristen-overhaul-trigger-header">
|
||||
<span class="fristen-overhaul-kind-icon" aria-hidden="true">${kindIcon}</span>
|
||||
<h2 class="fristen-overhaul-trigger-title">${escHtml(triggerName)}</h2>
|
||||
</header>
|
||||
<div class="fristen-overhaul-trigger-meta">
|
||||
<span class="fristen-overhaul-trigger-code">${escHtml(trig.code)}</span>
|
||||
<span class="fristen-overhaul-trigger-pt">${escHtml(ptName)}</span>
|
||||
${juris ? `<span class="fristen-overhaul-trigger-juris">${escHtml(juris)}</span>` : ""}
|
||||
</div>
|
||||
<div class="fristen-overhaul-trigger-date">
|
||||
<label for="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-label">
|
||||
${escHtml(t("deadlines.overhaul.trigger.date"))}
|
||||
</label>
|
||||
<input type="date" id="fristen-overhaul-trigger-date" class="fristen-overhaul-trigger-date-input"
|
||||
value="${escAttr(currentResponse.trigger_date)}" />
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
|
||||
const groups = groupFollowUps(currentResponse.follow_ups);
|
||||
const groupHtml = renderGroups(groups, lang);
|
||||
|
||||
const nudge = currentProjectId
|
||||
? ""
|
||||
: `<div class="fristen-overhaul-nudge">${escHtml(t("deadlines.overhaul.nudge.no_project"))}</div>`;
|
||||
|
||||
const footer = currentProjectId
|
||||
? renderFooter()
|
||||
: "";
|
||||
|
||||
root.innerHTML = `
|
||||
${triggerCard}
|
||||
${nudge}
|
||||
<section class="fristen-overhaul-groups" aria-label="${escAttr(t("deadlines.overhaul.followups.label"))}">
|
||||
${groupHtml}
|
||||
</section>
|
||||
${footer}
|
||||
<div class="fristen-overhaul-msg" id="fristen-overhaul-msg" role="status" aria-live="polite"></div>
|
||||
`;
|
||||
|
||||
wireSurfaceEvents();
|
||||
}
|
||||
|
||||
export interface GroupedFollowUps {
|
||||
mandatory: FollowUpRule[];
|
||||
recommended: FollowUpRule[];
|
||||
optional: FollowUpRule[];
|
||||
conditional: FollowUpRule[];
|
||||
}
|
||||
|
||||
// groupFollowUps splits the wire list into the four visible groups per
|
||||
// design §4.2. Conditional (sr.condition_expr IS NOT NULL) takes
|
||||
// precedence over the priority bucket so a "nur wenn CCR" mandatory
|
||||
// rule renders under Conditional with the gating language visible.
|
||||
export function groupFollowUps(rows: FollowUpRule[]): GroupedFollowUps {
|
||||
const out: GroupedFollowUps = { mandatory: [], recommended: [], optional: [], conditional: [] };
|
||||
for (const r of rows) {
|
||||
if (r.has_condition) {
|
||||
out.conditional.push(r);
|
||||
continue;
|
||||
}
|
||||
switch (r.priority) {
|
||||
case "mandatory":
|
||||
out.mandatory.push(r);
|
||||
break;
|
||||
case "recommended":
|
||||
out.recommended.push(r);
|
||||
break;
|
||||
case "optional":
|
||||
out.optional.push(r);
|
||||
break;
|
||||
default:
|
||||
// unknown / informational — fold into optional so the row is at
|
||||
// least visible. Future Phase 2 'informational' tier gets a
|
||||
// dedicated bucket once seeded.
|
||||
out.optional.push(r);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function renderGroups(groups: GroupedFollowUps, lang: "de" | "en"): string {
|
||||
const blocks: string[] = [];
|
||||
if (groups.mandatory.length > 0) {
|
||||
blocks.push(renderGroup("mandatory", t("deadlines.overhaul.group.mandatory"), groups.mandatory, lang));
|
||||
}
|
||||
if (groups.recommended.length > 0) {
|
||||
blocks.push(renderGroup("recommended", t("deadlines.overhaul.group.recommended"), groups.recommended, lang));
|
||||
}
|
||||
if (groups.optional.length > 0) {
|
||||
blocks.push(renderGroup("optional", t("deadlines.overhaul.group.optional"), groups.optional, lang));
|
||||
}
|
||||
if (groups.conditional.length > 0) {
|
||||
blocks.push(renderGroup("conditional", t("deadlines.overhaul.group.conditional"), groups.conditional, lang));
|
||||
}
|
||||
if (blocks.length === 0) {
|
||||
return `<div class="fristen-overhaul-empty">${escHtml(t("deadlines.overhaul.empty"))}</div>`;
|
||||
}
|
||||
return blocks.join("");
|
||||
}
|
||||
|
||||
function renderGroup(slug: string, label: string, rows: FollowUpRule[], lang: "de" | "en"): string {
|
||||
const items = rows.map((r) => renderRule(r, lang)).join("");
|
||||
return `
|
||||
<div class="fristen-overhaul-group fristen-overhaul-group--${escAttr(slug)}">
|
||||
<h3 class="fristen-overhaul-group-title">${escHtml(label)}</h3>
|
||||
<ul class="fristen-overhaul-rule-list">
|
||||
${items}
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderRule(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
const sel = selections.get(r.rule_id);
|
||||
const checked = sel ? sel.checked : defaultChecked(r);
|
||||
const dateOverride = sel?.override;
|
||||
const computedDate = r.due_date || "";
|
||||
const effectiveDate = dateOverride || computedDate;
|
||||
const disabled = r.is_court_set || (r.is_spawn && !r.due_date);
|
||||
|
||||
// Duration phrase: "3 Monate" / "14 Tage" — language-aware.
|
||||
const durationPhrase = formatDurationPhrase(r, lang);
|
||||
const dateCell = r.is_court_set
|
||||
? `<span class="fristen-overhaul-rule-court-set">${escHtml(t("deadlines.court.set"))}</span>`
|
||||
: effectiveDate
|
||||
? `<span class="fristen-overhaul-rule-date" data-rule-id="${escAttr(r.rule_id)}">${escHtml(formatDateForLang(effectiveDate, lang))}</span>`
|
||||
: `<span class="fristen-overhaul-rule-date fristen-overhaul-rule-date--unknown">—</span>`;
|
||||
|
||||
const partyBadge = r.primary_party
|
||||
? `<span class="fristen-overhaul-rule-party fristen-overhaul-rule-party--${escAttr(r.primary_party)}">${escHtml(t(`deadlines.party.${r.primary_party}` as never))}</span>`
|
||||
: "";
|
||||
|
||||
const sourceBadge = r.legal_source_display
|
||||
? r.legal_source_url
|
||||
? `<a class="fristen-overhaul-rule-source" href="${escAttr(r.legal_source_url)}" target="_blank" rel="noreferrer">${escHtml(r.legal_source_display)}</a>`
|
||||
: `<span class="fristen-overhaul-rule-source">${escHtml(r.legal_source_display)}</span>`
|
||||
: r.rule_code
|
||||
? `<span class="fristen-overhaul-rule-source">${escHtml(r.rule_code)}</span>`
|
||||
: "";
|
||||
|
||||
const spawnBadge = r.is_spawn
|
||||
? `<span class="fristen-overhaul-rule-spawn" title="${escAttr(t("deadlines.overhaul.spawn.tooltip"))}">${escHtml(t("deadlines.overhaul.spawn.badge"))}${r.spawn_proceeding_code ? ` · ${escHtml(r.spawn_proceeding_code)}` : ""}</span>`
|
||||
: "";
|
||||
|
||||
const condBadge = r.has_condition
|
||||
? `<span class="fristen-overhaul-rule-cond">${escHtml(t("deadlines.overhaul.condition.badge"))}</span>`
|
||||
: "";
|
||||
|
||||
const crossPartyBadge = r.is_cross_party
|
||||
? `<span class="fristen-overhaul-rule-crossparty" title="${escAttr(t("deadlines.overhaul.crossparty.tooltip"))}">${escHtml(t("deadlines.overhaul.crossparty.badge"))}</span>`
|
||||
: "";
|
||||
|
||||
const notesHtml = notes
|
||||
? `<details class="fristen-overhaul-rule-notes"><summary>${escHtml(t("deadlines.overhaul.notes.summary"))}</summary><p>${escHtml(notes)}</p></details>`
|
||||
: "";
|
||||
|
||||
const editBtn = r.is_court_set || r.is_spawn || !computedDate
|
||||
? ""
|
||||
: `<button type="button" class="fristen-overhaul-rule-edit-date" data-rule-id="${escAttr(r.rule_id)}" title="${escAttr(t("deadlines.overhaul.edit_date.title"))}" aria-label="${escAttr(t("deadlines.overhaul.edit_date.title"))}">${escHtml(t("deadlines.overhaul.edit_date.label"))}</button>`;
|
||||
|
||||
return `
|
||||
<li class="fristen-overhaul-rule${disabled ? " is-disabled" : ""}${r.is_cross_party ? " is-cross-party" : ""}" data-rule-id="${escAttr(r.rule_id)}">
|
||||
<label class="fristen-overhaul-rule-check">
|
||||
<input type="checkbox" data-rule-id="${escAttr(r.rule_id)}"
|
||||
${checked ? "checked" : ""} ${disabled ? "disabled" : ""} />
|
||||
<span class="visually-hidden">${escHtml(t("deadlines.overhaul.select_rule"))}</span>
|
||||
</label>
|
||||
<div class="fristen-overhaul-rule-body">
|
||||
<div class="fristen-overhaul-rule-title-row">
|
||||
<span class="fristen-overhaul-rule-title">${escHtml(title)}</span>
|
||||
${spawnBadge}
|
||||
${condBadge}
|
||||
${crossPartyBadge}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-meta-row">
|
||||
${durationPhrase ? `<span class="fristen-overhaul-rule-duration">${escHtml(durationPhrase)}</span>` : ""}
|
||||
${partyBadge}
|
||||
${sourceBadge}
|
||||
</div>
|
||||
${notesHtml}
|
||||
</div>
|
||||
<div class="fristen-overhaul-rule-date-cell">
|
||||
${dateCell}
|
||||
${editBtn}
|
||||
</div>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderFooter(): string {
|
||||
const selectedCount = countSelected();
|
||||
return `
|
||||
<footer class="fristen-overhaul-footer" id="fristen-overhaul-footer">
|
||||
<span class="fristen-overhaul-footer-count" id="fristen-overhaul-footer-count">
|
||||
${escHtml(tDyn("deadlines.overhaul.footer.count").replace("{n}", String(selectedCount)))}
|
||||
</span>
|
||||
<button type="button" class="fristen-overhaul-footer-cta btn-primary btn-cta-lime"
|
||||
id="fristen-overhaul-write-back"
|
||||
${selectedCount === 0 ? "disabled" : ""}>
|
||||
${escHtml(t("deadlines.overhaul.footer.cta"))}
|
||||
</button>
|
||||
</footer>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring --------------------------------------------------------
|
||||
|
||||
function wireSurfaceEvents(): void {
|
||||
// Trigger-date change → re-fetch with new date.
|
||||
const dateInput = document.getElementById("fristen-overhaul-trigger-date") as HTMLInputElement | null;
|
||||
if (dateInput && currentResponse) {
|
||||
dateInput.addEventListener("change", () => {
|
||||
if (!currentResponse) return;
|
||||
const newDate = dateInput.value;
|
||||
if (!newDate) return;
|
||||
void mountResultView({
|
||||
eventRef: currentResponse.trigger.code,
|
||||
triggerDate: newDate,
|
||||
party: currentResponse.party,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Checkbox toggles → update selections + footer count.
|
||||
const root = document.getElementById("fristen-overhaul-root");
|
||||
if (root) {
|
||||
root.querySelectorAll<HTMLInputElement>(".fristen-overhaul-rule-check input[type=checkbox]").forEach((cb) => {
|
||||
cb.addEventListener("change", () => {
|
||||
const id = cb.dataset.ruleId || "";
|
||||
const sel = selections.get(id) ?? { checked: cb.checked };
|
||||
sel.checked = cb.checked;
|
||||
selections.set(id, sel);
|
||||
refreshFooterCount();
|
||||
});
|
||||
});
|
||||
|
||||
// Per-rule date override.
|
||||
root.querySelectorAll<HTMLButtonElement>(".fristen-overhaul-rule-edit-date").forEach((btn) => {
|
||||
btn.addEventListener("click", () => editRuleDate(btn));
|
||||
});
|
||||
}
|
||||
|
||||
// Write-back CTA.
|
||||
const cta = document.getElementById("fristen-overhaul-write-back");
|
||||
if (cta) cta.addEventListener("click", () => void submitWriteBack());
|
||||
}
|
||||
|
||||
function editRuleDate(btn: HTMLButtonElement): void {
|
||||
const ruleId = btn.dataset.ruleId || "";
|
||||
const rule = currentResponse?.follow_ups.find((r) => r.rule_id === ruleId);
|
||||
if (!rule) return;
|
||||
const sel = selections.get(ruleId) ?? { checked: defaultChecked(rule) };
|
||||
const current = sel.override || rule.due_date || todayIso();
|
||||
|
||||
const dateCell = btn.parentElement;
|
||||
if (!dateCell) return;
|
||||
const dateSpan = dateCell.querySelector<HTMLSpanElement>(".fristen-overhaul-rule-date");
|
||||
if (!dateSpan) return;
|
||||
|
||||
const input = document.createElement("input");
|
||||
input.type = "date";
|
||||
input.value = current;
|
||||
input.className = "fristen-overhaul-rule-date-input";
|
||||
dateSpan.replaceWith(input);
|
||||
btn.disabled = true;
|
||||
input.focus();
|
||||
|
||||
const commit = () => {
|
||||
const newDate = input.value;
|
||||
if (newDate && newDate !== current) {
|
||||
sel.override = newDate;
|
||||
selections.set(ruleId, sel);
|
||||
}
|
||||
renderSurface();
|
||||
};
|
||||
input.addEventListener("blur", commit, { once: true });
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if ((e as KeyboardEvent).key === "Enter") {
|
||||
e.preventDefault();
|
||||
input.blur();
|
||||
} else if ((e as KeyboardEvent).key === "Escape") {
|
||||
renderSurface();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function refreshFooterCount(): void {
|
||||
const countEl = document.getElementById("fristen-overhaul-footer-count");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const n = countSelected();
|
||||
if (countEl) {
|
||||
countEl.textContent = tDyn("deadlines.overhaul.footer.count").replace("{n}", String(n));
|
||||
}
|
||||
if (cta) cta.disabled = n === 0;
|
||||
}
|
||||
|
||||
function countSelected(): number {
|
||||
let n = 0;
|
||||
if (!currentResponse) return 0;
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
if (r.is_court_set) continue;
|
||||
// Cross-party rows are unconditionally excluded from write-back
|
||||
// (design §2.4). Even if the user manually checks the box, they
|
||||
// describe what the opponent files — not Akte work for our side.
|
||||
if (r.is_cross_party) continue;
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (sel?.checked) n++;
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
// Write-back ----------------------------------------------------------
|
||||
|
||||
async function submitWriteBack(): Promise<void> {
|
||||
if (!currentResponse) return;
|
||||
if (!currentProjectId) return;
|
||||
const msg = document.getElementById("fristen-overhaul-msg");
|
||||
const cta = document.getElementById("fristen-overhaul-write-back") as HTMLButtonElement | null;
|
||||
const lang = getLang();
|
||||
|
||||
const deadlines: Array<Record<string, unknown>> = [];
|
||||
for (const r of currentResponse.follow_ups) {
|
||||
const sel = selections.get(r.rule_id);
|
||||
if (!sel?.checked) continue;
|
||||
if (r.is_court_set) continue;
|
||||
// Skip cross-party rows even if checked — they describe opposing-
|
||||
// side filings and don't belong in our side's Akte deadline set
|
||||
// (design §2.4, write-back exclusion).
|
||||
if (r.is_cross_party) continue;
|
||||
const dueDate = sel.override || r.due_date;
|
||||
if (!dueDate) continue;
|
||||
const title = lang === "en" ? r.title_en || r.title_de : r.title_de;
|
||||
const notes = lang === "en" ? r.notes_en || r.notes_de : r.notes_de;
|
||||
deadlines.push({
|
||||
title,
|
||||
rule_code: r.rule_code || undefined,
|
||||
due_date: dueDate,
|
||||
original_due_date: r.original_due_date || r.due_date || undefined,
|
||||
source: "fristenrechner",
|
||||
rule_id: r.rule_id,
|
||||
notes: notes || undefined,
|
||||
audit_reason: auditReason(),
|
||||
});
|
||||
}
|
||||
|
||||
if (deadlines.length === 0 || !msg || !cta) return;
|
||||
cta.disabled = true;
|
||||
msg.textContent = "";
|
||||
msg.className = "fristen-overhaul-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(currentProjectId)}/deadlines/bulk`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ deadlines }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await resp.json().catch(() => ({}) as { error?: string });
|
||||
msg.textContent = body.error || t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
return;
|
||||
}
|
||||
msg.innerHTML = `${escHtml(t("deadlines.save.success"))} <a href="/deadlines?project_id=${encodeURIComponent(currentProjectId)}">${escHtml(t("deadlines.save.success.link"))}</a>`;
|
||||
msg.className = "fristen-overhaul-msg form-msg-ok";
|
||||
setTimeout(() => {
|
||||
if (cta) cta.disabled = false;
|
||||
}, 1500);
|
||||
} catch {
|
||||
msg.textContent = t("deadlines.save.error");
|
||||
msg.className = "fristen-overhaul-msg form-msg-error";
|
||||
cta.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// audit reason per design §11.Q12: "Aus Fristenrechner — Trigger: {name} ({date})".
|
||||
function auditReason(): string {
|
||||
if (!currentResponse) return "";
|
||||
const name = currentResponse.trigger.name_de;
|
||||
const date = currentResponse.trigger_date;
|
||||
return `Aus Fristenrechner — Trigger: ${name} (${date})`;
|
||||
}
|
||||
|
||||
// Helpers -------------------------------------------------------------
|
||||
|
||||
export function defaultChecked(r: FollowUpRule): boolean {
|
||||
// Cross-party rows are unchecked by default — they describe what the
|
||||
// OTHER side files. They render to honestly show the workflow, but
|
||||
// the Akte write-back excludes them unconditionally (design §2.4).
|
||||
if (r.is_cross_party) return false;
|
||||
if (r.is_court_set) return false;
|
||||
if (r.is_spawn) return r.priority === "mandatory";
|
||||
if (r.has_condition) return false;
|
||||
return r.priority === "mandatory" || r.priority === "recommended";
|
||||
}
|
||||
|
||||
function formatDurationPhrase(r: FollowUpRule, lang: "de" | "en"): string {
|
||||
if (!r.duration_value || !r.duration_unit) return "";
|
||||
const unitDE: Record<string, string> = {
|
||||
days: "Tage",
|
||||
months: "Monate",
|
||||
weeks: "Wochen",
|
||||
years: "Jahre",
|
||||
};
|
||||
const unitEN: Record<string, string> = {
|
||||
days: "days",
|
||||
months: "months",
|
||||
weeks: "weeks",
|
||||
years: "years",
|
||||
};
|
||||
const u = (lang === "en" ? unitEN : unitDE)[r.duration_unit] || r.duration_unit;
|
||||
return `${r.duration_value} ${u}`;
|
||||
}
|
||||
|
||||
function formatDateForLang(iso: string, lang: "de" | "en"): string {
|
||||
// YYYY-MM-DD → DE: DD.MM.YYYY / EN: DD MMM YYYY (short).
|
||||
if (!iso || iso.length < 10) return iso;
|
||||
const [y, m, d] = iso.split("-");
|
||||
if (!y || !m || !d) return iso;
|
||||
if (lang === "en") {
|
||||
const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];
|
||||
const idx = parseInt(m, 10) - 1;
|
||||
const mn = idx >= 0 && idx < months.length ? months[idx] : m;
|
||||
return `${parseInt(d, 10)} ${mn} ${y}`;
|
||||
}
|
||||
return `${d}.${m}.${y}`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: string): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥"; // inbox/letter
|
||||
case "hearing": return "🏛️"; // courthouse
|
||||
case "decision": return "⚖️"; // scales
|
||||
case "order": return "📜"; // page
|
||||
default: return "📅"; // calendar
|
||||
}
|
||||
}
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function syncUrlState(eventCode: string, triggerDate: string): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", eventCode);
|
||||
url.searchParams.set("trigger_date", triggerDate);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { followUpsDifferByParty } from "./fristenrechner-wizard";
|
||||
|
||||
describe("followUpsDifferByParty — R5 trigger condition (S4, design §3.2)", () => {
|
||||
test("true when both claimant and defendant rules present", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false when all claimant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "claimant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when all defendant", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only 'both' rules", () => {
|
||||
// "Both" rules are bilateral procedural moves (Vertraulichkeits-
|
||||
// Erwiderung); they don't gate R5 because either party can be
|
||||
// looking at them.
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "both" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("false when only court rules", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "court" },
|
||||
])).toBe(false);
|
||||
});
|
||||
test("true when mixed with both / court alongside the asymmetric pair", () => {
|
||||
expect(followUpsDifferByParty([
|
||||
{ primary_party: "both" },
|
||||
{ primary_party: "claimant" },
|
||||
{ primary_party: "court" },
|
||||
{ primary_party: "defendant" },
|
||||
])).toBe(true);
|
||||
});
|
||||
test("false on empty list", () => {
|
||||
expect(followUpsDifferByParty([])).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,711 +0,0 @@
|
||||
// Fristenrechner overhaul Mode B — "Geführt" / wizard (design §3.2).
|
||||
//
|
||||
// 3-5 question row stack that lands the user on one procedural_event
|
||||
// (the trigger), then transitions to the shared §4 result view.
|
||||
//
|
||||
// R1 Was ist passiert? (event_kind) always asked
|
||||
// R2 Vor welchem Gericht? (jurisdiction) skip if R1 narrows
|
||||
// R3 In welchem Verfahren? (proceeding_type) auto-skip when 1 option
|
||||
// R4 Welches Schriftstück? (procedural_event — land) always asked
|
||||
// R5 Welche Seite vertreten Sie? (party) only when follow-ups differ
|
||||
//
|
||||
// Row badges per §11.Q3: R1+R2 = "Filter", R3+R4+R5 = "Qualifier".
|
||||
// R5 has NO "Beide" option per §11.Q8 (Mode B is the file-mode where
|
||||
// perspective is a qualifier).
|
||||
// Pre-fill + collapse rows from project (project.proceeding_type →
|
||||
// R3 + R2 derived; project.our_side → R5). Preserve compatible
|
||||
// downstream picks on back-navigation (§11.Q10).
|
||||
|
||||
import { escAttr, escHtml } from "./views/verfahrensablauf-core";
|
||||
import { getLang, t, tDyn } from "./i18n";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
|
||||
// Wire shapes — duplicates the parts of fristenrechner-mode-a.ts we
|
||||
// need; kept local so the wizard doesn't depend on Mode A.
|
||||
|
||||
interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string;
|
||||
};
|
||||
follow_up_count: number;
|
||||
}
|
||||
|
||||
interface EventSearchResponse {
|
||||
events: EventSearchHit[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
interface ProceedingChip {
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface ProjectSummary {
|
||||
id: string;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
type Forum = "UPC" | "DE" | "EPA" | "DPMA";
|
||||
type EventKindRow = "filing" | "hearing" | "decision" | "order" | "missed";
|
||||
type WizardParty = "claimant" | "defendant";
|
||||
|
||||
// WIZARD_HOST_ID is the DOM id the wizard renders into. Mounted by
|
||||
// fristenrechner-result.mountModeShell which creates the host element
|
||||
// under the overhaul root.
|
||||
const WIZARD_HOST_ID = "fristen-overhaul-mode-host";
|
||||
|
||||
// FORUMS + EVENT_KINDS — closed sets. Keep parallel to Mode A's lists
|
||||
// so re-grouping happens in one place.
|
||||
const FORUMS: Forum[] = ["UPC", "DE", "EPA", "DPMA"];
|
||||
const EVENT_KINDS: EventKindRow[] = ["filing", "hearing", "decision", "order", "missed"];
|
||||
|
||||
// Single wizard state. Module-local; one wizard at a time.
|
||||
interface WizardState {
|
||||
// Picks. "" = not answered. R5 only set when the question is asked.
|
||||
r1: EventKindRow | "";
|
||||
r2: Forum | "";
|
||||
r3: string; // proceeding_types.code
|
||||
r4: string; // procedural_events.code
|
||||
r5: WizardParty | "";
|
||||
|
||||
// Pre-fill provenance — when a pick came from the project context,
|
||||
// the row renders with an "aus Akte" tag so the user notices.
|
||||
r2FromProject: boolean;
|
||||
r3FromProject: boolean;
|
||||
r5FromProject: boolean;
|
||||
|
||||
// Implicit fills — R2 auto-derived from R1 when R1 narrows to one
|
||||
// forum (e.g. "missed" → no narrowing, "filing" → cross-forum, but
|
||||
// if downstream R3 lookup returns a single forum we can mark R2 as
|
||||
// implicit).
|
||||
r2Implicit: boolean;
|
||||
r3Implicit: boolean;
|
||||
}
|
||||
|
||||
const state: WizardState = {
|
||||
r1: "", r2: "", r3: "", r4: "", r5: "",
|
||||
r2FromProject: false, r3FromProject: false, r5FromProject: false,
|
||||
r2Implicit: false, r3Implicit: false,
|
||||
};
|
||||
|
||||
// Loaded from the project (if any).
|
||||
let projectSummary: ProjectSummary | null = null;
|
||||
|
||||
// Proceeding chip cache key: jurisdiction × event_kind.
|
||||
let lastProcCacheKey = "";
|
||||
let cachedProcChips: ProceedingChip[] = [];
|
||||
|
||||
// Event chip cache: keyed on R3 code + R1 event_kind.
|
||||
let lastEventCacheKey = "";
|
||||
let cachedEventChips: EventSearchHit[] = [];
|
||||
|
||||
// Public API ---------------------------------------------------------
|
||||
|
||||
export async function mountWizard(): Promise<void> {
|
||||
const host = document.getElementById(WIZARD_HOST_ID);
|
||||
if (!host) return;
|
||||
|
||||
// Hydrate from URL state (mode=wizard&forum=UPC&pt=upc.inf.cfi&…).
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
state.r1 = (params.get("kind") as EventKindRow) || "";
|
||||
state.r2 = (params.get("forum") as Forum) || "";
|
||||
state.r3 = params.get("pt") || "";
|
||||
state.r4 = params.get("event") || "";
|
||||
state.r5 = (params.get("party") as WizardParty) || "";
|
||||
|
||||
// Project prefills.
|
||||
const projectId = params.get("project");
|
||||
if (projectId) {
|
||||
projectSummary = await fetchProject(projectId);
|
||||
await applyProjectPrefills();
|
||||
} else {
|
||||
projectSummary = null;
|
||||
}
|
||||
|
||||
renderShell();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
// applyProjectPrefills derives R2 + R3 + R5 from the project when they
|
||||
// haven't been set explicitly. Project picks take precedence over
|
||||
// unspecified state, but a user-supplied URL pick wins over the
|
||||
// project default.
|
||||
async function applyProjectPrefills(): Promise<void> {
|
||||
if (!projectSummary) return;
|
||||
// Map our_side → R5.
|
||||
if (!state.r5) {
|
||||
const side = projectSummary.our_side;
|
||||
if (side === "claimant" || side === "applicant" || side === "appellant") {
|
||||
state.r5 = "claimant";
|
||||
state.r5FromProject = true;
|
||||
} else if (side === "defendant" || side === "respondent") {
|
||||
state.r5 = "defendant";
|
||||
state.r5FromProject = true;
|
||||
}
|
||||
}
|
||||
// Map proceeding_type_id → R3 + infer R2 jurisdiction.
|
||||
if (projectSummary.proceeding_type_id && !state.r3) {
|
||||
const pt = await fetchProceedingByID(projectSummary.proceeding_type_id);
|
||||
if (pt) {
|
||||
state.r3 = pt.code;
|
||||
state.r3FromProject = true;
|
||||
if (pt.group && !state.r2) {
|
||||
state.r2 = pt.group as Forum;
|
||||
state.r2FromProject = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Render -------------------------------------------------------------
|
||||
|
||||
function renderShell(): void {
|
||||
const host = document.getElementById(WIZARD_HOST_ID);
|
||||
if (!host) return;
|
||||
host.innerHTML = `
|
||||
<div class="fristen-wizard-root">
|
||||
<header class="fristen-wizard-header">
|
||||
<h2 class="fristen-wizard-title">${escHtml(t("deadlines.overhaul.wizard.heading"))}</h2>
|
||||
<p class="fristen-wizard-hint">${escHtml(t("deadlines.overhaul.wizard.hint"))}</p>
|
||||
</header>
|
||||
<div class="fristen-wizard-rows" id="fristen-wizard-rows" aria-live="polite"></div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function renderRows(): Promise<void> {
|
||||
const host = document.getElementById("fristen-wizard-rows");
|
||||
if (!host) return;
|
||||
|
||||
// Resolve dynamic row prerequisites BEFORE building markup so chip
|
||||
// sets are populated.
|
||||
if (state.r1 && state.r2) {
|
||||
await ensureProceedingChips(state.r2, state.r1);
|
||||
// Auto-skip R3 when the narrowed pool has exactly one option.
|
||||
if (!state.r3 && cachedProcChips.length === 1) {
|
||||
state.r3 = cachedProcChips[0].code;
|
||||
state.r3Implicit = true;
|
||||
}
|
||||
}
|
||||
if (state.r1 && state.r3) {
|
||||
await ensureEventChips(state.r3, state.r1);
|
||||
}
|
||||
|
||||
const rows: string[] = [];
|
||||
rows.push(rowR1());
|
||||
if (shouldShowR2()) rows.push(rowR2());
|
||||
if (shouldShowR3()) rows.push(rowR3());
|
||||
if (shouldShowR4()) rows.push(rowR4());
|
||||
if (state.r4 && shouldShowR5Sync()) rows.push(rowR5Loading());
|
||||
|
||||
host.innerHTML = rows.join("");
|
||||
wireRowEvents();
|
||||
|
||||
// R5 conditional check — fires after R4 picked. Inspects /follow-ups
|
||||
// to see whether they actually differ by party. If yes, show R5. If
|
||||
// no, or R5 already set, transition straight to result view.
|
||||
if (state.r4) {
|
||||
void maybeAdvanceFromR4();
|
||||
}
|
||||
}
|
||||
|
||||
// Should-show predicates --------------------------------------------
|
||||
|
||||
function shouldShowR2(): boolean {
|
||||
// Skip R2 only when R1 narrows to a single forum — which today
|
||||
// never happens for the closed event_kind set (every kind exists in
|
||||
// multiple jurisdictions). Always show R2 until we have empirical
|
||||
// evidence otherwise.
|
||||
return state.r1 !== "" && state.r1 !== "missed";
|
||||
}
|
||||
|
||||
function shouldShowR3(): boolean {
|
||||
if (state.r1 === "" || state.r2 === "") return false;
|
||||
if (state.r3 && state.r3Implicit) return true; // visible compact
|
||||
return true;
|
||||
}
|
||||
|
||||
function shouldShowR4(): boolean {
|
||||
return state.r3 !== "" && state.r1 !== "";
|
||||
}
|
||||
|
||||
// shouldShowR5Sync renders the placeholder row immediately; the actual
|
||||
// asked-or-not decision happens after the async follow-ups probe in
|
||||
// maybeAdvanceFromR4.
|
||||
function shouldShowR5Sync(): boolean {
|
||||
return state.r4 !== "";
|
||||
}
|
||||
|
||||
// Row builders ------------------------------------------------------
|
||||
|
||||
function rowR1(): string {
|
||||
const chips = EVENT_KINDS.map((k) => {
|
||||
const label = t(`deadlines.overhaul.kind.${k}` as never);
|
||||
const icon = eventKindIcon(k);
|
||||
return chipHtml("r1", k, label, state.r1 === k, icon);
|
||||
}).join("");
|
||||
return rowShell({
|
||||
n: 1,
|
||||
badge: "filter",
|
||||
label: t("deadlines.overhaul.wizard.r1.label"),
|
||||
active: !state.r1,
|
||||
answeredText: state.r1 ? t(`deadlines.overhaul.kind.${state.r1}` as never) : "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR2(): string {
|
||||
const chips = FORUMS.map((f) => chipHtml("r2", f, f, state.r2 === f)).join("");
|
||||
return rowShell({
|
||||
n: 2,
|
||||
badge: "filter",
|
||||
label: t("deadlines.overhaul.wizard.r2.label"),
|
||||
active: !state.r2,
|
||||
fromProject: state.r2FromProject,
|
||||
answeredText: state.r2 || "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR3(): string {
|
||||
if (cachedProcChips.length === 0) {
|
||||
return rowShell({
|
||||
n: 3, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||||
active: true,
|
||||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r3.empty"))}</div>`,
|
||||
});
|
||||
}
|
||||
const lang = getLang();
|
||||
const chips = cachedProcChips.map((p) => {
|
||||
const label = lang === "en" ? p.nameEN || p.name : p.name;
|
||||
return chipHtml("r3", p.code, label, state.r3 === p.code, undefined, p.code);
|
||||
}).join("");
|
||||
let answered = "";
|
||||
if (state.r3) {
|
||||
const hit = cachedProcChips.find((p) => p.code === state.r3);
|
||||
if (hit) answered = lang === "en" ? hit.nameEN || hit.name : hit.name;
|
||||
}
|
||||
return rowShell({
|
||||
n: 3,
|
||||
badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r3.label"),
|
||||
active: !state.r3,
|
||||
fromProject: state.r3FromProject,
|
||||
implicit: state.r3Implicit,
|
||||
answeredText: answered,
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR4(): string {
|
||||
if (cachedEventChips.length === 0) {
|
||||
return rowShell({
|
||||
n: 4, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||||
active: true,
|
||||
body: `<div class="fristen-wizard-empty">${escHtml(t("deadlines.overhaul.wizard.r4.empty"))}</div>`,
|
||||
});
|
||||
}
|
||||
const lang = getLang();
|
||||
const chips = cachedEventChips.map((e) => {
|
||||
const label = lang === "en" ? e.name_en || e.name_de : e.name_de;
|
||||
return chipHtml("r4", e.code, label, state.r4 === e.code, eventKindIcon(e.event_kind as EventKindRow));
|
||||
}).join("");
|
||||
let answered = "";
|
||||
if (state.r4) {
|
||||
const hit = cachedEventChips.find((e) => e.code === state.r4);
|
||||
if (hit) answered = lang === "en" ? hit.name_en || hit.name_de : hit.name_de;
|
||||
}
|
||||
return rowShell({
|
||||
n: 4,
|
||||
badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r4.label"),
|
||||
active: !state.r4,
|
||||
answeredText: answered,
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR5Loading(): string {
|
||||
// Placeholder while we probe whether R5 is needed. The async
|
||||
// follow-ups probe replaces this with rowR5 chips or skips
|
||||
// straight to the result view.
|
||||
return rowShell({
|
||||
n: 5, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||||
active: !state.r5,
|
||||
fromProject: state.r5FromProject,
|
||||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||||
body: `<div class="fristen-wizard-probe">${escHtml(t("deadlines.overhaul.wizard.r5.probing"))}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
function rowR5Chips(): string {
|
||||
const chips = (["claimant", "defendant"] as const).map((p) =>
|
||||
chipHtml("r5", p, t(`deadlines.party.${p}` as never), state.r5 === p)).join("");
|
||||
return rowShell({
|
||||
n: 5, badge: "qualifier",
|
||||
label: t("deadlines.overhaul.wizard.r5.label"),
|
||||
active: !state.r5,
|
||||
fromProject: state.r5FromProject,
|
||||
answeredText: state.r5 ? t(`deadlines.party.${state.r5}` as never) : "",
|
||||
body: `<div class="fristen-wizard-chips">${chips}</div>`,
|
||||
});
|
||||
}
|
||||
|
||||
interface RowShellOpts {
|
||||
n: number;
|
||||
badge: "filter" | "qualifier";
|
||||
label: string;
|
||||
active: boolean;
|
||||
body: string;
|
||||
answeredText?: string;
|
||||
fromProject?: boolean;
|
||||
implicit?: boolean;
|
||||
}
|
||||
|
||||
function rowShell(o: RowShellOpts): string {
|
||||
const cls = `fristen-wizard-row fristen-wizard-row--${o.badge}` +
|
||||
(o.active ? " is-active" : " is-answered") +
|
||||
(o.fromProject ? " is-from-project" : "") +
|
||||
(o.implicit ? " is-implicit" : "");
|
||||
const badgeText = o.badge === "filter"
|
||||
? t("deadlines.overhaul.wizard.badge.filter")
|
||||
: t("deadlines.overhaul.wizard.badge.qualifier");
|
||||
const annotations: string[] = [];
|
||||
if (o.fromProject) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.from_project"))}</span>`);
|
||||
if (o.implicit) annotations.push(`<span class="fristen-wizard-row-anno">${escHtml(t("deadlines.overhaul.wizard.anno.implicit"))}</span>`);
|
||||
const answered = o.answeredText
|
||||
? `<span class="fristen-wizard-row-answer">${escHtml(o.answeredText)}</span>`
|
||||
: "";
|
||||
const edit = !o.active
|
||||
? `<button type="button" class="fristen-wizard-row-edit" data-row="${o.n}">${escHtml(t("deadlines.overhaul.wizard.edit"))}</button>`
|
||||
: "";
|
||||
return `
|
||||
<section class="${cls}" data-row="${o.n}">
|
||||
<header class="fristen-wizard-row-header">
|
||||
<span class="fristen-wizard-row-n">${o.n}</span>
|
||||
<span class="fristen-wizard-row-badge fristen-wizard-row-badge--${o.badge}">${escHtml(badgeText)}</span>
|
||||
<span class="fristen-wizard-row-label">${escHtml(o.label)}</span>
|
||||
${annotations.join("")}
|
||||
${answered}
|
||||
${edit}
|
||||
</header>
|
||||
${o.active ? `<div class="fristen-wizard-row-body">${o.body}</div>` : ""}
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
// Event wiring ------------------------------------------------------
|
||||
|
||||
function wireRowEvents(): void {
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row .fristen-mode-a-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const axis = btn.dataset.axis || "";
|
||||
const value = btn.dataset.value || "";
|
||||
handleChip(axis, value);
|
||||
});
|
||||
});
|
||||
document.querySelectorAll<HTMLButtonElement>(".fristen-wizard-row-edit").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const n = parseInt(btn.dataset.row || "0", 10);
|
||||
handleEdit(n);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleChip(axis: string, value: string): void {
|
||||
switch (axis) {
|
||||
case "r1": {
|
||||
if (state.r1 === value) return;
|
||||
state.r1 = value as EventKindRow;
|
||||
// R1 change resets R3/R4 (event-kind narrows the pools).
|
||||
state.r3 = "";
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
cachedProcChips = [];
|
||||
lastProcCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r2": {
|
||||
if (state.r2 === value) return;
|
||||
state.r2 = value as Forum;
|
||||
state.r2FromProject = false;
|
||||
state.r2Implicit = false;
|
||||
// R2 change may invalidate R3 → reset.
|
||||
state.r3 = "";
|
||||
state.r3FromProject = false;
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedProcChips = [];
|
||||
lastProcCacheKey = "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r3": {
|
||||
if (state.r3 === value) return;
|
||||
state.r3 = value;
|
||||
state.r3FromProject = false;
|
||||
state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedEventChips = [];
|
||||
lastEventCacheKey = "";
|
||||
break;
|
||||
}
|
||||
case "r4": {
|
||||
if (state.r4 === value) return;
|
||||
state.r4 = value;
|
||||
break;
|
||||
}
|
||||
case "r5": {
|
||||
if (state.r5 === value) return;
|
||||
state.r5 = value as WizardParty;
|
||||
state.r5FromProject = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
syncUrl();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
function handleEdit(n: number): void {
|
||||
switch (n) {
|
||||
case 1:
|
||||
state.r1 = ""; state.r2 = ""; state.r3 = ""; state.r4 = ""; state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
cachedProcChips = []; lastProcCacheKey = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 2:
|
||||
state.r2 = ""; state.r2FromProject = false; state.r2Implicit = false;
|
||||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedProcChips = []; lastProcCacheKey = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 3:
|
||||
state.r3 = ""; state.r3FromProject = false; state.r3Implicit = false;
|
||||
state.r4 = "";
|
||||
cachedEventChips = []; lastEventCacheKey = "";
|
||||
break;
|
||||
case 4:
|
||||
state.r4 = "";
|
||||
state.r5 = state.r5FromProject ? state.r5 : "";
|
||||
break;
|
||||
case 5:
|
||||
state.r5 = ""; state.r5FromProject = false;
|
||||
break;
|
||||
}
|
||||
syncUrl();
|
||||
void renderRows();
|
||||
}
|
||||
|
||||
// maybeAdvanceFromR4 fetches /follow-ups for the picked event to
|
||||
// decide whether R5 is needed. If R5 is already set OR the
|
||||
// follow-ups don't differ by party, transition straight to the
|
||||
// result view. Else swap the R5 loading row for the chip picker.
|
||||
async function maybeAdvanceFromR4(): Promise<void> {
|
||||
if (!state.r4) return;
|
||||
if (state.r5) {
|
||||
// R5 already answered (project prefill or explicit pick) → go.
|
||||
void launchResult();
|
||||
return;
|
||||
}
|
||||
// Probe follow-ups.
|
||||
const url = new URL("/api/tools/fristenrechner/follow-ups", window.location.origin);
|
||||
url.searchParams.set("event", state.r4);
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
// Soft-fail → swap to R5 chips so the user can decide manually.
|
||||
swapR5(rowR5Chips());
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as { follow_ups: Array<{ primary_party?: string }> };
|
||||
const differs = followUpsDifferByParty(data.follow_ups);
|
||||
if (!differs) {
|
||||
void launchResult();
|
||||
return;
|
||||
}
|
||||
swapR5(rowR5Chips());
|
||||
} catch {
|
||||
swapR5(rowR5Chips());
|
||||
}
|
||||
}
|
||||
|
||||
function swapR5(html: string): void {
|
||||
const host = document.getElementById("fristen-wizard-rows");
|
||||
if (!host) return;
|
||||
const r5 = host.querySelector('.fristen-wizard-row[data-row="5"]');
|
||||
if (!r5) {
|
||||
host.insertAdjacentHTML("beforeend", html);
|
||||
} else {
|
||||
r5.outerHTML = html;
|
||||
}
|
||||
wireRowEvents();
|
||||
}
|
||||
|
||||
function launchResult(): void {
|
||||
// Hand off to the §4 result view. The URL already carries the
|
||||
// picks via syncUrl(); add event= so the boot path treats this
|
||||
// as a deep-link.
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("event", state.r4);
|
||||
if (state.r5) url.searchParams.set("party", state.r5);
|
||||
else url.searchParams.delete("party");
|
||||
history.pushState(null, "", url.pathname + url.search + url.hash);
|
||||
void mountResultView({ eventRef: state.r4, party: state.r5 || undefined });
|
||||
}
|
||||
|
||||
export function followUpsDifferByParty(rows: Array<{ primary_party?: string }>): boolean {
|
||||
let hasClaimant = false, hasDefendant = false;
|
||||
for (const r of rows) {
|
||||
if (r.primary_party === "claimant") hasClaimant = true;
|
||||
else if (r.primary_party === "defendant") hasDefendant = true;
|
||||
if (hasClaimant && hasDefendant) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Fetches -----------------------------------------------------------
|
||||
|
||||
async function fetchProject(id: string): Promise<ProjectSummary | null> {
|
||||
try {
|
||||
const resp = await fetch(`/api/projects/${encodeURIComponent(id)}`, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) return null;
|
||||
return (await resp.json()) as ProjectSummary;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchProceedingByID(id: number): Promise<ProceedingChip | null> {
|
||||
// The proceeding-types endpoint returns codes, names, jurisdictions
|
||||
// but doesn't carry the id (the wire shape FristenrechnerType is
|
||||
// code-keyed). Walk the unfiltered list and pick by sort-order
|
||||
// proximity / sort-fallback: we need the row whose id matches; since
|
||||
// the wire doesn't expose id, fetch the projects detail to get the
|
||||
// code directly. Cheap workaround: rely on /api/projects/{id}'s
|
||||
// proceeding_type_id being matched against the proceeding-types list
|
||||
// by jurisdiction round-trip is not possible without id. Instead
|
||||
// expose the proceeding-types-by-id mapping via a follow-up endpoint
|
||||
// later. For now hit the unfiltered list and assume the project's
|
||||
// pick is in the active set.
|
||||
//
|
||||
// Pragmatic fallback: query the full list and return the only entry
|
||||
// whose pseudo-id-via-sort-order matches. The lookup is unreliable
|
||||
// until the wire shape includes id; for the project-prefill case the
|
||||
// user can always re-pick R3 / R2 if the prefill misfires.
|
||||
try {
|
||||
const resp = await fetch(`/api/tools/proceeding-types`, { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) return null;
|
||||
const list = (await resp.json()) as ProceedingChip[] | null;
|
||||
if (!list || list.length === 0) return null;
|
||||
// Without id in the wire we cannot match by id. Skip the prefill
|
||||
// silently — R3 stays unanswered and the user picks manually.
|
||||
// (S5/follow-up can extend the wire shape to include id.)
|
||||
void id;
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureProceedingChips(forum: Forum, kind: EventKindRow): Promise<void> {
|
||||
const key = `${forum}\x00${kind}`;
|
||||
if (lastProcCacheKey === key) return;
|
||||
lastProcCacheKey = key;
|
||||
const url = new URL("/api/tools/proceeding-types", window.location.origin);
|
||||
url.searchParams.set("kind", "proceeding");
|
||||
url.searchParams.set("jurisdiction", forum);
|
||||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
cachedProcChips = [];
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as ProceedingChip[] | null;
|
||||
cachedProcChips = data || [];
|
||||
} catch {
|
||||
cachedProcChips = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function ensureEventChips(procCode: string, kind: EventKindRow): Promise<void> {
|
||||
const key = `${procCode}\x00${kind}`;
|
||||
if (lastEventCacheKey === key) return;
|
||||
lastEventCacheKey = key;
|
||||
const url = new URL("/api/tools/fristenrechner/search", window.location.origin);
|
||||
url.searchParams.set("kind", "events");
|
||||
url.searchParams.set("proc", procCode);
|
||||
if (kind !== "missed") url.searchParams.set("event_kind", kind);
|
||||
url.searchParams.set("limit", "100");
|
||||
try {
|
||||
const resp = await fetch(url.toString(), { headers: { Accept: "application/json" } });
|
||||
if (!resp.ok) {
|
||||
cachedEventChips = [];
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as EventSearchResponse;
|
||||
cachedEventChips = data.events || [];
|
||||
} catch {
|
||||
cachedEventChips = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers -----------------------------------------------------------
|
||||
|
||||
function chipHtml(axis: string, value: string, label: string, active: boolean, icon?: string, title?: string): string {
|
||||
const cls = `fristen-mode-a-chip${active ? " is-active" : ""}`;
|
||||
const tt = title ? ` title="${escAttr(title)}"` : "";
|
||||
const i = icon ? `<span class="fristen-mode-a-chip-icon" aria-hidden="true">${icon}</span>` : "";
|
||||
return `<button type="button" class="${cls}" data-axis="${escAttr(axis)}" data-value="${escAttr(value)}"${tt}>${i}<span class="fristen-mode-a-chip-label">${escHtml(label)}</span></button>`;
|
||||
}
|
||||
|
||||
function eventKindIcon(kind?: EventKindRow): string {
|
||||
switch (kind) {
|
||||
case "filing": return "📥";
|
||||
case "hearing": return "🏛️";
|
||||
case "decision": return "⚖️";
|
||||
case "order": return "📜";
|
||||
case "missed": return "⏲";
|
||||
default: return "📅";
|
||||
}
|
||||
}
|
||||
|
||||
function syncUrl(): void {
|
||||
const url = new URL(window.location.href);
|
||||
url.searchParams.set("overhaul", "1");
|
||||
url.searchParams.set("mode", "wizard");
|
||||
setOrClear(url, "kind", state.r1);
|
||||
setOrClear(url, "forum", state.r2);
|
||||
setOrClear(url, "pt", state.r3);
|
||||
// event=… is set only on launchResult; the wizard URL carries the
|
||||
// R4 candidate via r4= so back/forward navigates within the wizard.
|
||||
setOrClear(url, "r4", state.r4);
|
||||
setOrClear(url, "party", state.r5);
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
function setOrClear(url: URL, key: string, val: string): void {
|
||||
if (val) url.searchParams.set(key, val);
|
||||
else url.searchParams.delete(key);
|
||||
}
|
||||
@@ -294,6 +294,62 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"builder.search.summary.projects.other": "{n} Akten",
|
||||
"builder.search.anchor.divider": "\u2501\u2501\u2501\u2501 DU BIST HIER \u2501\u2501\u2501\u2501",
|
||||
|
||||
// B5 \u2014 side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Geteilt mit mir",
|
||||
"builder.bucket.promoted": "Als Projekt angelegt",
|
||||
"builder.bucket.archived": "Archiviert",
|
||||
"builder.bucket.empty": "\u2014",
|
||||
"builder.readonly.watermark": "Geteilt von {owner} \u00b7 schreibgesch\u00fctzt",
|
||||
"builder.readonly.blocked": "Schreibgesch\u00fctzt \u2014 Bearbeiten ist nur f\u00fcr die Eigent\u00fcmer:in m\u00f6glich.",
|
||||
"builder.share.title": "Szenario teilen",
|
||||
"builder.share.subtitle": "Schreibgesch\u00fctzt mit HLC-Kolleg:innen teilen. Du bleibst alleinige Bearbeiter:in.",
|
||||
"builder.share.search.placeholder": "Name oder E-Mail suchen \u2026",
|
||||
"builder.share.button": "Schreibgesch\u00fctzt teilen",
|
||||
"builder.share.current.title": "Bereits geteilt mit:",
|
||||
"builder.share.current.empty": "Noch mit niemandem geteilt.",
|
||||
"builder.share.revoke": "Entfernen",
|
||||
"builder.share.close": "Schlie\u00dfen",
|
||||
"builder.share.no_results": "Keine Nutzer:innen gefunden.",
|
||||
"builder.share.error": "Teilen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.title": "Als Projekt anlegen",
|
||||
"builder.promote.step1": "Best\u00e4tigen",
|
||||
"builder.promote.step2": "Parteien erg\u00e4nzen",
|
||||
"builder.promote.step3": "Akte-Metadaten",
|
||||
"builder.promote.next": "Weiter",
|
||||
"builder.promote.back": "Zur\u00fcck",
|
||||
"builder.promote.commit": "Anlegen",
|
||||
"builder.promote.cancel": "Abbrechen",
|
||||
"builder.promote.summary.heading": "Das wird angelegt:",
|
||||
"builder.promote.summary.proceeding": "Hauptverfahren",
|
||||
"builder.promote.summary.events_filed": "eingereichte Ereignisse",
|
||||
"builder.promote.summary.events_planned": "geplante Ereignisse",
|
||||
"builder.promote.summary.flags": "aktive Optionen",
|
||||
"builder.promote.summary.note_extra": "{n} weitere(s) eigenst\u00e4ndige(s) Verfahren bleibt im Szenario und wird nicht automatisch \u00fcbernommen.",
|
||||
"builder.promote.parties.hint": "Trage die echten Parteinamen ein \u2014 oder erg\u00e4nze sie sp\u00e4ter in der Akte.",
|
||||
"builder.promote.parties.add": "+ Partei hinzuf\u00fcgen",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Rolle (z. B. Kl\u00e4ger)",
|
||||
"builder.promote.parties.representative": "Vertreter:in",
|
||||
"builder.promote.parties.remove": "Entfernen",
|
||||
"builder.promote.parties.empty": "Noch keine Parteien.",
|
||||
"builder.promote.meta.title": "Aktentitel / Mandat",
|
||||
"builder.promote.meta.title.placeholder": "z. B. Becker ./. X \u2014 UPC Verletzung",
|
||||
"builder.promote.meta.reference": "Referenz (optional)",
|
||||
"builder.promote.meta.case_number": "Aktenzeichen (optional)",
|
||||
"builder.promote.meta.client_number": "Mandantennummer (optional)",
|
||||
"builder.promote.meta.our_side": "Unsere Seite",
|
||||
"builder.promote.meta.our_side.claimant": "Kl\u00e4ger",
|
||||
"builder.promote.meta.our_side.defendant": "Beklagter",
|
||||
"builder.promote.meta.our_side.none": "\u2014 offen \u2014",
|
||||
"builder.promote.meta.parent": "\u00dcbergeordnetes Verfahren (optional)",
|
||||
"builder.promote.meta.parent.none": "\u2014 keines \u2014",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "Du wirst automatisch als Lead hinzugef\u00fcgt.",
|
||||
"builder.promote.error.title_required": "Bitte einen Aktentitel eingeben.",
|
||||
"builder.promote.error.generic": "Anlegen fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.promote.success": "Akte angelegt \u2014 Weiterleitung \u2026",
|
||||
"builder.mobile.blocked": "Auf gr\u00f6\u00dferem Bildschirm \u00f6ffnen, um zu bearbeiten.",
|
||||
|
||||
"deadlines.step1": "Verfahrensart w\u00e4hlen",
|
||||
"deadlines.step2": "Ausgangsdatum eingeben",
|
||||
"deadlines.step2.perspective": "Perspektive und Datum",
|
||||
@@ -343,10 +399,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.epa.opp.opd": "Einspruchsverfahren",
|
||||
"deadlines.epa.opp.boa": "Beschwerdeverfahren",
|
||||
"deadlines.epa.grant.exa": "EP-Erteilungsverfahren",
|
||||
"deadlines.party.claimant": "Kl\u00e4ger",
|
||||
"deadlines.party.defendant": "Beklagter",
|
||||
"deadlines.party.court": "Gericht",
|
||||
"deadlines.party.both": "Beide",
|
||||
"deadlines.party.both.label": "beide Seiten",
|
||||
"deadlines.court.set": "vom Gericht bestimmt",
|
||||
"deadlines.court.indirect": "unbestimmt",
|
||||
@@ -1072,6 +1124,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"cal.view.month": "Monat",
|
||||
"cal.view.week": "Woche",
|
||||
"cal.view.day": "Tag",
|
||||
"cal.today": "Heute",
|
||||
"cal.month.prev": "Vorheriger Monat",
|
||||
"cal.month.next": "Nächster Monat",
|
||||
"cal.week.prev": "Vorherige Woche",
|
||||
@@ -1692,11 +1745,28 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
|
||||
// t-paliad-354 — Dateiname-Stichwort (führt den Namen des exportierten Dokuments an).
|
||||
"submissions.draft.keyword.label": "Stichwort (Dateiname)",
|
||||
"submissions.draft.keyword.placeholder": "Automatisch aus dem Schriftsatztyp",
|
||||
"submissions.draft.keyword.hint": "Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Vorlagenbasis",
|
||||
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
|
||||
"submissions.draft.sections.title": "Abschnitte",
|
||||
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Vorlagen — Paliad",
|
||||
"templates.authoring.heading": "Vorlagen",
|
||||
"templates.authoring.intro": "Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.",
|
||||
"templates.authoring.upload.title": "Neue Vorlage hochladen",
|
||||
"templates.authoring.upload.file": "Word-Datei (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Kanzlei (optional)",
|
||||
"templates.authoring.upload.submit": "Hochladen",
|
||||
"templates.authoring.list.title": "Vorhandene Vorlagen",
|
||||
"templates.authoring.workspace.hint": "Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.",
|
||||
"templates.authoring.slots.title": "Platzhalter",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Bausteine — Paliad",
|
||||
"admin.building_blocks.heading": "Bausteine",
|
||||
@@ -3578,6 +3648,62 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"builder.search.summary.projects.other": "{n} matters",
|
||||
"builder.search.anchor.divider": "━━━━ YOU ARE HERE ━━━━",
|
||||
|
||||
// B5 — side-panel buckets, sharing, promote-to-project wizard.
|
||||
"builder.bucket.shared": "Shared with me",
|
||||
"builder.bucket.promoted": "Promoted to project",
|
||||
"builder.bucket.archived": "Archived",
|
||||
"builder.bucket.empty": "—",
|
||||
"builder.readonly.watermark": "Shared by {owner} · read-only",
|
||||
"builder.readonly.blocked": "Read-only — only the owner can edit.",
|
||||
"builder.share.title": "Share scenario",
|
||||
"builder.share.subtitle": "Share read-only with HLC colleagues. You remain the sole editor.",
|
||||
"builder.share.search.placeholder": "Search name or email …",
|
||||
"builder.share.button": "Share read-only",
|
||||
"builder.share.current.title": "Already shared with:",
|
||||
"builder.share.current.empty": "Not shared with anyone yet.",
|
||||
"builder.share.revoke": "Remove",
|
||||
"builder.share.close": "Close",
|
||||
"builder.share.no_results": "No users found.",
|
||||
"builder.share.error": "Sharing failed. Please try again.",
|
||||
"builder.promote.title": "Create as project",
|
||||
"builder.promote.step1": "Confirm",
|
||||
"builder.promote.step2": "Add parties",
|
||||
"builder.promote.step3": "Case metadata",
|
||||
"builder.promote.next": "Next",
|
||||
"builder.promote.back": "Back",
|
||||
"builder.promote.commit": "Create",
|
||||
"builder.promote.cancel": "Cancel",
|
||||
"builder.promote.summary.heading": "What will be created:",
|
||||
"builder.promote.summary.proceeding": "Primary proceeding",
|
||||
"builder.promote.summary.events_filed": "filed events",
|
||||
"builder.promote.summary.events_planned": "planned events",
|
||||
"builder.promote.summary.flags": "active options",
|
||||
"builder.promote.summary.note_extra": "{n} further standalone proceeding(s) stay in the scenario and are not carried over automatically.",
|
||||
"builder.promote.parties.hint": "Enter the real party names — or add them later in the case file.",
|
||||
"builder.promote.parties.add": "+ Add party",
|
||||
"builder.promote.parties.name": "Name",
|
||||
"builder.promote.parties.role": "Role (e.g. claimant)",
|
||||
"builder.promote.parties.representative": "Representative",
|
||||
"builder.promote.parties.remove": "Remove",
|
||||
"builder.promote.parties.empty": "No parties yet.",
|
||||
"builder.promote.meta.title": "Case title / matter",
|
||||
"builder.promote.meta.title.placeholder": "e.g. Becker v. X — UPC infringement",
|
||||
"builder.promote.meta.reference": "Reference (optional)",
|
||||
"builder.promote.meta.case_number": "Case number (optional)",
|
||||
"builder.promote.meta.client_number": "Client number (optional)",
|
||||
"builder.promote.meta.our_side": "Our side",
|
||||
"builder.promote.meta.our_side.claimant": "Claimant",
|
||||
"builder.promote.meta.our_side.defendant": "Defendant",
|
||||
"builder.promote.meta.our_side.none": "— open —",
|
||||
"builder.promote.meta.parent": "Parent litigation (optional)",
|
||||
"builder.promote.meta.parent.none": "— none —",
|
||||
"builder.promote.meta.team": "Team (optional)",
|
||||
"builder.promote.meta.team.hint": "You are added as lead automatically.",
|
||||
"builder.promote.error.title_required": "Please enter a case title.",
|
||||
"builder.promote.error.generic": "Creation failed. Please try again.",
|
||||
"builder.promote.success": "Case created — redirecting …",
|
||||
"builder.mobile.blocked": "Open on a larger screen to edit.",
|
||||
|
||||
"deadlines.step1": "Select Proceeding Type",
|
||||
"deadlines.step2": "Enter Trigger Date",
|
||||
"deadlines.step2.perspective": "Perspective and Date",
|
||||
@@ -3627,10 +3753,6 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"deadlines.epa.opp.opd": "Opposition",
|
||||
"deadlines.epa.opp.boa": "Appeal",
|
||||
"deadlines.epa.grant.exa": "Grant Procedure",
|
||||
"deadlines.party.claimant": "Claimant",
|
||||
"deadlines.party.defendant": "Defendant",
|
||||
"deadlines.party.court": "Court",
|
||||
"deadlines.party.both": "Both",
|
||||
"deadlines.party.both.label": "both parties",
|
||||
"deadlines.court.set": "set by court",
|
||||
"deadlines.court.indirect": "tbd",
|
||||
@@ -4952,6 +5074,10 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universal skeleton (no language-matched template).",
|
||||
// t-paliad-354 — filename keyword (leads the exported document name).
|
||||
"submissions.draft.keyword.label": "Keyword (filename)",
|
||||
"submissions.draft.keyword.placeholder": "Auto-derived from the submission type",
|
||||
"submissions.draft.keyword.hint": "Leads the filename: <date> <keyword> (<case number>).",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-277 — import-from-project + party-picker.
|
||||
"submissions.draft.import.button": "Import from project",
|
||||
@@ -4962,6 +5088,19 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
|
||||
"submissions.draft.sections.title": "Sections",
|
||||
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Templates — Paliad",
|
||||
"templates.authoring.heading": "Templates",
|
||||
"templates.authoring.intro": "Upload a Word template, highlight spots and insert variables.",
|
||||
"templates.authoring.upload.title": "Upload a new template",
|
||||
"templates.authoring.upload.file": "Word file (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Firm (optional)",
|
||||
"templates.authoring.upload.submit": "Upload",
|
||||
"templates.authoring.list.title": "Existing templates",
|
||||
"templates.authoring.workspace.hint": "Highlight text, then pick a variable to place a placeholder.",
|
||||
"templates.authoring.slots.title": "Placeholders",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Building blocks — Paliad",
|
||||
"admin.building_blocks.heading": "Building blocks",
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
@@ -33,6 +35,9 @@ interface SubmissionDraftJSON {
|
||||
// path stays the fallback). composer_meta carries the seed-time
|
||||
// section order in later slices.
|
||||
base_id?: string | null;
|
||||
// t-paliad-349 slice 7 — pinned uploaded docforge template version.
|
||||
// Mutually exclusive with base_id in practice (export checks this first).
|
||||
template_version_id?: string | null;
|
||||
composer_meta?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -69,6 +74,17 @@ interface SubmissionBaseRow {
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — an uploaded docforge template offered in the
|
||||
// picker for generation. version_id is what a draft pins.
|
||||
interface PickerTemplate {
|
||||
id: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
firm?: string | null;
|
||||
version: number;
|
||||
version_id?: string;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -153,19 +169,16 @@ function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
|
||||
// shared editor utilities); the local copies were removed in slice 5.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||||
// Mirrors the same shape the email-template variables sidebar uses;
|
||||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||||
// Labels come from the Go-side catalogue (GET /api/docforge/variables),
|
||||
// fetched once on boot into state.varLabels. The frontend keeps only the
|
||||
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
|
||||
// section them — not the label data itself, so labels can't drift from the
|
||||
// resolvers that produce the values (t-paliad-349 slice 5).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
@@ -186,71 +199,6 @@ interface VariableGroup {
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||||
"today": { de: "Heute", en: "Today" },
|
||||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||||
"user.email": { de: "E-Mail", en: "Email" },
|
||||
"user.office": { de: "Büro", en: "Office" },
|
||||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||||
"project.court": { de: "Gericht", en: "Court" },
|
||||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||||
// Procedural-event namespace (t-paliad-262 Slice A, design doc
|
||||
// docs/design-procedural-events-model-2026-05-25.md). The canonical
|
||||
// placeholder names are below; the `rule.*` aliases that follow are
|
||||
// @deprecated but kept forever per m's Q7 lock — existing Word
|
||||
// templates and saved drafts authored with the old names keep
|
||||
// merging identically.
|
||||
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
|
||||
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
|
||||
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
|
||||
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
|
||||
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
|
||||
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
|
||||
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
|
||||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||||
@@ -341,7 +289,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
const entry = state.varLabels[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
@@ -373,6 +321,15 @@ interface State {
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
// t-paliad-349 slice 7 — uploaded templates offered in the picker.
|
||||
templates: PickerTemplate[];
|
||||
templatesLoaded: boolean;
|
||||
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
|
||||
// Go catalogue (GET /api/docforge/variables), the single source of
|
||||
// truth. Empty until the fetch lands; labelFor falls back to the raw
|
||||
// key, so a failed fetch degrades gracefully rather than breaking the
|
||||
// form.
|
||||
varLabels: Record<string, VariableLabel>;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -401,6 +358,9 @@ const state: State = {
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
templates: [],
|
||||
templatesLoaded: false,
|
||||
varLabels: {},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -425,6 +385,21 @@ async function boot(): Promise<void> {
|
||||
console.warn("submission-draft: base catalog fetch failed", err);
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
// t-paliad-349 slice 7 — uploaded-template catalog for the picker.
|
||||
loadTemplates().catch(err => {
|
||||
console.warn("submission-draft: template catalog fetch failed", err);
|
||||
state.templatesLoaded = true;
|
||||
});
|
||||
|
||||
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
|
||||
// before the first paint so the sidebar form labels render. Awaited
|
||||
// because labelFor needs it at paint time; a failure leaves varLabels
|
||||
// empty and labelFor falls back to the raw key (degraded but usable).
|
||||
try {
|
||||
state.varLabels = labelMap(await fetchVariableCatalogue());
|
||||
} catch (err) {
|
||||
console.warn("submission-draft: variable catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
@@ -528,7 +503,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
@@ -583,6 +558,7 @@ function paint(): void {
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintKeywordRow();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
@@ -1059,6 +1035,53 @@ function paintLanguageFallback(): void {
|
||||
el.style.display = fallback ? "" : "none";
|
||||
}
|
||||
|
||||
// autoKeyword returns the lang-aware rule name that leads the exported
|
||||
// filename when the user sets no override — shown as the keyword input's
|
||||
// placeholder so the lawyer sees the default without it being forced.
|
||||
// t-paliad-354.
|
||||
function autoKeyword(): string {
|
||||
const view = state.view;
|
||||
if (!view?.rule) return "";
|
||||
const en = (view.draft.language || view.lang || "de").toLowerCase() === "en";
|
||||
const name = en && view.rule.name_en ? view.rule.name_en : view.rule.name;
|
||||
return (name || "").trim();
|
||||
}
|
||||
|
||||
// paintKeywordRow syncs the "Stichwort (Dateiname)" input with the
|
||||
// draft's stored override (composer_meta.filename_keyword) and shows the
|
||||
// auto-derived rule name as the placeholder. Editing PATCHes the draft on
|
||||
// blur (change), persisting under composer_meta.filename_keyword.
|
||||
// t-paliad-354.
|
||||
function paintKeywordRow(): void {
|
||||
const input = document.getElementById("submission-draft-keyword") as HTMLInputElement | null;
|
||||
if (!input || !state.view) return;
|
||||
const stored = state.view.draft.composer_meta?.["filename_keyword"];
|
||||
input.value = typeof stored === "string" ? stored : "";
|
||||
const auto = autoKeyword();
|
||||
if (auto) input.placeholder = auto;
|
||||
input.onchange = () => { void onKeywordChange(input.value.trim()); };
|
||||
}
|
||||
|
||||
async function onKeywordChange(keyword: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const stored = state.view.draft.composer_meta?.["filename_keyword"];
|
||||
const current = typeof stored === "string" ? stored.trim() : "";
|
||||
if (keyword === current) return;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ filename_keyword: keyword });
|
||||
state.view = view;
|
||||
paintKeywordRow();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft keyword save:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
// Revert to the persisted value so the field doesn't lie.
|
||||
paintKeywordRow();
|
||||
}
|
||||
}
|
||||
|
||||
async function onLanguageChange(lang: "de" | "en"): Promise<void> {
|
||||
if (!state.view) return;
|
||||
if ((state.view.draft.language || "de").toLowerCase() === lang) return;
|
||||
@@ -1217,29 +1240,46 @@ async function loadBases(): Promise<void> {
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
// loadTemplates fetches the firm-shared uploaded-template catalog
|
||||
// (t-paliad-349 slice 7). Failure leaves the list empty — the picker
|
||||
// simply offers no uploaded templates, the editor stays usable.
|
||||
async function loadTemplates(): Promise<void> {
|
||||
const res = await fetch("/api/templates", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error("template list HTTP " + res.status);
|
||||
}
|
||||
const body = await res.json() as { templates?: PickerTemplate[] };
|
||||
state.templates = (body.templates ?? []).filter(t => !!t.version_id);
|
||||
state.templatesLoaded = true;
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
function paintBasePicker(): void {
|
||||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||||
if (!row || !sel || !state.view) return;
|
||||
|
||||
// Hide the picker until the catalog has loaded AND the catalog has
|
||||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||||
if (!state.basesLoaded || state.bases.length === 0) {
|
||||
// Hide the picker only when BOTH catalogs are loaded-but-empty. As long
|
||||
// as bases OR uploaded templates exist, the picker is useful. A failed
|
||||
// fetch leaves the respective list empty; the editor stays usable.
|
||||
const hasBases = state.basesLoaded && state.bases.length > 0;
|
||||
const hasTemplates = state.templatesLoaded && state.templates.length > 0;
|
||||
if (!hasBases && !hasTemplates) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
// Rebuild the <option> list each paint so language toggles + base
|
||||
// catalog updates flow through.
|
||||
// Rebuild the <option> list each paint so language toggles + catalog
|
||||
// updates flow through.
|
||||
sel.innerHTML = "";
|
||||
const currentBaseID = state.view.draft.base_id ?? "";
|
||||
const currentTplVersion = state.view.draft.template_version_id ?? "";
|
||||
|
||||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||||
// to clear after they've already picked one.
|
||||
if (!currentBaseID) {
|
||||
// that state (no base, no template). Avoids tempting the lawyer to
|
||||
// clear after they've already picked one.
|
||||
if (!currentBaseID && !currentTplVersion) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||||
@@ -1252,6 +1292,21 @@ function paintBasePicker(): void {
|
||||
if (b.id === currentBaseID) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// t-paliad-349 slice 7 — uploaded templates as a separate optgroup.
|
||||
// The value is "tpl:<version_id>" so onBaseChange can route it to the
|
||||
// template_version_id PATCH instead of base_id.
|
||||
if (hasTemplates) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
|
||||
for (const tmpl of state.templates) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "tpl:" + tmpl.version_id;
|
||||
opt.textContent = isEN() ? tmpl.name_en : tmpl.name_de;
|
||||
if (tmpl.version_id === currentTplVersion) opt.selected = true;
|
||||
group.appendChild(opt);
|
||||
}
|
||||
sel.appendChild(group);
|
||||
}
|
||||
|
||||
// Wire change handler once per paint. Removing then re-adding
|
||||
// keeps the binding consistent across repaints (e.g. after
|
||||
@@ -1259,12 +1314,17 @@ function paintBasePicker(): void {
|
||||
sel.onchange = () => { onBaseChange(sel.value); };
|
||||
}
|
||||
|
||||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||||
async function onBaseChange(newValue: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const payload: Record<string, unknown> = {
|
||||
// Empty string in the picker maps to null = clear.
|
||||
base_id: newBaseID === "" ? null : newBaseID,
|
||||
};
|
||||
// The picker mixes legacy bases (plain uuid) and uploaded templates
|
||||
// ("tpl:<version_id>"). Route to the matching field and clear the other
|
||||
// so the two render paths stay mutually exclusive. Empty = clear both.
|
||||
let payload: Record<string, unknown>;
|
||||
if (newValue.startsWith("tpl:")) {
|
||||
payload = { template_version_id: newValue.slice(4), base_id: null };
|
||||
} else {
|
||||
payload = { base_id: newValue === "" ? null : newValue, template_version_id: null };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}`,
|
||||
@@ -1985,11 +2045,11 @@ function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec
|
||||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||||
row.innerHTML = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
|
||||
</div>
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
row.addEventListener("click", () => {
|
||||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||||
});
|
||||
@@ -2019,15 +2079,6 @@ async function insertBlockIntoSection(blockID: string, sectionID: string, overla
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
@@ -2104,17 +2155,6 @@ function findVarInput(key: string): HTMLInputElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
function cssEscape(s: string): string {
|
||||
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
|
||||
// older browsers may lack it; defensive fallback escapes characters
|
||||
// CSS treats as special. Placeholder keys never carry whitespace or
|
||||
// quotes so escaping is straightforward.
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function onDraftVarClick(key: string, ev: Event): void {
|
||||
const input = findVarInput(key);
|
||||
if (!input) return;
|
||||
|
||||
314
frontend/src/client/templates-authoring.ts
Normal file
314
frontend/src/client/templates-authoring.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — client for the template authoring page.
|
||||
//
|
||||
// Flow: list templates → upload a .docx (or open one) → the carrier renders
|
||||
// as run spans (<span class="docforge-run" data-run="N">) → the admin
|
||||
// selects text within one run, then clicks a variable in the palette → the
|
||||
// server injects {{slot}} at the selection and returns the updated view.
|
||||
//
|
||||
// The select-then-pick gesture keys on the run index (data-run) + the
|
||||
// selected text, matching the server's text-based InjectSlot so umlauts
|
||||
// can't desync the selection from the slice. Selections that span more than
|
||||
// one run are rejected with a hint (v1 scope: single-run text slots).
|
||||
|
||||
interface TemplateMeta {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
kind: string;
|
||||
source_format: string;
|
||||
firm?: string;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface TemplateSlot {
|
||||
key: string;
|
||||
anchor: string;
|
||||
label?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface AuthoringView {
|
||||
template: TemplateMeta;
|
||||
preview_html: string;
|
||||
slots: TemplateSlot[];
|
||||
}
|
||||
|
||||
interface Selection1Run {
|
||||
runIndex: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
catalogue: VariableEntry[];
|
||||
openID: string | null;
|
||||
activeSlotKey: string | null;
|
||||
selection: Selection1Run | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
catalogue: [],
|
||||
openID: null,
|
||||
activeSlotKey: null,
|
||||
selection: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return (document.documentElement.lang || "de").toLowerCase().startsWith("en");
|
||||
}
|
||||
|
||||
function labelOf(e: VariableEntry): string {
|
||||
return isEN() ? e.label_en : e.label_de;
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
try {
|
||||
state.catalogue = await fetchVariableCatalogue();
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
wireUploadForm();
|
||||
await loadList();
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
const host = document.getElementById("docforge-template-list");
|
||||
if (!host) return;
|
||||
let metas: TemplateMeta[] = [];
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } });
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { templates: TemplateMeta[] };
|
||||
metas = body.templates ?? [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: list fetch failed", err);
|
||||
}
|
||||
if (metas.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = metas
|
||||
.map((m) => {
|
||||
const name = isEN() ? m.name_en : m.name_de;
|
||||
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
|
||||
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
|
||||
<span class="docforge-template-name">${escapeHtml(name)}</span>
|
||||
<span class="docforge-template-meta">v${m.version}${firm}</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
host.querySelectorAll<HTMLLIElement>(".docforge-template-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const id = li.dataset.templateId;
|
||||
if (id) void openTemplate(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireUploadForm(): void {
|
||||
const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const status = document.getElementById("docforge-upload-status");
|
||||
const data = new FormData(form);
|
||||
setText(status, isEN() ? "Uploading…" : "Lädt hoch…");
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { method: "POST", body: data });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
const view = (await res.json()) as AuthoringView;
|
||||
setText(status, "");
|
||||
form.reset();
|
||||
await loadList();
|
||||
openView(view);
|
||||
} catch (err) {
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openTemplate(id: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: open failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function openView(view: AuthoringView): void {
|
||||
state.openID = view.template.id;
|
||||
state.activeSlotKey = null;
|
||||
state.selection = null;
|
||||
|
||||
const workspace = document.getElementById("docforge-workspace");
|
||||
if (workspace) workspace.hidden = false;
|
||||
|
||||
const title = document.getElementById("docforge-workspace-title");
|
||||
if (title) {
|
||||
const name = isEN() ? view.template.name_en : view.template.name_de;
|
||||
title.textContent = `${name} · v${view.template.version}`;
|
||||
}
|
||||
|
||||
renderPreview(view.preview_html);
|
||||
renderSlots(view.slots);
|
||||
renderPalette();
|
||||
setWorkspaceStatus("");
|
||||
}
|
||||
|
||||
function renderPreview(html: string): void {
|
||||
const host = document.getElementById("docforge-preview");
|
||||
if (!host) return;
|
||||
host.innerHTML = html;
|
||||
host.addEventListener("mouseup", onPreviewSelect);
|
||||
}
|
||||
|
||||
// onPreviewSelect captures a selection that lies entirely within one run
|
||||
// span; otherwise it clears the pending selection and hints.
|
||||
function onPreviewSelect(): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const text = sel.toString();
|
||||
if (text === "") {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const anchorRun = closestRun(sel.anchorNode);
|
||||
const focusRun = closestRun(sel.focusNode);
|
||||
if (!anchorRun || anchorRun !== focusRun) {
|
||||
state.selection = null;
|
||||
setWorkspaceStatus(isEN()
|
||||
? "Select within a single text span."
|
||||
: "Bitte innerhalb einer Textstelle markieren.");
|
||||
return;
|
||||
}
|
||||
const runIndex = Number(anchorRun.dataset.run);
|
||||
if (Number.isNaN(runIndex)) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
state.selection = { runIndex, text };
|
||||
setWorkspaceStatus(state.activeSlotKey
|
||||
? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`)
|
||||
: (isEN() ? `Selected “${text}” — now pick a variable.` : `„${text}" markiert — jetzt Variable wählen.`));
|
||||
}
|
||||
|
||||
function closestRun(node: Node | null): HTMLElement | null {
|
||||
let el: Node | null = node;
|
||||
while (el && el !== document.body) {
|
||||
if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el;
|
||||
el = el.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// renderPalette groups catalogue entries by their namespace group and wires
|
||||
// each as a click-to-place control.
|
||||
function renderPalette(): void {
|
||||
const host = document.getElementById("docforge-palette");
|
||||
if (!host) return;
|
||||
if (state.catalogue.length === 0) {
|
||||
host.innerHTML = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const groups = new Map<string, VariableEntry[]>();
|
||||
for (const e of state.catalogue) {
|
||||
const arr = groups.get(e.group) ?? [];
|
||||
arr.push(e);
|
||||
groups.set(e.group, arr);
|
||||
}
|
||||
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
|
||||
for (const [group, entries] of groups) {
|
||||
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
|
||||
for (const e of entries) {
|
||||
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
host.querySelectorAll<HTMLButtonElement>(".docforge-palette-var").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn));
|
||||
});
|
||||
}
|
||||
|
||||
function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void {
|
||||
state.activeSlotKey = slotKey;
|
||||
const host = document.getElementById("docforge-palette");
|
||||
host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active"));
|
||||
btn.classList.add("docforge-palette-var--active");
|
||||
|
||||
if (state.selection) {
|
||||
void placeSlot(state.selection.runIndex, state.selection.text, slotKey);
|
||||
} else {
|
||||
setWorkspaceStatus(isEN()
|
||||
? `${slotKey} selected — now highlight the text to replace.`
|
||||
: `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise<void> {
|
||||
if (!state.openID) return;
|
||||
setWorkspaceStatus(isEN() ? "Placing…" : "Setze…");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSlots(slots: TemplateSlot[]): void {
|
||||
const host = document.getElementById("docforge-slot-list");
|
||||
if (!host) return;
|
||||
if (slots.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = slots
|
||||
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setWorkspaceStatus(msg: string): void {
|
||||
setText(document.getElementById("docforge-workspace-status"), msg);
|
||||
}
|
||||
|
||||
function setText(el: Element | null, msg: string): void {
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => void boot());
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,320 +0,0 @@
|
||||
// Per-event-card choice popover + chip indicator (t-paliad-265 /
|
||||
// m/paliad#96).
|
||||
//
|
||||
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
|
||||
// button on cards that carry a non-empty `choices_offered` declaration
|
||||
// and an inert chip span next to the title. This module:
|
||||
//
|
||||
// 1. Wires a delegated click handler on the result container so the
|
||||
// caret opens a popover with the offered choice-kinds.
|
||||
// 2. Commits the user's pick — either by POSTing to the project-
|
||||
// bound endpoint or by mutating the in-memory state for the
|
||||
// unbound (no-project) case.
|
||||
// 3. Rehydrates the chip on every render + after every commit so the
|
||||
// glanceable indicator matches the active state.
|
||||
//
|
||||
// Two consumer pages — /tools/verfahrensablauf (unbound) and
|
||||
// /tools/fristenrechner (project-bound) — both wire this module
|
||||
// once at boot via attachEventCardChoices().
|
||||
|
||||
import { escAttr, escHtml } from "./verfahrensablauf-core";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
|
||||
|
||||
export interface EventChoice {
|
||||
submission_code: string;
|
||||
choice_kind: ChoiceKind;
|
||||
choice_value: string;
|
||||
}
|
||||
|
||||
// State surface — the page passes in callbacks that own persistence.
|
||||
// commit / remove must trigger a recalc on the page side (the popover
|
||||
// only owns its own visual state).
|
||||
export interface EventCardChoicesOpts {
|
||||
container: HTMLElement;
|
||||
// Initial state: a list of choices. The page seeds this from the
|
||||
// server response (project-bound) or from URL params (unbound).
|
||||
initial: EventChoice[];
|
||||
// commit gets called for an UPSERT. The page POSTs to the API (or
|
||||
// mutates URL state) AND triggers a recalc.
|
||||
commit: (choice: EventChoice) => Promise<void> | void;
|
||||
// remove gets called when the user resets a choice.
|
||||
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// One mutable bag per attach() call. The current implementation is a
|
||||
// single-page singleton — paginated views (admin tables) are not in
|
||||
// scope. Last-write-wins on the in-memory state.
|
||||
interface AttachedState {
|
||||
opts: EventCardChoicesOpts;
|
||||
// active: submission_code → kind → value. Rebuilt from `initial`
|
||||
// on every reseed() call.
|
||||
active: Map<string, Map<ChoiceKind, string>>;
|
||||
popover: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, AttachedState>();
|
||||
|
||||
// attachEventCardChoices wires the delegated click + popover lifecycle
|
||||
// to the given container. Call once per page after mount; safe to call
|
||||
// again with a fresh container.
|
||||
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
const state: AttachedState = {
|
||||
opts,
|
||||
active: new Map(),
|
||||
popover: null,
|
||||
};
|
||||
for (const c of opts.initial) {
|
||||
if (!state.active.has(c.submission_code)) {
|
||||
state.active.set(c.submission_code, new Map());
|
||||
}
|
||||
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
|
||||
}
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
if (state.popover && !state.popover.contains(e.target as Node)) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// ESC also closes.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.popover) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// Repaint chips on every renderResults() call. The page is
|
||||
// responsible for calling reseedChips() after re-render so the chip
|
||||
// dom node (re-created by the renderer) picks the active state up.
|
||||
reseedChips(opts.container);
|
||||
}
|
||||
|
||||
// reseedChips walks every chip span in the container and re-renders
|
||||
// its content from the active state map. Idempotent.
|
||||
export function reseedChips(container: HTMLElement): void {
|
||||
const state = states.get(container);
|
||||
if (!state) return;
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const kinds = state.active.get(code);
|
||||
if (!kinds || kinds.size === 0) {
|
||||
chip.innerHTML = "";
|
||||
chip.dataset.empty = "true";
|
||||
return;
|
||||
}
|
||||
chip.dataset.empty = "false";
|
||||
chip.innerHTML = renderChip(kinds);
|
||||
});
|
||||
// Skipped rows fade out via a class on the card-item ancestor.
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const skipped = state.active.get(code)?.get("skip") === "true";
|
||||
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
|
||||
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChip(kinds: Map<ChoiceKind, string>): string {
|
||||
const parts: string[] = [];
|
||||
if (kinds.get("skip") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
|
||||
}
|
||||
const ap = kinds.get("appellant");
|
||||
if (ap && ap !== "" ) {
|
||||
let label = "";
|
||||
switch (ap) {
|
||||
case "claimant": label = t("choices.appellant.claimant"); break;
|
||||
case "defendant": label = t("choices.appellant.defendant"); break;
|
||||
case "both": label = t("choices.appellant.both"); break;
|
||||
case "none": label = t("choices.appellant.none"); break;
|
||||
}
|
||||
if (label) {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
|
||||
}
|
||||
}
|
||||
if (kinds.get("include_ccr") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
closePopover(state);
|
||||
const code = caret.dataset.submissionCode || "";
|
||||
if (!code) return;
|
||||
let offered: Record<string, unknown> = {};
|
||||
try {
|
||||
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
if (Array.isArray(offered.include_ccr)) {
|
||||
blocks.push(renderToggleBlock(state, code, "include_ccr"));
|
||||
}
|
||||
if (Array.isArray(offered.skip)) {
|
||||
blocks.push(renderToggleBlock(state, code, "skip"));
|
||||
}
|
||||
pop.innerHTML = blocks.join("");
|
||||
|
||||
document.body.appendChild(pop);
|
||||
state.popover = pop;
|
||||
positionPopover(pop, caret);
|
||||
|
||||
pop.addEventListener("click", async (e) => {
|
||||
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
|
||||
const value = btn.dataset.choiceValue || "";
|
||||
const action = btn.dataset.choiceAction;
|
||||
if (!kind) return;
|
||||
try {
|
||||
if (action === "set") {
|
||||
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
if (!state.active.has(code)) state.active.set(code, new Map());
|
||||
state.active.get(code)!.set(kind, value);
|
||||
} else if (action === "clear") {
|
||||
await state.opts.remove(code, kind);
|
||||
state.active.get(code)?.delete(kind);
|
||||
}
|
||||
reseedChips(state.opts.container);
|
||||
closePopover(state);
|
||||
} catch (err) {
|
||||
console.error("event card choice commit failed", err);
|
||||
// Surface a soft inline error inside the popover; do NOT close.
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "event-card-choices-error";
|
||||
errEl.textContent = t("choices.commit.error");
|
||||
pop.appendChild(errEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
|
||||
const current = state.active.get(code)?.get("appellant") || "";
|
||||
const buttons = values
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => {
|
||||
const labelKey = `choices.appellant.${v}` as const;
|
||||
const isActive = v === current;
|
||||
return `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="appellant"
|
||||
data-choice-value="${escAttr(v)}"
|
||||
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
})
|
||||
.join("");
|
||||
const reset = current
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
|
||||
<div class="event-card-choices-options">${buttons}</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
|
||||
const current = state.active.get(code)?.get(kind) || "false";
|
||||
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
|
||||
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
|
||||
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
|
||||
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="${kind}"
|
||||
data-choice-value="${v}"
|
||||
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
const reset = state.active.get(code)?.has(kind)
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
|
||||
<div class="event-card-choices-options">
|
||||
${opt("true", trueKey)}
|
||||
${opt("false", falseKey)}
|
||||
</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
state.popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
|
||||
const rect = caret.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
pop.style.position = "absolute";
|
||||
pop.style.top = `${rect.bottom + scrollY + 4}px`;
|
||||
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
|
||||
pop.style.zIndex = "1000";
|
||||
}
|
||||
|
||||
// Returns the current in-memory choice list for the given container —
|
||||
// used by the unbound /tools/verfahrensablauf page to keep the URL
|
||||
// param in sync.
|
||||
export function currentChoices(container: HTMLElement): EventChoice[] {
|
||||
const state = states.get(container);
|
||||
if (!state) return [];
|
||||
const out: EventChoice[] = [];
|
||||
state.active.forEach((kinds, code) => {
|
||||
kinds.forEach((value, kind) => {
|
||||
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -1,309 +0,0 @@
|
||||
// Unit tests for the /tools/verfahrensablauf URL + scenario-localStorage
|
||||
// state contract (t-paliad-308 / m/paliad#137). Run with `bun test`.
|
||||
//
|
||||
// The contract:
|
||||
// 1. URL params (proceeding, side, target, trigger_date) define which
|
||||
// timeline kind the user is looking at — paste-able, shareable,
|
||||
// refresh-resistant.
|
||||
// 2. localStorage (paliad.verfahrensablauf.scenario.*) holds the
|
||||
// per-user scenario tweaks (event_choices, court_id, flags,
|
||||
// show_hidden) — these never leak into a shared link.
|
||||
// 3. On hydrate, URL wins. localStorage fills the rest.
|
||||
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
APPEAL_TARGETS,
|
||||
SCENARIO_KEYS,
|
||||
SCENARIO_PREFIX,
|
||||
URL_KEYS,
|
||||
applyFiltersToSearch,
|
||||
hydrate,
|
||||
makeMemoryStorage,
|
||||
parseAppealTargetFromSearch,
|
||||
parseProceedingFromSearch,
|
||||
parseSideFromSearch,
|
||||
parseTriggerDateFromSearch,
|
||||
readBoolFlag,
|
||||
readCourtId,
|
||||
readEventChoices,
|
||||
readScenario,
|
||||
writeBoolFlag,
|
||||
writeCourtId,
|
||||
writeEventChoices,
|
||||
} from "./verfahrensablauf-state";
|
||||
|
||||
describe("URL parsers — filter chips", () => {
|
||||
test("parseProceedingFromSearch returns empty string when absent", () => {
|
||||
expect(parseProceedingFromSearch("")).toBe("");
|
||||
expect(parseProceedingFromSearch("?side=claimant")).toBe("");
|
||||
});
|
||||
|
||||
test("parseProceedingFromSearch echoes the raw value", () => {
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.inf.cfi")).toBe("upc.inf.cfi");
|
||||
expect(parseProceedingFromSearch("?proceeding=upc.apl.unified&side=claimant")).toBe("upc.apl.unified");
|
||||
});
|
||||
|
||||
test("parseSideFromSearch validates the enum", () => {
|
||||
expect(parseSideFromSearch("?side=claimant")).toBe("claimant");
|
||||
expect(parseSideFromSearch("?side=defendant")).toBe("defendant");
|
||||
expect(parseSideFromSearch("?side=neither")).toBe(null);
|
||||
expect(parseSideFromSearch("")).toBe(null);
|
||||
});
|
||||
|
||||
test("parseAppealTargetFromSearch only accepts canonical slugs", () => {
|
||||
for (const t of APPEAL_TARGETS) {
|
||||
expect(parseAppealTargetFromSearch(`?target=${t}`)).toBe(t);
|
||||
}
|
||||
expect(parseAppealTargetFromSearch("?target=unknown")).toBe("");
|
||||
expect(parseAppealTargetFromSearch("")).toBe("");
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch validates the ISO-date shape", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-05-26")).toBe("2026-05-26");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2024-02-29")).toBe("2024-02-29"); // leap year
|
||||
});
|
||||
|
||||
test("parseTriggerDateFromSearch rejects malformed and impossible dates", () => {
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-02-30")).toBe(""); // Feb 30
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-13-01")).toBe(""); // month 13
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=tomorrow")).toBe("");
|
||||
expect(parseTriggerDateFromSearch("?trigger_date=2026-5-26")).toBe(""); // 1-digit month
|
||||
expect(parseTriggerDateFromSearch("")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL encoder — applyFiltersToSearch", () => {
|
||||
test("empty filters preserve the existing query string", () => {
|
||||
expect(applyFiltersToSearch("?other=keep", {})).toBe("?other=keep");
|
||||
});
|
||||
|
||||
test("setting a filter writes the canonical key", () => {
|
||||
expect(applyFiltersToSearch("", { proceeding: "upc.inf.cfi" })).toBe("?proceeding=upc.inf.cfi");
|
||||
expect(applyFiltersToSearch("", { side: "claimant" })).toBe("?side=claimant");
|
||||
expect(applyFiltersToSearch("", { target: "endentscheidung" })).toBe("?target=endentscheidung");
|
||||
expect(applyFiltersToSearch("", { triggerDate: "2026-05-26" })).toBe("?trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("setting null / empty / undefined deletes the key", () => {
|
||||
expect(applyFiltersToSearch("?side=claimant", { side: null })).toBe("");
|
||||
expect(applyFiltersToSearch("?proceeding=upc.inf.cfi", { proceeding: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?target=endentscheidung", { target: "" })).toBe("");
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "" })).toBe("");
|
||||
});
|
||||
|
||||
test("invalid trigger_date is deleted (never written as-is)", () => {
|
||||
expect(applyFiltersToSearch("?trigger_date=2026-05-26", { triggerDate: "bogus" })).toBe("");
|
||||
});
|
||||
|
||||
test("setting all four filters together emits all four keys", () => {
|
||||
const out = applyFiltersToSearch("", {
|
||||
proceeding: "upc.apl.unified",
|
||||
side: "defendant",
|
||||
target: "endentscheidung",
|
||||
triggerDate: "2026-05-26",
|
||||
});
|
||||
expect(out).toContain("proceeding=upc.apl.unified");
|
||||
expect(out).toContain("side=defendant");
|
||||
expect(out).toContain("target=endentscheidung");
|
||||
expect(out).toContain("trigger_date=2026-05-26");
|
||||
});
|
||||
|
||||
test("other params (project, view) are preserved", () => {
|
||||
const out = applyFiltersToSearch("?project=abc&view=timeline", { side: "claimant" });
|
||||
expect(out).toContain("project=abc");
|
||||
expect(out).toContain("view=timeline");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
|
||||
test("absent keys in the filter object don't touch existing URL values", () => {
|
||||
// Only updating side — proceeding should be untouched.
|
||||
const out = applyFiltersToSearch("?proceeding=upc.inf.cfi&side=defendant", { side: "claimant" });
|
||||
expect(out).toContain("proceeding=upc.inf.cfi");
|
||||
expect(out).toContain("side=claimant");
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL round-trip — encode then parse yields the same value", () => {
|
||||
test("proceeding", () => {
|
||||
const enc = applyFiltersToSearch("", { proceeding: "upc.inf.cfi" });
|
||||
expect(parseProceedingFromSearch(enc)).toBe("upc.inf.cfi");
|
||||
});
|
||||
|
||||
test("side", () => {
|
||||
const enc = applyFiltersToSearch("", { side: "defendant" });
|
||||
expect(parseSideFromSearch(enc)).toBe("defendant");
|
||||
});
|
||||
|
||||
test("target", () => {
|
||||
const enc = applyFiltersToSearch("", { target: "kostenentscheidung" });
|
||||
expect(parseAppealTargetFromSearch(enc)).toBe("kostenentscheidung");
|
||||
});
|
||||
|
||||
test("trigger_date", () => {
|
||||
const enc = applyFiltersToSearch("", { triggerDate: "2026-05-26" });
|
||||
expect(parseTriggerDateFromSearch(enc)).toBe("2026-05-26");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scenario localStorage helpers", () => {
|
||||
test("SCENARIO_PREFIX is paliad.verfahrensablauf.scenario and all keys live under it", () => {
|
||||
expect(SCENARIO_PREFIX).toBe("paliad.verfahrensablauf.scenario");
|
||||
for (const key of Object.values(SCENARIO_KEYS)) {
|
||||
expect(key.startsWith(SCENARIO_PREFIX + ".")).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("readEventChoices returns [] on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readEventChoices(s)).toEqual([]);
|
||||
});
|
||||
|
||||
test("writeEventChoices + readEventChoices round-trip", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const choices = [
|
||||
{ submission_code: "upc.inf.cfi.r12", choice_kind: "appellant" as const, choice_value: "claimant" },
|
||||
{ submission_code: "upc.inf.cfi.r30", choice_kind: "include_ccr" as const, choice_value: "1" },
|
||||
];
|
||||
writeEventChoices(s, choices);
|
||||
expect(readEventChoices(s)).toEqual(choices);
|
||||
});
|
||||
|
||||
test("writeEventChoices([]) clears the key (removeItem semantic, not empty string)", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeEventChoices(s, [{ submission_code: "r1", choice_kind: "skip", choice_value: "1" }]);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).not.toBe(null);
|
||||
writeEventChoices(s, []);
|
||||
expect(s.getItem(SCENARIO_KEYS.eventChoices)).toBe(null);
|
||||
});
|
||||
|
||||
test("readEventChoices ignores unknown choice_kind values", () => {
|
||||
const s = makeMemoryStorage();
|
||||
s.setItem(SCENARIO_KEYS.eventChoices, "r1:appellant=claimant,r2:bogus=x,r3:skip=1");
|
||||
expect(readEventChoices(s)).toEqual([
|
||||
{ submission_code: "r1", choice_kind: "appellant", choice_value: "claimant" },
|
||||
{ submission_code: "r3", choice_kind: "skip", choice_value: "1" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("readCourtId returns '' on empty storage, echoes stored value otherwise", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readCourtId(s)).toBe("");
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(readCourtId(s)).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("writeCourtId('') removes the key", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe("UPC-LD-MUC");
|
||||
writeCourtId(s, "");
|
||||
expect(s.getItem(SCENARIO_KEYS.courtId)).toBe(null);
|
||||
});
|
||||
|
||||
test("readBoolFlag / writeBoolFlag round-trip with removeItem on false", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(true);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe("1");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, false);
|
||||
expect(readBoolFlag(s, SCENARIO_KEYS.ccr)).toBe(false);
|
||||
expect(s.getItem(SCENARIO_KEYS.ccr)).toBe(null);
|
||||
});
|
||||
|
||||
test("readScenario returns all fields defaulted on empty storage", () => {
|
||||
const s = makeMemoryStorage();
|
||||
expect(readScenario(s)).toEqual({
|
||||
eventChoices: [],
|
||||
courtId: "",
|
||||
ccr: false,
|
||||
infAmend: false,
|
||||
revAmend: false,
|
||||
revCci: false,
|
||||
showHidden: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Hydration order — URL wins, localStorage fills the rest", () => {
|
||||
test("URL fills filter chips, localStorage fills scenario state", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
writeBoolFlag(s, SCENARIO_KEYS.showHidden, true);
|
||||
writeBoolFlag(s, SCENARIO_KEYS.ccr, true);
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.inf.cfi&side=defendant&target=endentscheidung&trigger_date=2026-05-26",
|
||||
s,
|
||||
);
|
||||
// URL-sourced
|
||||
expect(out.proceeding).toBe("upc.inf.cfi");
|
||||
expect(out.side).toBe("defendant");
|
||||
expect(out.target).toBe("endentscheidung");
|
||||
expect(out.triggerDate).toBe("2026-05-26");
|
||||
// localStorage-sourced
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
expect(out.showHidden).toBe(true);
|
||||
expect(out.ccr).toBe(true);
|
||||
});
|
||||
|
||||
test("absent URL → all filter fields are empty/null, localStorage still hydrates scenario", () => {
|
||||
const s = makeMemoryStorage();
|
||||
writeCourtId(s, "UPC-LD-MUC");
|
||||
const out = hydrate("", s);
|
||||
expect(out.proceeding).toBe("");
|
||||
expect(out.side).toBe(null);
|
||||
expect(out.target).toBe("");
|
||||
expect(out.triggerDate).toBe("");
|
||||
expect(out.courtId).toBe("UPC-LD-MUC");
|
||||
});
|
||||
|
||||
test("absent localStorage → URL still fills filter chips, scenario defaults", () => {
|
||||
const s = makeMemoryStorage();
|
||||
const out = hydrate(
|
||||
"?proceeding=upc.apl.unified&side=claimant&target=anordnung&trigger_date=2026-07-01",
|
||||
s,
|
||||
);
|
||||
expect(out.proceeding).toBe("upc.apl.unified");
|
||||
expect(out.side).toBe("claimant");
|
||||
expect(out.target).toBe("anordnung");
|
||||
expect(out.triggerDate).toBe("2026-07-01");
|
||||
expect(out.courtId).toBe("");
|
||||
expect(out.eventChoices).toEqual([]);
|
||||
expect(out.showHidden).toBe(false);
|
||||
});
|
||||
|
||||
test("a shared link doesn't leak the recipient's scenario state in", () => {
|
||||
// Two storages: m's (loaded with court + flags) and a recipient's
|
||||
// (empty). The same URL should reproduce filter chips identically
|
||||
// but leave each user's scenario state untouched.
|
||||
const mStorage = makeMemoryStorage();
|
||||
writeCourtId(mStorage, "UPC-LD-MUC");
|
||||
writeBoolFlag(mStorage, SCENARIO_KEYS.ccr, true);
|
||||
const recipientStorage = makeMemoryStorage();
|
||||
|
||||
const sharedURL = "?proceeding=upc.inf.cfi&side=defendant&trigger_date=2026-05-26";
|
||||
|
||||
const mView = hydrate(sharedURL, mStorage);
|
||||
const recipientView = hydrate(sharedURL, recipientStorage);
|
||||
|
||||
// Filter chips identical
|
||||
expect(mView.proceeding).toBe(recipientView.proceeding);
|
||||
expect(mView.side).toBe(recipientView.side);
|
||||
expect(mView.triggerDate).toBe(recipientView.triggerDate);
|
||||
|
||||
// Scenario state diverges — recipient sees defaults
|
||||
expect(mView.courtId).toBe("UPC-LD-MUC");
|
||||
expect(recipientView.courtId).toBe("");
|
||||
expect(mView.ccr).toBe(true);
|
||||
expect(recipientView.ccr).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("URL key constants match the documented contract", () => {
|
||||
test("URL_KEYS uses the spec'd snake_case names", () => {
|
||||
expect(URL_KEYS.proceeding).toBe("proceeding");
|
||||
expect(URL_KEYS.side).toBe("side");
|
||||
expect(URL_KEYS.target).toBe("target");
|
||||
expect(URL_KEYS.triggerDate).toBe("trigger_date");
|
||||
});
|
||||
});
|
||||
@@ -1,263 +0,0 @@
|
||||
// /tools/verfahrensablauf URL + scenario-localStorage state contract
|
||||
// (t-paliad-308 / m/paliad#137). Splits the page's persisted state into
|
||||
// two namespaces:
|
||||
//
|
||||
// URL params (filter chips — the timeline kind the user is looking
|
||||
// at; paste-able, shareable, refresh-resistant):
|
||||
// proceeding, side, target, trigger_date
|
||||
//
|
||||
// localStorage `paliad.verfahrensablauf.scenario.*` (per-user
|
||||
// scenario inputs — the noisy parts that don't belong in a URL):
|
||||
// event_choices, court_id, ccr, inf_amend, rev_amend, rev_cci,
|
||||
// show_hidden
|
||||
//
|
||||
// Hydration order: URL wins. On page load, URL fills the filter chips;
|
||||
// localStorage fills the rest. Filter-chip changes write to URL only.
|
||||
// Scenario changes write to localStorage only. A shared link from a
|
||||
// colleague reproduces the timeline kind (proceeding + side + target +
|
||||
// trigger_date) but never leaks the recipient's court / flag /
|
||||
// event_choices state in.
|
||||
//
|
||||
// All helpers in this module are pure: they take a search string (or a
|
||||
// StorageLike) and return values, no DOM. The wiring in
|
||||
// ../verfahrensablauf.ts mounts them onto window.location +
|
||||
// window.localStorage at runtime.
|
||||
|
||||
import type { EventChoice, ChoiceKind } from "./event-card-choices";
|
||||
|
||||
// ----- URL params (filter chips) ----------------------------------
|
||||
|
||||
export type Side = "claimant" | "defendant" | null;
|
||||
|
||||
export const APPEAL_TARGETS = [
|
||||
"endentscheidung",
|
||||
"kostenentscheidung",
|
||||
"anordnung",
|
||||
"schadensbemessung",
|
||||
"bucheinsicht",
|
||||
] as const;
|
||||
export type AppealTarget = (typeof APPEAL_TARGETS)[number] | "";
|
||||
|
||||
export const URL_KEYS = {
|
||||
proceeding: "proceeding",
|
||||
side: "side",
|
||||
target: "target",
|
||||
triggerDate: "trigger_date",
|
||||
} as const;
|
||||
|
||||
// parseProceedingFromSearch extracts the proceeding code. Returns ""
|
||||
// if absent. No validation against the proceeding registry — that's
|
||||
// the caller's job (an unknown code from a stale link should leave
|
||||
// the first-tile auto-select fallback running).
|
||||
export function parseProceedingFromSearch(search: string): string {
|
||||
const v = new URLSearchParams(search).get(URL_KEYS.proceeding);
|
||||
return v ?? "";
|
||||
}
|
||||
|
||||
export function parseSideFromSearch(search: string): Side {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.side);
|
||||
return raw === "claimant" || raw === "defendant" ? raw : null;
|
||||
}
|
||||
|
||||
export function parseAppealTargetFromSearch(search: string): AppealTarget {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.target) || "";
|
||||
if ((APPEAL_TARGETS as readonly string[]).includes(raw)) {
|
||||
return raw as AppealTarget;
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// parseTriggerDateFromSearch validates the ISO-date shape so a
|
||||
// malformed link can't poison the date input. Accepts "YYYY-MM-DD"
|
||||
// only. Round-tripped against Date to reject 2026-02-30 etc.
|
||||
export function parseTriggerDateFromSearch(search: string): string {
|
||||
const raw = new URLSearchParams(search).get(URL_KEYS.triggerDate) || "";
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(raw)) return "";
|
||||
const d = new Date(raw + "T00:00:00Z");
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
if (d.toISOString().slice(0, 10) !== raw) return "";
|
||||
return raw;
|
||||
}
|
||||
|
||||
// applyFiltersToSearch produces the canonical query string for the
|
||||
// four URL-owned params. Other params (e.g. ?view=, ?project=) are
|
||||
// preserved verbatim. Empty values are deleted, never written as
|
||||
// empty string, so the URL stays clean on the default.
|
||||
export function applyFiltersToSearch(
|
||||
search: string,
|
||||
filters: { proceeding?: string; side?: Side; target?: AppealTarget; triggerDate?: string },
|
||||
): string {
|
||||
const params = new URLSearchParams(search);
|
||||
if ("proceeding" in filters) {
|
||||
if (filters.proceeding && filters.proceeding !== "") {
|
||||
params.set(URL_KEYS.proceeding, filters.proceeding);
|
||||
} else {
|
||||
params.delete(URL_KEYS.proceeding);
|
||||
}
|
||||
}
|
||||
if ("side" in filters) {
|
||||
if (filters.side === "claimant" || filters.side === "defendant") {
|
||||
params.set(URL_KEYS.side, filters.side);
|
||||
} else {
|
||||
params.delete(URL_KEYS.side);
|
||||
}
|
||||
}
|
||||
if ("target" in filters) {
|
||||
if (filters.target && filters.target !== "") {
|
||||
params.set(URL_KEYS.target, filters.target);
|
||||
} else {
|
||||
params.delete(URL_KEYS.target);
|
||||
}
|
||||
}
|
||||
if ("triggerDate" in filters) {
|
||||
if (filters.triggerDate && /^\d{4}-\d{2}-\d{2}$/.test(filters.triggerDate)) {
|
||||
params.set(URL_KEYS.triggerDate, filters.triggerDate);
|
||||
} else {
|
||||
params.delete(URL_KEYS.triggerDate);
|
||||
}
|
||||
}
|
||||
const s = params.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
// ----- localStorage (scenario state) ------------------------------
|
||||
|
||||
export const SCENARIO_PREFIX = "paliad.verfahrensablauf.scenario";
|
||||
export const SCENARIO_KEYS = {
|
||||
eventChoices: `${SCENARIO_PREFIX}.event_choices`,
|
||||
courtId: `${SCENARIO_PREFIX}.court_id`,
|
||||
ccr: `${SCENARIO_PREFIX}.ccr`,
|
||||
infAmend: `${SCENARIO_PREFIX}.inf_amend`,
|
||||
revAmend: `${SCENARIO_PREFIX}.rev_amend`,
|
||||
revCci: `${SCENARIO_PREFIX}.rev_cci`,
|
||||
showHidden: `${SCENARIO_PREFIX}.show_hidden`,
|
||||
} as const;
|
||||
|
||||
// StorageLike is the tiny subset of the Web Storage API the scenario
|
||||
// helpers actually use. Lets the tests pass a Map-backed fake without
|
||||
// pulling in a full localStorage polyfill.
|
||||
export interface StorageLike {
|
||||
getItem(key: string): string | null;
|
||||
setItem(key: string, value: string): void;
|
||||
removeItem(key: string): void;
|
||||
}
|
||||
|
||||
// readEventChoices is forgiving: malformed tuples or unknown
|
||||
// choice_kinds are dropped silently. Same shape as the legacy URL
|
||||
// codec (comma-separated `submission_code:kind=value`).
|
||||
export function readEventChoices(storage: StorageLike): EventChoice[] {
|
||||
const raw = storage.getItem(SCENARIO_KEYS.eventChoices);
|
||||
if (!raw) return [];
|
||||
const out: EventChoice[] = [];
|
||||
for (const tuple of raw.split(",")) {
|
||||
const m = tuple.match(/^([^:]+):([^=]+)=(.+)$/);
|
||||
if (!m) continue;
|
||||
const kind = m[2] as ChoiceKind;
|
||||
if (kind !== "appellant" && kind !== "include_ccr" && kind !== "skip") continue;
|
||||
out.push({ submission_code: m[1], choice_kind: kind, choice_value: m[3] });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEventChoices(storage: StorageLike, choices: EventChoice[]): void {
|
||||
if (choices.length === 0) {
|
||||
storage.removeItem(SCENARIO_KEYS.eventChoices);
|
||||
return;
|
||||
}
|
||||
const enc = choices
|
||||
.map((c) => `${c.submission_code}:${c.choice_kind}=${c.choice_value}`)
|
||||
.join(",");
|
||||
storage.setItem(SCENARIO_KEYS.eventChoices, enc);
|
||||
}
|
||||
|
||||
// readCourtId / writeCourtId — empty string == no court picked. The
|
||||
// "" value is stored as a removed key, not an empty string entry, so
|
||||
// reading it back yields null rather than "".
|
||||
export function readCourtId(storage: StorageLike): string {
|
||||
return storage.getItem(SCENARIO_KEYS.courtId) ?? "";
|
||||
}
|
||||
|
||||
export function writeCourtId(storage: StorageLike, courtId: string): void {
|
||||
if (courtId === "") {
|
||||
storage.removeItem(SCENARIO_KEYS.courtId);
|
||||
return;
|
||||
}
|
||||
storage.setItem(SCENARIO_KEYS.courtId, courtId);
|
||||
}
|
||||
|
||||
// Boolean flags — "1" / "0" string encoding, removeItem on default
|
||||
// (false for flags, also false for show_hidden) so the storage stays
|
||||
// uncluttered on a fresh page.
|
||||
export function readBoolFlag(storage: StorageLike, key: string): boolean {
|
||||
return storage.getItem(key) === "1";
|
||||
}
|
||||
|
||||
export function writeBoolFlag(storage: StorageLike, key: string, on: boolean): void {
|
||||
if (on) storage.setItem(key, "1");
|
||||
else storage.removeItem(key);
|
||||
}
|
||||
|
||||
// Read all scenario state in one call — convenience for the page's
|
||||
// load-time hydration. Caller decides whether to apply each field
|
||||
// (e.g. court_id is proceeding-specific; the page may discard the
|
||||
// stored value if the active proceeding doesn't expose a court row).
|
||||
export interface ScenarioState {
|
||||
eventChoices: EventChoice[];
|
||||
courtId: string;
|
||||
ccr: boolean;
|
||||
infAmend: boolean;
|
||||
revAmend: boolean;
|
||||
revCci: boolean;
|
||||
showHidden: boolean;
|
||||
}
|
||||
|
||||
export function readScenario(storage: StorageLike): ScenarioState {
|
||||
return {
|
||||
eventChoices: readEventChoices(storage),
|
||||
courtId: readCourtId(storage),
|
||||
ccr: readBoolFlag(storage, SCENARIO_KEYS.ccr),
|
||||
infAmend: readBoolFlag(storage, SCENARIO_KEYS.infAmend),
|
||||
revAmend: readBoolFlag(storage, SCENARIO_KEYS.revAmend),
|
||||
revCci: readBoolFlag(storage, SCENARIO_KEYS.revCci),
|
||||
showHidden: readBoolFlag(storage, SCENARIO_KEYS.showHidden),
|
||||
};
|
||||
}
|
||||
|
||||
// ----- URL → localStorage hydration order -------------------------
|
||||
|
||||
// The page's load-time contract: read URL filters, then read
|
||||
// scenario state from localStorage. URL wins on conflict — but the
|
||||
// only field that can conflict is none of them today (URL owns
|
||||
// proceeding/side/target/trigger_date; localStorage owns the rest).
|
||||
// The order matters for one edge case: if a future field migrates
|
||||
// from URL → localStorage with overlap, the URL value MUST be honored.
|
||||
|
||||
export interface HydratedState extends ScenarioState {
|
||||
proceeding: string;
|
||||
side: Side;
|
||||
target: AppealTarget;
|
||||
triggerDate: string;
|
||||
}
|
||||
|
||||
export function hydrate(search: string, storage: StorageLike): HydratedState {
|
||||
const scenario = readScenario(storage);
|
||||
return {
|
||||
proceeding: parseProceedingFromSearch(search),
|
||||
side: parseSideFromSearch(search),
|
||||
target: parseAppealTargetFromSearch(search),
|
||||
triggerDate: parseTriggerDateFromSearch(search),
|
||||
...scenario,
|
||||
};
|
||||
}
|
||||
|
||||
// makeMemoryStorage — tiny StorageLike for tests / SSR fallback.
|
||||
// Not used by the runtime page (which mounts real localStorage), but
|
||||
// kept here so test files have one well-known import.
|
||||
export function makeMemoryStorage(): StorageLike {
|
||||
const store = new Map<string, string>();
|
||||
return {
|
||||
getItem: (k) => (store.has(k) ? store.get(k)! : null),
|
||||
setItem: (k, v) => { store.set(k, v); },
|
||||
removeItem: (k) => { store.delete(k); },
|
||||
};
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
import { h } from "../jsx";
|
||||
|
||||
interface ProceedingDef {
|
||||
code: string;
|
||||
i18nKey: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
function proceedingBtn(p: ProceedingDef): string {
|
||||
return (
|
||||
<button type="button" className="proceeding-btn" data-code={p.code}>
|
||||
<strong data-i18n={p.i18nKey}>{p.name}</strong>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
// Slice B1 (m/paliad#124 §18.1): the 3 separate Berufung tiles
|
||||
// (upc.apl.merits / upc.apl.cost / upc.apl.order) collapse into ONE
|
||||
// unified "Berufung" tile (upc.apl). After picking it, the user
|
||||
// selects which decision the appeal is directed AT via the
|
||||
// .appeal-target-row chip group below — the engine then filters
|
||||
// rules whose applies_to_target contains the picked slug.
|
||||
const UPC_TYPES: ProceedingDef[] = [
|
||||
{ code: "upc.inf.cfi", i18nKey: "deadlines.upc.inf.cfi", name: "Verletzungsverfahren" },
|
||||
{ code: "upc.rev.cfi", i18nKey: "deadlines.upc.rev.cfi", name: "Nichtigkeitsklage" },
|
||||
{ code: "upc.ccr.cfi", i18nKey: "deadlines.upc.ccr.cfi", name: "Widerklage auf Nichtigkeit" },
|
||||
{ code: "upc.pi.cfi", i18nKey: "deadlines.upc.pi.cfi", name: "Einstw. Maßnahmen" },
|
||||
{ code: "upc.apl.unified", i18nKey: "deadlines.upc.apl.unified", name: "Berufung" },
|
||||
{ code: "upc.dmgs.cfi", i18nKey: "deadlines.upc.dmgs.cfi", name: "Schadensbemessung" },
|
||||
{ code: "upc.disc.cfi", i18nKey: "deadlines.upc.disc.cfi", name: "Bucheinsicht" },
|
||||
];
|
||||
|
||||
const DE_INF_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.inf.lg", i18nKey: "deadlines.de.inf.lg", name: "LG (1. Instanz)" },
|
||||
{ code: "de.inf.olg", i18nKey: "deadlines.de.inf.olg", name: "OLG (Berufung)" },
|
||||
{ code: "de.inf.bgh", i18nKey: "deadlines.de.inf.bgh", name: "BGH (Revision / NZB)" },
|
||||
];
|
||||
|
||||
const DE_NULL_TYPES: ProceedingDef[] = [
|
||||
{ code: "de.null.bpatg", i18nKey: "deadlines.de.null.bpatg", name: "BPatG (1. Instanz)" },
|
||||
{ code: "de.null.bgh", i18nKey: "deadlines.de.null.bgh", name: "BGH (Berufung)" },
|
||||
];
|
||||
|
||||
const EPA_TYPES: ProceedingDef[] = [
|
||||
{ code: "epa.opp.opd", i18nKey: "deadlines.epa.opp.opd", name: "Einspruchsverfahren" },
|
||||
{ code: "epa.opp.boa", i18nKey: "deadlines.epa.opp.boa", name: "Beschwerdeverfahren" },
|
||||
{ code: "epa.grant.exa", i18nKey: "deadlines.epa.grant.exa", name: "EP-Erteilungsverfahren" },
|
||||
];
|
||||
|
||||
const DPMA_TYPES: ProceedingDef[] = [
|
||||
{ code: "dpma.opp.dpma", i18nKey: "deadlines.dpma.opp.dpma", name: "Einspruch DPMA" },
|
||||
{ code: "dpma.appeal.bpatg", i18nKey: "deadlines.dpma.appeal.bpatg", name: "Beschwerde BPatG (DPMA)" },
|
||||
{ code: "dpma.appeal.bgh", i18nKey: "deadlines.dpma.appeal.bgh", name: "Rechtsbeschwerde BGH" },
|
||||
];
|
||||
|
||||
// Shared Verfahrensablauf wizard body. Renders the proceeding picker,
|
||||
// perspective + date inputs, scenario flag rows, detail-mode toggle,
|
||||
// view toggle, and the timeline-container that client/verfahrensablauf.ts
|
||||
// (via initVerfahrensablauf()) wires against. Used by both
|
||||
// /tools/verfahrensablauf (legacy) and /tools/procedures (unified).
|
||||
export function VerfahrensablaufBody({ todayIso }: { todayIso: string }): string {
|
||||
return (
|
||||
<div className="fristen-wizard" id="verfahrensablauf-wizard" data-mode="procedure">
|
||||
<div className="wizard-step" id="step-1">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">1</span>
|
||||
<span data-i18n="deadlines.step1">Verfahrensart wählen</span>
|
||||
</h3>
|
||||
|
||||
<div className="proceeding-group" data-forum="upc">
|
||||
<h4 data-i18n="deadlines.upc">UPC</h4>
|
||||
<div className="proceeding-btns">
|
||||
{UPC_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="de">
|
||||
<h4 data-i18n="deadlines.de">Deutsche Gerichte</h4>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.inf">Verletzungsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_INF_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="proceeding-subgroup">
|
||||
<h5 className="proceeding-subgroup-heading" data-i18n="deadlines.de.group.null">Nichtigkeitsverfahren</h5>
|
||||
<div className="proceeding-btns">
|
||||
{DE_NULL_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="epa">
|
||||
<h4 data-i18n="deadlines.epa">EPA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{EPA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-group" data-forum="dpma">
|
||||
<h4 data-i18n="deadlines.dpma">DPMA</h4>
|
||||
<div className="proceeding-btns">
|
||||
{DPMA_TYPES.map((p) => proceedingBtn(p))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="proceeding-summary" id="proceeding-summary" style="display:none" role="group">
|
||||
<span className="proceeding-summary-label" data-i18n="deadlines.proceeding.selected">Verfahren:</span>
|
||||
<strong className="proceeding-summary-name" id="proceeding-summary-name">—</strong>
|
||||
<button type="button" className="proceeding-summary-reselect" id="proceeding-summary-reselect"
|
||||
data-i18n="deadlines.proceeding.reselect">
|
||||
Anderes Verfahren wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-2" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">2</span>
|
||||
<span data-i18n="deadlines.step2.perspective">Perspektive und Datum</span>
|
||||
</h3>
|
||||
|
||||
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
|
||||
<div className="verfahrensablauf-perspective-row" id="side-row">
|
||||
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
|
||||
<div className="side-radio-cluster" id="side-radio-cluster">
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="claimant" />
|
||||
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="defendant" />
|
||||
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="side" value="" checked />
|
||||
<span data-i18n="deadlines.side.undefined">Nicht festgelegt</span>
|
||||
</label>
|
||||
</div>
|
||||
<span className="side-hint" id="side-hint"
|
||||
data-i18n="deadlines.side.hint">
|
||||
Wählen Sie eine Seite, um die Spalten zu fokussieren.
|
||||
</span>
|
||||
</div>
|
||||
<div className="side-chip" id="side-chip" style="display:none">
|
||||
<span className="side-chip-tag" data-i18n="deadlines.side.from_project">Aus Akte:</span>
|
||||
<strong className="side-chip-value" id="side-chip-value">—</strong>
|
||||
<button type="button" className="side-chip-override" id="side-chip-override"
|
||||
data-i18n="deadlines.side.override">
|
||||
Andere Seite wählen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="appeal-target-row" style="display:none">
|
||||
<span className="date-label" data-i18n="deadlines.appeal_target.label">Worauf richtet sich die Berufung?</span>
|
||||
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appeal target">
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="endentscheidung" checked />
|
||||
<span data-i18n="deadlines.appeal_target.endentscheidung">Endentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="kostenentscheidung" />
|
||||
<span data-i18n="deadlines.appeal_target.kostenentscheidung">Kostenentscheidung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="anordnung" />
|
||||
<span data-i18n="deadlines.appeal_target.anordnung">Anordnung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="schadensbemessung" />
|
||||
<span data-i18n="deadlines.appeal_target.schadensbemessung">Schadensbemessung</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="appeal-target" value="bucheinsicht" />
|
||||
<span data-i18n="deadlines.appeal_target.bucheinsicht">Bucheinsicht</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="verfahrensablauf-perspective-row" id="show-hidden-row" style="display:none">
|
||||
<label className="fristen-view-option">
|
||||
<input type="checkbox" id="show-hidden-toggle" />
|
||||
<span data-i18n="choices.show_hidden.label">Ausgeblendete anzeigen</span>
|
||||
</label>
|
||||
<span className="show-hidden-count" id="show-hidden-count" aria-live="polite"> </span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="verfahrensablauf-step2-divider" aria-hidden="true"></div>
|
||||
|
||||
<div className="date-input-group">
|
||||
<div className="date-field-row">
|
||||
<span className="date-label" data-i18n="deadlines.trigger.event">Auslösendes Ereignis:</span>
|
||||
<span id="trigger-event" className="trigger-event-name">—</span>
|
||||
</div>
|
||||
<div className="date-field-row">
|
||||
<label htmlFor="trigger-date" className="date-label" data-i18n="deadlines.trigger.date">Datum:</label>
|
||||
<input type="date" id="trigger-date" className="date-input" value={todayIso} />
|
||||
</div>
|
||||
<div className="date-field-row" id="court-picker-row" style="display:none">
|
||||
<label htmlFor="court-picker" className="date-label" data-i18n="deadlines.court.label">Gericht:</label>
|
||||
<select id="court-picker" className="date-input"></select>
|
||||
</div>
|
||||
<div className="date-field-row" id="ccr-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="ccr-flag" />
|
||||
<span data-i18n="deadlines.flag.ccr">Mit Widerklage auf Nichtigkeit</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row date-field-row--nested" id="inf-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="inf-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.inf_amend">Mit Antrag auf Patentänderung (R.30)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-amend-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-amend-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_amend">Mit Antrag auf Patentänderung (R.49.2.a)</span>
|
||||
</label>
|
||||
</div>
|
||||
<div className="date-field-row" id="rev-cci-flag-row" style="display:none">
|
||||
<label className="date-label">
|
||||
<input type="checkbox" id="rev-cci-flag" />
|
||||
<span data-i18n="deadlines.flag.rev_cci">Mit Verletzungswiderklage (R.49.2.b)</span>
|
||||
</label>
|
||||
</div>
|
||||
<button type="button" id="calculate-btn" className="calculate-btn" data-i18n="deadlines.calculate">
|
||||
Fristen berechnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="wizard-step" id="step-3" style="display:none">
|
||||
<h3 className="wizard-step-label">
|
||||
<span className="step-number">3</span>
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
<div className="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
|
||||
role="radiogroup" aria-label="Detail">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="mandatory_only" />
|
||||
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="selected" checked />
|
||||
<span data-i18n="deadlines.detail.selected">Gewählt</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="all_options" />
|
||||
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="columns" checked />
|
||||
<span data-i18n="deadlines.view.columns">Spalten</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="fristen-view" value="timeline" />
|
||||
<span data-i18n="deadlines.view.timeline">Zeitstrahl</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="fristen-notes-show" />
|
||||
<span data-i18n="deadlines.notes.show">Hinweise anzeigen</span>
|
||||
</label>
|
||||
<label className="fristen-notes-option">
|
||||
<input type="checkbox" id="verfahrensablauf-durations-show" />
|
||||
<span data-i18n="deadlines.durations.show">Dauern anzeigen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="timeline-container">
|
||||
</div>
|
||||
|
||||
<div className="fristen-result-actions">
|
||||
<button type="button" id="fristen-print-btn" className="print-btn" style="display:none">
|
||||
<svg className="print-btn-icon" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||
<polyline points="6 9 6 2 18 2 18 9"></polyline>
|
||||
<path d="M6 18H4a2 2 0 0 1-2-2v-5a2 2 0 0 1 2-2h16a2 2 0 0 1 2 2v5a2 2 0 0 1-2 2h-2"></path>
|
||||
<rect x="6" y="14" width="12" height="8"></rect>
|
||||
</svg>
|
||||
<span data-i18n="deadlines.print">Drucken</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -735,6 +735,10 @@ export type I18nKey =
|
||||
| "builder.akte.banner.prefix"
|
||||
| "builder.akte.none"
|
||||
| "builder.bucket.active"
|
||||
| "builder.bucket.archived"
|
||||
| "builder.bucket.empty"
|
||||
| "builder.bucket.promoted"
|
||||
| "builder.bucket.shared"
|
||||
| "builder.canvas.add_proceeding"
|
||||
| "builder.empty.cta"
|
||||
| "builder.empty.headline"
|
||||
@@ -754,6 +758,7 @@ export type I18nKey =
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mobile.blocked"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
@@ -768,6 +773,45 @@ export type I18nKey =
|
||||
| "builder.picker.future_jurisdiction"
|
||||
| "builder.picker.placeholder"
|
||||
| "builder.picker.title"
|
||||
| "builder.promote.back"
|
||||
| "builder.promote.cancel"
|
||||
| "builder.promote.commit"
|
||||
| "builder.promote.error.generic"
|
||||
| "builder.promote.error.title_required"
|
||||
| "builder.promote.meta.case_number"
|
||||
| "builder.promote.meta.client_number"
|
||||
| "builder.promote.meta.our_side"
|
||||
| "builder.promote.meta.our_side.claimant"
|
||||
| "builder.promote.meta.our_side.defendant"
|
||||
| "builder.promote.meta.our_side.none"
|
||||
| "builder.promote.meta.parent"
|
||||
| "builder.promote.meta.parent.none"
|
||||
| "builder.promote.meta.reference"
|
||||
| "builder.promote.meta.team"
|
||||
| "builder.promote.meta.team.hint"
|
||||
| "builder.promote.meta.title"
|
||||
| "builder.promote.meta.title.placeholder"
|
||||
| "builder.promote.next"
|
||||
| "builder.promote.parties.add"
|
||||
| "builder.promote.parties.empty"
|
||||
| "builder.promote.parties.hint"
|
||||
| "builder.promote.parties.name"
|
||||
| "builder.promote.parties.remove"
|
||||
| "builder.promote.parties.representative"
|
||||
| "builder.promote.parties.role"
|
||||
| "builder.promote.step1"
|
||||
| "builder.promote.step2"
|
||||
| "builder.promote.step3"
|
||||
| "builder.promote.success"
|
||||
| "builder.promote.summary.events_filed"
|
||||
| "builder.promote.summary.events_planned"
|
||||
| "builder.promote.summary.flags"
|
||||
| "builder.promote.summary.heading"
|
||||
| "builder.promote.summary.note_extra"
|
||||
| "builder.promote.summary.proceeding"
|
||||
| "builder.promote.title"
|
||||
| "builder.readonly.blocked"
|
||||
| "builder.readonly.watermark"
|
||||
| "builder.save.error"
|
||||
| "builder.save.idle"
|
||||
| "builder.save.saved"
|
||||
@@ -789,6 +833,16 @@ export type I18nKey =
|
||||
| "builder.search.summary.projects.other"
|
||||
| "builder.search.summary.scenarios.one"
|
||||
| "builder.search.summary.scenarios.other"
|
||||
| "builder.share.button"
|
||||
| "builder.share.close"
|
||||
| "builder.share.current.empty"
|
||||
| "builder.share.current.title"
|
||||
| "builder.share.error"
|
||||
| "builder.share.no_results"
|
||||
| "builder.share.revoke"
|
||||
| "builder.share.search.placeholder"
|
||||
| "builder.share.subtitle"
|
||||
| "builder.share.title"
|
||||
| "builder.subtitle"
|
||||
| "builder.triplet.collapse"
|
||||
| "builder.triplet.detailgrad.all_options"
|
||||
@@ -2788,6 +2842,9 @@ export type I18nKey =
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.import.button"
|
||||
| "submissions.draft.keyword.hint"
|
||||
| "submissions.draft.keyword.label"
|
||||
| "submissions.draft.keyword.placeholder"
|
||||
| "submissions.draft.language"
|
||||
| "submissions.draft.language.de"
|
||||
| "submissions.draft.language.en"
|
||||
@@ -2887,6 +2944,18 @@ export type I18nKey =
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "templates.authoring.heading"
|
||||
| "templates.authoring.intro"
|
||||
| "templates.authoring.list.title"
|
||||
| "templates.authoring.slots.title"
|
||||
| "templates.authoring.title"
|
||||
| "templates.authoring.upload.file"
|
||||
| "templates.authoring.upload.firm"
|
||||
| "templates.authoring.upload.name_de"
|
||||
| "templates.authoring.upload.name_en"
|
||||
| "templates.authoring.upload.submit"
|
||||
| "templates.authoring.upload.title"
|
||||
| "templates.authoring.workspace.hint"
|
||||
| "theme.toggle.auto"
|
||||
| "theme.toggle.cycle.auto"
|
||||
| "theme.toggle.cycle.dark"
|
||||
|
||||
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// docforge-editor — the variable catalogue client.
|
||||
//
|
||||
// The catalogue (key + bilingual label + namespace group) is served by the
|
||||
// Go backend at GET /api/docforge/variables, built from the resolvers'
|
||||
// Keys() as the single source of truth. A consumer fetches it once and uses
|
||||
// labelMap() to label its sidebar form + authoring palette, instead of
|
||||
// hard-coding a parallel label table that can drift from the resolvers.
|
||||
|
||||
export interface VariableEntry {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface VariablesResponse {
|
||||
variables: VariableEntry[];
|
||||
}
|
||||
|
||||
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
|
||||
// non-2xx response so the caller can decide how to degrade.
|
||||
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
|
||||
const res = await fetch("/api/docforge/variables", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`docforge variables: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as VariablesResponse;
|
||||
return body.variables ?? [];
|
||||
}
|
||||
|
||||
// labelMap turns a catalogue into a key → {de, en} lookup for a label
|
||||
// function. Keys absent from the map fall back to the raw key at the call
|
||||
// site, so a failed fetch degrades to dotted-key labels rather than a
|
||||
// broken form.
|
||||
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
|
||||
const out: Record<string, { de: string; en: string }> = {};
|
||||
for (const e of catalogue) {
|
||||
out[e.key] = { de: e.label_de, en: e.label_en };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { escapeHtml, cssEscape } from "./dom";
|
||||
|
||||
test("escapeHtml escapes the five HTML-significant characters", () => {
|
||||
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
|
||||
"<a href="x" title='y'>& z</a>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapeHtml is a no-op on plain text", () => {
|
||||
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
|
||||
});
|
||||
|
||||
test("escapeHtml escapes & first to avoid double-encoding", () => {
|
||||
expect(escapeHtml("<")).toBe("&lt;");
|
||||
});
|
||||
|
||||
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
|
||||
// Both CSS.escape and the regex fallback escape '.' the same way, so the
|
||||
// result is stable across environments (bun has no CSS global → fallback).
|
||||
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
|
||||
});
|
||||
|
||||
test("cssEscape leaves identifier-safe characters untouched", () => {
|
||||
expect(cssEscape("today")).toBe("today");
|
||||
});
|
||||
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// docforge-editor — shared, framework-agnostic editor utilities.
|
||||
//
|
||||
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
|
||||
// extracting the generic editor plumbing out of the submission-specific
|
||||
// client bundle so a second consumer (and the slice-6 authoring page) can
|
||||
// reuse it. This module holds the pure DOM-string helpers — no DOM
|
||||
// mutation, no editor state — so they unit-test cleanly under bun.
|
||||
|
||||
// escapeHtml escapes the five HTML-significant characters for safe
|
||||
// insertion into element text or an attribute value. Matches the
|
||||
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
|
||||
// round-trips identically.
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape escapes a string for use inside a CSS attribute selector
|
||||
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
|
||||
// and falls back to escaping CSS-special characters for older runtimes.
|
||||
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
|
||||
// quotes, so the fallback is straightforward.
|
||||
export function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
@@ -68,12 +68,10 @@ export function renderProcedures(): string {
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
title="In B5 verfügbar"
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
<div className="builder-pageheader-row">
|
||||
@@ -141,10 +139,28 @@ export function renderProcedures(): string {
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.active">Aktiv</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-active" aria-label="Aktive Szenarien"></ul>
|
||||
</div>
|
||||
{/* Geteilt / Promoted / Archiviert buckets land in B5+. */}
|
||||
{/* B5 — Geteilt mit mir / Als Projekt angelegt / Archiviert.
|
||||
Each bucket hides itself when empty (builder.ts toggles
|
||||
the hidden attribute). */}
|
||||
<div className="builder-sidepanel-bucket" data-bucket="shared" id="builder-bucket-shared" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.shared">Geteilt mit mir</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-shared" aria-label="Mit mir geteilte Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="promoted" id="builder-bucket-promoted" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.promoted">Als Projekt angelegt</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-promoted" aria-label="Promotete Szenarien"></ul>
|
||||
</div>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="archived" id="builder-bucket-archived" hidden>
|
||||
<h3 className="builder-bucket-label" data-i18n="builder.bucket.archived">Archiviert</h3>
|
||||
<ul className="builder-scenario-list" id="builder-scenario-list-archived" aria-label="Archivierte Szenarien"></ul>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<section className="builder-canvas-wrap" aria-label="Builder-Canvas">
|
||||
{/* B5 — read-only watermark for shared / promoted scenarios.
|
||||
builder.ts fills + unhides it when the active scenario
|
||||
is not editable by the current user. */}
|
||||
<div id="builder-readonly-watermark" className="builder-readonly-watermark" hidden></div>
|
||||
<div id="builder-canvas" className="builder-canvas">
|
||||
{/* Cold-open placeholder — replaced by triplet stack once a
|
||||
scenario is loaded. */}
|
||||
|
||||
@@ -20648,3 +20648,414 @@ a.fristen-overhaul-rule-source {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* ===================================================================
|
||||
B5 — side-panel buckets, read-only watermark, share modal,
|
||||
promote-to-project wizard (m/paliad#153 PRD §2.4 + §2.5).
|
||||
=================================================================== */
|
||||
|
||||
.builder-sidepanel-bucket + .builder-sidepanel-bucket {
|
||||
margin-top: 0.85rem;
|
||||
padding-top: 0.65rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.builder-bucket-label {
|
||||
margin: 0 0 0.35rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Read-only watermark banner above the canvas. */
|
||||
.builder-readonly-watermark {
|
||||
display: block;
|
||||
margin-bottom: 0.6rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 0.4rem;
|
||||
background: repeating-linear-gradient(
|
||||
45deg,
|
||||
var(--color-surface-muted),
|
||||
var(--color-surface-muted) 10px,
|
||||
var(--color-surface-2) 10px,
|
||||
var(--color-surface-2) 20px
|
||||
);
|
||||
border: 1px dashed var(--color-border);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Read-only mode: neutralise every mutating affordance in the canvas
|
||||
while keeping text selectable + read interactions working. PRD §2.5 /
|
||||
§10 — pointer-events:none on the controls, not the cards. */
|
||||
body.builder-readonly .builder-triplet-host button,
|
||||
body.builder-readonly .builder-triplet-host input,
|
||||
body.builder-readonly .builder-triplet-host select,
|
||||
body.builder-readonly .builder-add-proceeding-btn {
|
||||
pointer-events: none;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Generic modal scaffold (shared by share modal + promote wizard). */
|
||||
.builder-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1rem;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.builder-modal {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.6rem;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.25);
|
||||
width: 100%;
|
||||
max-width: 540px;
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow: auto;
|
||||
padding: 1.1rem 1.25rem 1.25rem;
|
||||
}
|
||||
.builder-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
.builder-modal-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.builder-modal-close {
|
||||
font: inherit;
|
||||
font-size: 1.4rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
padding: 0 0.3rem;
|
||||
}
|
||||
.builder-modal-subtitle {
|
||||
margin: 0 0 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* Share modal. */
|
||||
.builder-share-pickerbox {
|
||||
position: relative;
|
||||
}
|
||||
.builder-share-search {
|
||||
width: 100%;
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.6rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.35rem;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-share-results {
|
||||
list-style: none;
|
||||
margin: 0.4rem 0 0;
|
||||
padding: 0;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
}
|
||||
.builder-share-result,
|
||||
.builder-share-current-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.35rem 0.4rem;
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
.builder-share-result:hover {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
.builder-share-result-empty,
|
||||
.builder-share-current-empty {
|
||||
padding: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--color-text-muted);
|
||||
list-style: none;
|
||||
}
|
||||
.builder-share-add,
|
||||
.builder-share-revoke {
|
||||
font: inherit;
|
||||
font-size: 0.8rem;
|
||||
padding: 0.2rem 0.6rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
}
|
||||
.builder-share-add {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
.builder-share-current {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.builder-share-current-title {
|
||||
margin: 0 0 0.4rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-share-current-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.builder-share-error {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
/* Promote wizard. */
|
||||
.builder-promote-modal {
|
||||
max-width: 600px;
|
||||
}
|
||||
.builder-promote-steps {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.builder-promote-step {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--color-text-muted);
|
||||
flex: 1;
|
||||
}
|
||||
.builder-promote-step-n {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.4rem;
|
||||
height: 1.4rem;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--color-border);
|
||||
font-size: 0.78rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.builder-promote-step.is-active {
|
||||
color: var(--color-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
.builder-promote-step.is-active .builder-promote-step-n {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
}
|
||||
.builder-promote-step.is-done .builder-promote-step-n {
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
.builder-promote-body {
|
||||
min-height: 160px;
|
||||
}
|
||||
.builder-promote-section-title {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.builder-promote-summary {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.builder-promote-summary li {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.3rem 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.builder-promote-summary li span {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-promote-note {
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0.5rem 0.65rem;
|
||||
border-radius: 0.35rem;
|
||||
background: var(--color-surface-muted);
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-promote-hint,
|
||||
.builder-promote-empty,
|
||||
.builder-promote-team-hint {
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
.builder-promote-parties {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.builder-promote-party {
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr 1fr auto;
|
||||
gap: 0.35rem;
|
||||
align-items: center;
|
||||
}
|
||||
.builder-promote-party input,
|
||||
.builder-promote-field input,
|
||||
.builder-promote-field select {
|
||||
font: inherit;
|
||||
padding: 0.35rem 0.5rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.3rem;
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
}
|
||||
.builder-promote-party-remove {
|
||||
font: inherit;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.builder-promote-party-add {
|
||||
font: inherit;
|
||||
font-size: 0.85rem;
|
||||
padding: 0.3rem 0.7rem;
|
||||
border-radius: 0.3rem;
|
||||
cursor: pointer;
|
||||
border: 1px dashed var(--color-border);
|
||||
background: transparent;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-promote-field {
|
||||
display: block;
|
||||
margin-bottom: 0.7rem;
|
||||
}
|
||||
.builder-promote-field > span {
|
||||
display: block;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
.builder-promote-field-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.builder-promote-team {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
max-height: 160px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 0.35rem;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
.builder-promote-team-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.builder-promote-error {
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.85rem;
|
||||
color: #c0392b;
|
||||
}
|
||||
.builder-promote-success {
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
font-size: 1rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-promote-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.8rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
.builder-promote-footer-spacer {
|
||||
flex: 1;
|
||||
}
|
||||
.builder-promote-cancel,
|
||||
.builder-promote-backbtn,
|
||||
.builder-promote-nextbtn {
|
||||
font: inherit;
|
||||
padding: 0.4rem 0.95rem;
|
||||
border-radius: 0.35rem;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface-2);
|
||||
color: var(--color-text);
|
||||
}
|
||||
.builder-promote-nextbtn {
|
||||
background: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
color: var(--color-accent-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
.builder-promote-nextbtn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.builder-promote-party,
|
||||
.builder-promote-field-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
/* B6 — modals go full-bleed on phones so the wizard/share UI is at
|
||||
least legible if reached; entry is gated by the mobile guard, but
|
||||
keep it readable for the read-only case. */
|
||||
.builder-modal {
|
||||
max-width: 100%;
|
||||
border-radius: 0.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* B6 — mobile basic-read guard toast (PRD §7.1 + §10). Shown when a
|
||||
mutating affordance is tapped on a narrow viewport. */
|
||||
.builder-mobile-toast {
|
||||
position: fixed;
|
||||
left: 50%;
|
||||
bottom: 1.25rem;
|
||||
transform: translateX(-50%) translateY(1rem);
|
||||
z-index: 1100;
|
||||
max-width: calc(100vw - 2rem);
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0.5rem;
|
||||
background: var(--color-accent-dark, #0b1f33);
|
||||
color: #fff;
|
||||
font-size: 0.88rem;
|
||||
box-shadow: 0 6px 24px rgba(0, 0, 0, 0.3);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
.builder-mobile-toast.is-visible {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(0);
|
||||
}
|
||||
|
||||
@@ -171,6 +171,33 @@ export function renderSubmissionDraft(): string {
|
||||
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
|
||||
</p>
|
||||
|
||||
{/* t-paliad-354 — keyword that leads the exported
|
||||
document name "<date> <keyword> (<case>)". Empty
|
||||
falls back to the auto-derived rule name; the
|
||||
placeholder shows that default. Persisted to
|
||||
composer_meta.filename_keyword via the draft-save
|
||||
path on change. */}
|
||||
<div className="submission-draft-keyword-row">
|
||||
<label
|
||||
htmlFor="submission-draft-keyword"
|
||||
data-i18n="submissions.draft.keyword.label">
|
||||
Stichwort (Dateiname)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-keyword"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.keyword.placeholder"
|
||||
placeholder="Automatisch aus dem Schriftsatztyp"
|
||||
/>
|
||||
<p
|
||||
className="submission-draft-keyword-hint"
|
||||
id="submission-draft-keyword-hint"
|
||||
data-i18n="submissions.draft.keyword.hint">
|
||||
Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
{/* t-paliad-277: "Aus Projekt importieren" + last-
|
||||
|
||||
112
frontend/src/templates-authoring.tsx
Normal file
112
frontend/src/templates-authoring.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring page at
|
||||
// /admin/templates.
|
||||
//
|
||||
// Admin uploads a base .docx, sees it rendered as run-addressable text,
|
||||
// selects a span + a variable from the palette to drop a {{slot}}, and the
|
||||
// result saves as a reusable docforge template. Pure shell:
|
||||
// client/templates-authoring.ts hydrates the list, upload form, preview,
|
||||
// palette, and slot list after load. The palette labels come from the Go
|
||||
// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5).
|
||||
//
|
||||
// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1.
|
||||
|
||||
export function renderTemplatesAuthoring(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="templates.authoring.title">Vorlagen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-templates-authoring">
|
||||
<Sidebar currentPath="/admin" />
|
||||
<BottomNav currentPath="/admin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page docforge-templates-page">
|
||||
<div className="container">
|
||||
<header className="docforge-templates-header">
|
||||
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
|
||||
<p
|
||||
className="docforge-templates-intro"
|
||||
data-i18n="templates.authoring.intro">
|
||||
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Upload a new base .docx */}
|
||||
<section className="docforge-upload" id="docforge-upload">
|
||||
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
|
||||
<form id="docforge-upload-form" className="entity-form">
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
|
||||
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
|
||||
<input type="text" name="name_de" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
|
||||
<input type="text" name="name_en" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
|
||||
<input type="text" name="firm" className="entity-form-input" />
|
||||
</label>
|
||||
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
|
||||
Hochladen
|
||||
</button>
|
||||
<span className="docforge-upload-status" id="docforge-upload-status" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Existing templates */}
|
||||
<section className="docforge-template-list-wrap">
|
||||
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
|
||||
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
|
||||
</section>
|
||||
|
||||
{/* Authoring workspace — hidden until a template is opened. */}
|
||||
<section className="docforge-workspace" id="docforge-workspace" hidden>
|
||||
<header className="docforge-workspace-header">
|
||||
<h2 id="docforge-workspace-title" />
|
||||
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
|
||||
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
|
||||
</span>
|
||||
<span className="docforge-workspace-status" id="docforge-workspace-status" />
|
||||
</header>
|
||||
<div className="docforge-workspace-grid">
|
||||
{/* Variable palette (left) — populated from the catalogue. */}
|
||||
<aside className="docforge-palette" id="docforge-palette" />
|
||||
{/* Run-addressable preview (center) — selection target. */}
|
||||
<div className="docforge-preview" id="docforge-preview" />
|
||||
{/* Placed slots (right). */}
|
||||
<aside className="docforge-slots">
|
||||
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
|
||||
<ul className="docforge-slot-list" id="docforge-slot-list" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/templates-authoring.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-349: revert the template-version pin on submission drafts.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.submission_drafts_template_version_idx;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS template_version_id;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- t-paliad-349 (m/paliad#157): docforge slice 7 — pin an uploaded template
|
||||
-- version onto a submission draft (generation-on-uploaded-templates).
|
||||
--
|
||||
-- A draft can now source its document from a docforge uploaded template
|
||||
-- (paliad.template_versions) instead of a legacy Gitea base. template_version_id
|
||||
-- is the snapshot pin (PRD §4 A3): the draft renders the exact carrier of the
|
||||
-- version it was bound to, so a later template edit (which creates a new
|
||||
-- version) doesn't shift an in-flight draft.
|
||||
--
|
||||
-- Nullable + additive: existing drafts keep template_version_id NULL and
|
||||
-- render via their existing path (Composer base_id, or the v1 fallback).
|
||||
-- The three sources are mutually exclusive in practice; the export path
|
||||
-- checks template_version_id first, then base_id, then v1.
|
||||
--
|
||||
-- ON DELETE SET NULL: if the pinned version is removed, the draft detaches
|
||||
-- and falls back rather than failing — same posture as base_id's
|
||||
-- ON DELETE SET NULL.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS template_version_id uuid
|
||||
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_template_version_idx
|
||||
ON paliad.submission_drafts (template_version_id)
|
||||
WHERE template_version_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.template_version_id IS
|
||||
't-paliad-349: pinned docforge template version (snapshot-at-create). NULL = render via base_id Composer path or v1 fallback.';
|
||||
48
internal/handlers/docforge_variables.go
Normal file
48
internal/handlers/docforge_variables.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handlers
|
||||
|
||||
// docforge variable catalogue handler (t-paliad-349 slice 5).
|
||||
//
|
||||
// Endpoint: GET /api/docforge/variables → the full variable catalogue
|
||||
// (key + bilingual label + namespace group) the sidebar form and the
|
||||
// authoring palette render. The catalogue is the Go-side single source of
|
||||
// truth, built from the submission resolvers' Keys(); it replaces the
|
||||
// duplicated TS VARIABLE_LABELS table so labels can't drift between the
|
||||
// resolver that produces a value and the form that labels it.
|
||||
//
|
||||
// Static — no DB call, no per-user state. Auth-gated only (anonymous 401);
|
||||
// the catalogue is the same for every authenticated user.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
type docforgeVariablesResponse struct {
|
||||
Variables []variableEntry `json:"variables"`
|
||||
}
|
||||
|
||||
type variableEntry struct {
|
||||
Key string `json:"key"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// handleDocforgeVariables backs GET /api/docforge/variables.
|
||||
func handleDocforgeVariables(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
cat := services.SubmissionVariableCatalogue()
|
||||
out := make([]variableEntry, 0, len(cat))
|
||||
for _, e := range cat {
|
||||
out = append(out, variableEntry{
|
||||
Key: e.Key,
|
||||
LabelDE: e.LabelDE,
|
||||
LabelEN: e.LabelEN,
|
||||
Group: e.Group,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docforgeVariablesResponse{Variables: out})
|
||||
}
|
||||
@@ -128,6 +128,10 @@ type Services struct {
|
||||
// editor. Per Q2: paste sources only, no lineage on sections.
|
||||
SubmissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store backing
|
||||
// the authoring surface.
|
||||
TemplateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
@@ -215,6 +219,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
submissionSection: svc.SubmissionSection,
|
||||
submissionComposer: svc.SubmissionComposer,
|
||||
submissionBuildingBlock: svc.SubmissionBuildingBlock,
|
||||
templateStore: svc.TemplateStore,
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
scenarioFlags: svc.ScenarioFlags,
|
||||
@@ -455,6 +460,12 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
|
||||
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
|
||||
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
|
||||
// t-paliad-349 slice 7 — firm-shared template picker list for
|
||||
// generation (any authenticated lawyer; admin authoring stays gated).
|
||||
protected.HandleFunc("GET /api/templates", handlePickerTemplates)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
|
||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||
// owner-scoped via SubmissionDraftService.Get.
|
||||
@@ -531,6 +542,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// scenario from a paliad.projects row; subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/from-project", handleBuilderScenarioFromProject)
|
||||
// m/paliad#153 B5 — "Geteilt mit mir" bucket. Literal segment wins
|
||||
// over {id} in Go 1.22+ ServeMux precedence, so this never shadows GET .../{id}.
|
||||
protected.HandleFunc("GET /api/builder/scenarios/shared", handleBuilderScenariosShared)
|
||||
protected.HandleFunc("GET /api/builder/scenarios/{id}", handleBuilderScenarioGet)
|
||||
protected.HandleFunc("PATCH /api/builder/scenarios/{id}", handleBuilderScenarioPatch)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/proceedings", handleBuilderProceedingCreate)
|
||||
@@ -541,6 +555,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/events/{eid}", handleBuilderEventDelete)
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/shares", handleBuilderShareCreate)
|
||||
protected.HandleFunc("DELETE /api/builder/scenarios/{id}/shares/{sid}", handleBuilderShareDelete)
|
||||
// m/paliad#153 B5 — transactional promote-to-project wizard commit.
|
||||
protected.HandleFunc("POST /api/builder/scenarios/{id}/promote", handleBuilderScenarioPromote)
|
||||
// m/paliad#153 B2 — read-only passthrough so the builder can render
|
||||
// per-triplet flag toggles without a per-project round-trip.
|
||||
protected.HandleFunc("GET /api/builder/scenario-flag-catalog", handleBuilderFlagCatalog)
|
||||
@@ -747,6 +763,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring surface
|
||||
// (upload base .docx → place variable slots → save). Admin-only,
|
||||
// firm-shared catalog like submission_bases.
|
||||
protected.HandleFunc("GET /admin/templates", adminGate(users, gateOnboarded(handleTemplatesAuthoringPage)))
|
||||
protected.HandleFunc("GET /api/admin/templates", adminGate(users, handleListTemplates))
|
||||
protected.HandleFunc("POST /api/admin/templates", adminGate(users, handleUploadTemplate))
|
||||
protected.HandleFunc("GET /api/admin/templates/{id}", adminGate(users, handleGetTemplateAuthoring))
|
||||
protected.HandleFunc("POST /api/admin/templates/{id}/slots", adminGate(users, handlePlaceTemplateSlot))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
|
||||
@@ -77,6 +77,9 @@ type dbServices struct {
|
||||
submissionComposer *services.SubmissionComposer
|
||||
submissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store.
|
||||
templateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
|
||||
|
||||
@@ -433,6 +433,62 @@ func handleBuilderShareDelete(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared-with-me + Promote (B5, m/paliad#153)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenariosShared — GET /api/builder/scenarios/shared
|
||||
//
|
||||
// Lists scenarios shared read-only with the caller (the "Geteilt mit mir"
|
||||
// side-panel bucket, PRD §2.5). The caller's own scenarios are excluded.
|
||||
func handleBuilderScenariosShared(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.ListSharedWithMe(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// handleBuilderScenarioPromote — POST /api/builder/scenarios/{id}/promote
|
||||
//
|
||||
// Body: PromoteScenarioInput (wizard steps 2 + 3). Promotes the scenario
|
||||
// into a real paliad.projects 'case' row transactionally (PRD §10 — no
|
||||
// partial promotions) and returns PromoteResult with the new project id
|
||||
// the wizard navigates to (/projects/{project_id}).
|
||||
func handleBuilderScenarioPromote(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sid, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Szenario-ID"})
|
||||
return
|
||||
}
|
||||
var input services.PromoteScenarioInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.PromoteScenario(r.Context(), uid, sid, input)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario flag catalog passthrough (m/paliad#153 B2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,6 +44,7 @@ import (
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// submissionDraftPreviewTimeout caps a single preview round-trip.
|
||||
@@ -115,10 +116,14 @@ type submissionDraftJSON struct {
|
||||
// pre-Composer drafts; the editor sidebar surfaces this in the
|
||||
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
||||
// {"base_id": null} to set or clear.
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
// TemplateVersionID — pinned uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). NULL = base_id/v1 path. The editor's picker
|
||||
// surfaces this; PATCH accepts {"template_version_id": "<uuid>"} | null.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// submissionSectionJSON is the on-the-wire row for each per-draft
|
||||
@@ -126,15 +131,15 @@ type submissionDraftJSON struct {
|
||||
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
||||
// editable + adds the PATCH endpoint.
|
||||
type submissionSectionJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
@@ -170,6 +175,16 @@ type submissionDraftPatchInput struct {
|
||||
// admin-recovery flows).
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
BaseIDSet bool `json:"-"`
|
||||
// TemplateVersionID pins an uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). Same three-state presence contract as
|
||||
// base_id: absent = no change, uuid = pin, null = clear.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
TemplateVersionIDSet bool `json:"-"`
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear back
|
||||
// to the auto-derived rule name; "x" = set. Persisted in
|
||||
// composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
||||
@@ -193,6 +208,9 @@ func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
if _, ok := raw["base_id"]; ok {
|
||||
p.BaseIDSet = true
|
||||
}
|
||||
if _, ok := raw["template_version_id"]; ok {
|
||||
p.TemplateVersionIDSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -433,10 +451,17 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: input.Variables,
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
FilenameKeyword: input.FilenameKeyword,
|
||||
}
|
||||
if input.BaseIDSet {
|
||||
patch.BaseID = &input.BaseID
|
||||
}
|
||||
if input.TemplateVersionIDSet {
|
||||
if !validateTemplateVersionPin(w, r.Context(), input.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
patch.TemplateVersionID = &input.TemplateVersionID
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -517,7 +542,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
tplBytes, err := previewTemplateBytes(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
@@ -573,7 +598,7 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
// Audit + provenance updates are best-effort on a background
|
||||
// context so the download still succeeds if the DB races.
|
||||
@@ -597,6 +622,48 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateTemplateVersionPin checks that a non-nil template-version pin
|
||||
// refers to an existing version (404 otherwise), so a PATCH can't bind a
|
||||
// draft to a vanished template. A nil pin (clear) is always valid. Returns
|
||||
// true when the patch may proceed; writes the error response otherwise.
|
||||
func validateTemplateVersionPin(w http.ResponseWriter, ctx context.Context, pin *uuid.UUID) bool {
|
||||
if pin == nil {
|
||||
return true
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
if _, err := dbSvc.templateStore.GetVersion(ctx, pin.String()); err != nil {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template version not found"})
|
||||
} else {
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// previewTemplateBytes returns the carrier bytes to render a draft's
|
||||
// preview: the pinned uploaded-template version's carrier when set
|
||||
// (t-paliad-349 slice 7), otherwise the resolved upstream submission
|
||||
// template (v1/legacy path). A missing pinned version falls through to the
|
||||
// upstream resolution rather than failing.
|
||||
func previewTemplateBytes(ctx context.Context, d *services.SubmissionDraft) ([]byte, error) {
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
if err == nil {
|
||||
return tmpl.CarrierBytes, nil
|
||||
}
|
||||
if !errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// exportSubmissionDraft is the shared render entry point used by both
|
||||
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
||||
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
||||
@@ -607,6 +674,27 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
//
|
||||
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
// t-paliad-349 slice 7 — uploaded-template path, checked first. The
|
||||
// pinned version's carrier already carries {{slots}}; Export resolves
|
||||
// the bag + substitutes them via the same renderer the v1 path uses
|
||||
// (no Composer/sections — the uploaded doc IS the document). A missing
|
||||
// pinned version falls through to the base_id / v1 paths.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
switch {
|
||||
case err == nil:
|
||||
docx, resolved, rerr := dbSvc.submissionDraft.Export(ctx, d, tmpl.CarrierBytes)
|
||||
if rerr != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", rerr)
|
||||
}
|
||||
return docx, resolved, "", false, nil
|
||||
case errors.Is(err, docforge.ErrTemplateNotFound):
|
||||
log.Printf("submission_drafts: pinned template version missing (draft=%s version=%s) — falling back", d.ID, *d.TemplateVersionID)
|
||||
default:
|
||||
return nil, nil, "", false, fmt.Errorf("template version lookup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
||||
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
||||
switch {
|
||||
@@ -853,16 +941,26 @@ type globalDraftPatchInput struct {
|
||||
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
baseIDProvided bool
|
||||
// TemplateVersionID + provided flag — uploaded-template pin
|
||||
// (t-paliad-349 slice 7), same present/absent contract as base_id.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
templateVersionIDProvided bool
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
|
||||
// set. Persisted in composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -874,14 +972,17 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
g.BaseID = a.BaseID
|
||||
// Detect whether "project_id" / "base_id" were present in the JSON
|
||||
// object.
|
||||
g.TemplateVersionID = a.TemplateVersionID
|
||||
g.FilenameKeyword = a.FilenameKeyword
|
||||
// Detect whether "project_id" / "base_id" / "template_version_id" were
|
||||
// present in the JSON object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, g.projectIDProvided = raw["project_id"]
|
||||
_, g.baseIDProvided = raw["base_id"]
|
||||
_, g.templateVersionIDProvided = raw["template_version_id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -917,6 +1018,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: in.Variables,
|
||||
SelectedParties: in.SelectedParties,
|
||||
Language: in.Language,
|
||||
FilenameKeyword: in.FilenameKeyword,
|
||||
}
|
||||
if in.projectIDProvided {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
@@ -926,6 +1028,13 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
bid := in.BaseID // may be nil → clear
|
||||
patch.BaseID = &bid
|
||||
}
|
||||
if in.templateVersionIDProvided {
|
||||
if !validateTemplateVersionPin(w, r.Context(), in.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
tv := in.TemplateVersionID // may be nil → clear
|
||||
patch.TemplateVersionID = &tv
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
@@ -1045,7 +1154,7 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
@@ -1155,6 +1264,23 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — uploaded-template draft: render the pinned
|
||||
// carrier. The Gitea tier / language-fallback notions don't apply (they
|
||||
// describe the upstream fallback chain), so they stay at their zero
|
||||
// values. A missing pinned version falls through to upstream resolution.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
if tmpl, terr := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String()); terr == nil {
|
||||
html, rerr := dbSvc.submissionDraft.RenderPreview(ctx, d, tmpl.CarrierBytes)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
view.PreviewHTML = html
|
||||
return view, nil
|
||||
} else if !errors.Is(terr, docforge.ErrTemplateNotFound) {
|
||||
return nil, terr
|
||||
}
|
||||
}
|
||||
|
||||
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
|
||||
@@ -1184,11 +1310,11 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
type submissionTemplateTier string
|
||||
|
||||
const (
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
)
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
@@ -1306,21 +1432,22 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
TemplateVersionID: d.TemplateVersionID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
144
internal/handlers/submission_filename_test.go
Normal file
144
internal/handlers/submission_filename_test.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package handlers
|
||||
|
||||
// Regression tests for the generated-document download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
|
||||
// The date segment is environment-dependent (Europe/Berlin "today"),
|
||||
// so the assertions pin the keyword + bracketed case-number frame and
|
||||
// the .docx suffix rather than the literal date.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
func strptr(s string) *string { return &s }
|
||||
|
||||
func todayBerlin() string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
return day.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func TestSubmissionFileName(t *testing.T) {
|
||||
t.Parallel()
|
||||
rule := &models.DeadlineRule{Name: "Klageerwiderung", NameEN: "Statement of defence"}
|
||||
date := todayBerlin()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
rule *models.DeadlineRule
|
||||
project *models.Project
|
||||
lang string
|
||||
keyword string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full data — rule name + case number",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "missing case number falls back to placeholder",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: nil},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (Az. folgt).docx",
|
||||
},
|
||||
{
|
||||
name: "user override keyword wins over rule name",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
keyword: "Replik Hauptantrag",
|
||||
want: date + " Replik Hauptantrag (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "EN lang uses NameEN when no override",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "en",
|
||||
want: date + " Statement of defence (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "case number containing slash is sanitised inside brackets",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123/2026")},
|
||||
lang: "de",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "blank override falls back to rule name",
|
||||
rule: rule,
|
||||
project: &models.Project{CaseNumber: strptr("UPC_CFI_123_2026")},
|
||||
lang: "de",
|
||||
keyword: " ",
|
||||
want: date + " Klageerwiderung (UPC_CFI_123_2026).docx",
|
||||
},
|
||||
{
|
||||
name: "empty rule name + no override falls back to submission",
|
||||
rule: &models.DeadlineRule{Name: "", NameEN: ""},
|
||||
project: &models.Project{CaseNumber: nil},
|
||||
lang: "de",
|
||||
want: date + " submission (Az. folgt).docx",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got := submissionFileName(tc.rule, tc.project, tc.lang, tc.keyword)
|
||||
if got != tc.want {
|
||||
t.Errorf("submissionFileName() = %q, want %q", got, tc.want)
|
||||
}
|
||||
if !strings.HasSuffix(got, ".docx") {
|
||||
t.Errorf("filename %q missing .docx suffix", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionFilenameKeyword(t *testing.T) {
|
||||
t.Parallel()
|
||||
cases := []struct {
|
||||
name string
|
||||
draft *services.SubmissionDraft
|
||||
want string
|
||||
}{
|
||||
{"nil draft", nil, ""},
|
||||
{"nil meta", &services.SubmissionDraft{}, ""},
|
||||
{
|
||||
"key absent",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"other": "x"}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"key set",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
|
||||
"Replik",
|
||||
},
|
||||
{
|
||||
"key set with surrounding whitespace is trimmed",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": " Replik "}},
|
||||
"Replik",
|
||||
},
|
||||
{
|
||||
"non-string value ignored",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": 42}},
|
||||
"",
|
||||
},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := submissionFilenameKeyword(tc.draft); got != tc.want {
|
||||
t.Errorf("submissionFilenameKeyword() = %q, want %q", got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -336,7 +336,9 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
// One-click /generate has no saved draft row → no override store, so
|
||||
// the keyword stays the auto-derived rule name (t-paliad-354).
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang, "")
|
||||
|
||||
// Audit write is best-effort with a background context so the
|
||||
// download still succeeds if the DB races. Audit failure here only
|
||||
@@ -355,34 +357,66 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// submissionFileName produces the user-facing download name per
|
||||
// design §7: {rule.name}-{project.case_number}-{YYYY-MM-DD}.docx.
|
||||
// Empty case_number drops the segment entirely (no fallback hash —
|
||||
// the lawyer can rename if the project lacks an Aktenzeichen).
|
||||
// Umlauts in the rule name are ASCII-folded by SanitiseSubmissionFileName
|
||||
// so the file lands cleanly on legacy SMB shares.
|
||||
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang string) string {
|
||||
// submissionNoCaseNumberPlaceholder fills the bracketed case-number slot
|
||||
// when the project has no Aktenzeichen yet. Kept as a named const so the
|
||||
// wording is one-line changeable (m left the exact text open, t-paliad-354).
|
||||
const submissionNoCaseNumberPlaceholder = "Az. folgt"
|
||||
|
||||
// submissionFileName produces the user-facing download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx".
|
||||
//
|
||||
// - Date first (Europe/Berlin) so the files sort chronologically.
|
||||
// - keyword is the user override when set, else the lang-aware rule
|
||||
// name, else "submission".
|
||||
// - The case number is always rendered in parentheses; when the project
|
||||
// has no Aktenzeichen it falls back to submissionNoCaseNumberPlaceholder.
|
||||
//
|
||||
// Each segment is run through SanitiseSubmissionFileName (umlaut-folds for
|
||||
// legacy SMB shares, strips the Windows-reserved set so a case number like
|
||||
// "UPC_CFI_123/2026" stays safe) while the assembled "<date> <kw> (<case>)"
|
||||
// frame keeps its spaces and brackets — the sanitiser preserves both.
|
||||
func submissionFileName(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
}
|
||||
ruleName := strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
ruleName = strings.TrimSpace(rule.NameEN)
|
||||
kw := strings.TrimSpace(keyword)
|
||||
if kw == "" {
|
||||
kw = strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
kw = strings.TrimSpace(rule.NameEN)
|
||||
}
|
||||
}
|
||||
if ruleName == "" {
|
||||
ruleName = "submission"
|
||||
if kw == "" {
|
||||
kw = "submission"
|
||||
}
|
||||
parts := []string{services.SanitiseSubmissionFileName(ruleName)}
|
||||
caseNo := ""
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
caseNo = strings.TrimSpace(*project.CaseNumber)
|
||||
}
|
||||
if caseNo != "" {
|
||||
parts = append(parts, services.SanitiseSubmissionFileName(caseNo))
|
||||
if caseNo == "" {
|
||||
caseNo = submissionNoCaseNumberPlaceholder
|
||||
}
|
||||
parts = append(parts, day.Format("2006-01-02"))
|
||||
return strings.Join(parts, "-") + ".docx"
|
||||
return fmt.Sprintf("%s %s (%s).docx",
|
||||
day.Format("2006-01-02"),
|
||||
services.SanitiseSubmissionFileName(kw),
|
||||
services.SanitiseSubmissionFileName(caseNo),
|
||||
)
|
||||
}
|
||||
|
||||
// submissionFilenameKeyword pulls the user's filename keyword override
|
||||
// from a saved draft's composer_meta jsonb (t-paliad-354). Empty when the
|
||||
// key is absent or blank — callers then fall back to the auto-derived rule
|
||||
// name inside submissionFileName. The one-click /generate path has no draft
|
||||
// row and always passes "".
|
||||
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
|
||||
if d == nil || d.ComposerMeta == nil {
|
||||
return ""
|
||||
}
|
||||
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// writeSubmissionAuditRow files one row in paliad.system_audit_log per
|
||||
|
||||
306
internal/handlers/templates.go
Normal file
306
internal/handlers/templates.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package handlers
|
||||
|
||||
// docforge template authoring handlers (t-paliad-349 slice 6).
|
||||
//
|
||||
// The admin-only authoring surface: upload a base .docx, see it rendered as
|
||||
// run-addressable text, place {{variable}} slots into it, and save the
|
||||
// result as a reusable template. Backed by docforge.TemplateStore
|
||||
// (Postgres bytea carrier) + the docx authoring engine
|
||||
// (ImportForAuthoring / InjectSlot).
|
||||
//
|
||||
// Endpoints (all under adminGate — templates are firm-shared, admin-
|
||||
// authored, like submission_bases):
|
||||
// GET /api/admin/templates — catalog list
|
||||
// POST /api/admin/templates — multipart upload → create v1
|
||||
// GET /api/admin/templates/{id} — authoring view (preview+slots)
|
||||
// POST /api/admin/templates/{id}/slots — place a slot → new version
|
||||
//
|
||||
// Slot placement creates a new template version (immutable snapshot) per
|
||||
// placement. That keeps the snapshot guarantee simple; batching a whole
|
||||
// authoring session into one version on an explicit "save" is a documented
|
||||
// future refinement (it trades the version-per-slot churn for a client- or
|
||||
// session-held draft carrier).
|
||||
//
|
||||
// VERIFICATION CEILING: the live upload→render→select→inject→save flow
|
||||
// needs the app running with DATABASE_URL + Supabase auth + Playwright; it
|
||||
// is verified post-merge. The docx surgery (ImportForAuthoring/InjectSlot)
|
||||
// and the store are unit/live-tested independently.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// maxTemplateUpload bounds an uploaded .docx. Templates are firm letterhead
|
||||
// + chrome — tens of KB in practice; 10 MB is a generous ceiling.
|
||||
const maxTemplateUpload = 10 << 20
|
||||
|
||||
type templateMetaJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
Kind string `json:"kind"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
Firm string `json:"firm,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Version int `json:"version"`
|
||||
VersionID string `json:"version_id,omitempty"`
|
||||
}
|
||||
|
||||
type templateSlotJSON struct {
|
||||
Key string `json:"key"`
|
||||
Anchor string `json:"anchor"`
|
||||
Label string `json:"label,omitempty"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
}
|
||||
|
||||
type authoringViewJSON struct {
|
||||
Template templateMetaJSON `json:"template"`
|
||||
PreviewHTML string `json:"preview_html"`
|
||||
Slots []templateSlotJSON `json:"slots"`
|
||||
}
|
||||
|
||||
func metaJSON(m docforge.TemplateMeta) templateMetaJSON {
|
||||
return templateMetaJSON{
|
||||
ID: m.ID, Slug: m.Slug, NameDE: m.NameDE, NameEN: m.NameEN,
|
||||
Kind: m.Kind, SourceFormat: m.SourceFormat, Firm: m.Firm,
|
||||
IsActive: m.IsActive, Version: m.Version, VersionID: m.VersionID,
|
||||
}
|
||||
}
|
||||
|
||||
func slotsJSON(slots []docforge.TemplateSlot) []templateSlotJSON {
|
||||
out := make([]templateSlotJSON, 0, len(slots))
|
||||
for _, s := range slots {
|
||||
out = append(out, templateSlotJSON{Key: s.Key, Anchor: s.Anchor, Label: s.Label, OrderIndex: s.OrderIndex})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeTemplateError maps docforge's not-found sentinel to 404 and falls
|
||||
// back to the shared service-error mapper.
|
||||
func writeTemplateError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
|
||||
func requireTemplateStore(w http.ResponseWriter) bool {
|
||||
if !requireDB(w) {
|
||||
return false
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleTemplatesAuthoringPage serves the authoring page shell. The client
|
||||
// bundle hydrates the list, upload, preview, palette, and slots.
|
||||
func handleTemplatesAuthoringPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/templates-authoring.html")
|
||||
}
|
||||
|
||||
// handleListTemplates backs GET /api/admin/templates.
|
||||
func handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(), docforge.TemplateFilter{ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handlePickerTemplates backs GET /api/templates — the firm-shared catalog
|
||||
// any authenticated lawyer reads to pick an uploaded template for
|
||||
// generation (t-paliad-349 slice 7). Unlike the admin list it filters by
|
||||
// firm (the deployment's branding firm + firm-agnostic templates), matching
|
||||
// the submission_bases picker contract. Metadata only — no carrier bytes.
|
||||
func handlePickerTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(),
|
||||
docforge.TemplateFilter{Firm: branding.Name, ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handleUploadTemplate backs POST /api/admin/templates (multipart). Reads
|
||||
// the uploaded .docx, validates it parses, detects any slots already in it,
|
||||
// and creates the template at version 1.
|
||||
func handleUploadTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxTemplateUpload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
carrier, err := io.ReadAll(io.LimitReader(file, maxTemplateUpload))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not read uploaded file"})
|
||||
return
|
||||
}
|
||||
nameDE := r.FormValue("name_de")
|
||||
nameEN := r.FormValue("name_en")
|
||||
if nameDE == "" || nameEN == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name_de and name_en required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate + detect existing slots before persisting.
|
||||
view, err := docx.ImportForAuthoring(carrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "not a parseable .docx: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := dbSvc.templateStore.Create(r.Context(),
|
||||
docforge.TemplateMetaInput{
|
||||
Slug: r.FormValue("slug"),
|
||||
NameDE: nameDE,
|
||||
NameEN: nameEN,
|
||||
Firm: r.FormValue("firm"),
|
||||
CreatedBy: uid.String(),
|
||||
},
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: carrier,
|
||||
Slots: view.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(tmpl.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetTemplateAuthoring backs GET /api/admin/templates/{id} — the
|
||||
// authoring view: current carrier rendered run-addressable + its slots.
|
||||
func handleGetTemplateAuthoring(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := docx.ImportForAuthoring(tmpl.CarrierBytes)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stored carrier failed to parse: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(view.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
type placeSlotInput struct {
|
||||
RunIndex int `json:"run_index"`
|
||||
SelectedText string `json:"selected_text"`
|
||||
SlotKey string `json:"slot_key"`
|
||||
}
|
||||
|
||||
// handlePlaceTemplateSlot backs POST /api/admin/templates/{id}/slots —
|
||||
// inject a slot at the selection and persist as a new version.
|
||||
func handlePlaceTemplateSlot(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in placeSlotInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newCarrier, err := docx.InjectSlot(tmpl.CarrierBytes, in.RunIndex, in.SelectedText, in.SlotKey)
|
||||
if err != nil {
|
||||
// Injection failures are client-fixable (bad selection / key).
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-detect slots from the new carrier so template_slots mirrors the
|
||||
// carrier's actual {{tokens}} (single source of truth).
|
||||
newView, err := docx.ImportForAuthoring(newCarrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("post-inject parse: %v", err)})
|
||||
return
|
||||
}
|
||||
updated, err := dbSvc.templateStore.AddVersion(r.Context(), id,
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: newCarrier,
|
||||
Stylemap: tmpl.Stylemap,
|
||||
Slots: newView.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(updated.TemplateMeta),
|
||||
PreviewHTML: newView.PreviewHTML,
|
||||
Slots: slotsJSON(updated.Slots),
|
||||
})
|
||||
}
|
||||
@@ -37,16 +37,24 @@ type ScenarioBuilderService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
flags *ScenarioFlagsService
|
||||
// fristenrechner computes planned-deadline due dates during the B5
|
||||
// promote-to-project cascade (PRD §5.4 — "due_date=computed"). nil in
|
||||
// test setups that don't exercise promotion; the promote path then
|
||||
// skips planned events that have no actual_date (it can't assert a
|
||||
// date it didn't compute) and reports them via DeadlinesSkipped.
|
||||
fristenrechner *FristenrechnerService
|
||||
}
|
||||
|
||||
// NewScenarioBuilderService wires the service to the shared pool plus
|
||||
// the project + scenario-flags services it leans on for the Akte-mode
|
||||
// dual-write. projects + flags are optional in test setups (nil → the
|
||||
// dual-write hooks short-circuit), but a production wiring should
|
||||
// always pass them so Akte-backed scenarios stay in sync with project
|
||||
// surfaces.
|
||||
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db, projects: projects, flags: flags}
|
||||
// dual-write, and the Fristenrechner calc service the B5 promote path
|
||||
// uses to compute planned-deadline dates. projects / flags / frist are
|
||||
// optional in test setups (nil → the dual-write + promote-compute hooks
|
||||
// short-circuit), but a production wiring should always pass them so
|
||||
// Akte-backed scenarios stay in sync with project surfaces and
|
||||
// promotion cascades real dates.
|
||||
func NewScenarioBuilderService(db *sqlx.DB, projects *ProjectService, flags *ScenarioFlagsService, frist *FristenrechnerService) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db, projects: projects, flags: flags, fristenrechner: frist}
|
||||
}
|
||||
|
||||
// ErrScenarioBuilderNotVisible is returned when the caller is neither
|
||||
@@ -928,6 +936,438 @@ func (s *ScenarioBuilderService) DeleteShare(ctx context.Context, userID, scenar
|
||||
return nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Shared-with-me listing (B5)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// ListSharedWithMe returns scenarios shared read-only with the caller
|
||||
// (a paliad.scenario_shares row exists for shared_with_user_id = caller).
|
||||
// The caller's own scenarios are excluded — they live in ListMyScenarios.
|
||||
// Sorted by the share's created_at desc so the most-recently-shared sits
|
||||
// on top. Promoted scenarios stay visible (read-only reference) just like
|
||||
// in the owner's own list.
|
||||
func (s *ScenarioBuilderService) ListSharedWithMe(ctx context.Context, userID uuid.UUID) ([]BuilderScenario, error) {
|
||||
out := []BuilderScenario{}
|
||||
if err := s.db.SelectContext(ctx, &out,
|
||||
`SELECT sc.id, sc.owner_id, sc.name, sc.status, sc.origin_project_id,
|
||||
sc.promoted_project_id, sc.stichtag, sc.notes,
|
||||
sc.project_id, sc.description, sc.created_by,
|
||||
sc.created_at, sc.updated_at
|
||||
FROM paliad.scenarios sc
|
||||
JOIN paliad.scenario_shares sh ON sh.scenario_id = sc.id
|
||||
WHERE sh.shared_with_user_id = $1
|
||||
AND (sc.owner_id IS NULL OR sc.owner_id <> $1)
|
||||
ORDER BY sh.created_at DESC`, userID); err != nil {
|
||||
return nil, fmt.Errorf("list shared scenarios: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Promote-to-project (B5, PRD §2.4 + §5.4 + §10)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// PromotePartyInput is one party row the wizard's "Parteien ergänzen"
|
||||
// step contributes. Mirrors CreatePartyInput minus contact_info (the
|
||||
// wizard collects names + roles; full contact data is filled in the Akte
|
||||
// later).
|
||||
type PromotePartyInput struct {
|
||||
Name string `json:"name"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Representative *string `json:"representative,omitempty"`
|
||||
}
|
||||
|
||||
// PromoteTeamMemberInput grants a colleague access to the new project at
|
||||
// promote time. Responsibility defaults to 'member' when blank.
|
||||
type PromoteTeamMemberInput struct {
|
||||
UserID uuid.UUID `json:"user_id"`
|
||||
Responsibility string `json:"responsibility,omitempty"`
|
||||
}
|
||||
|
||||
// PromoteScenarioInput is the POST /api/builder/scenarios/{id}/promote
|
||||
// body — the merged payload from wizard steps 2 (Parteien) + 3
|
||||
// (Akte-Metadaten). The procedural shape (proceeding type, flags,
|
||||
// perspective) + event states come from the scenario itself; the wizard
|
||||
// only supplies the client-bound metadata the scenario can't know.
|
||||
type PromoteScenarioInput struct {
|
||||
Title string `json:"title"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
ParentID *uuid.UUID `json:"parent_id,omitempty"`
|
||||
OurSide *string `json:"our_side,omitempty"`
|
||||
Parties []PromotePartyInput `json:"parties,omitempty"`
|
||||
TeamMembers []PromoteTeamMemberInput `json:"team_members,omitempty"`
|
||||
}
|
||||
|
||||
// PromoteResult is the outcome the wizard navigates on.
|
||||
type PromoteResult struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
DeadlinesCreated int `json:"deadlines_created"`
|
||||
DeadlinesSkipped int `json:"deadlines_skipped"`
|
||||
PartiesCreated int `json:"parties_created"`
|
||||
ProceedingsSkipped int `json:"proceedings_skipped"`
|
||||
}
|
||||
|
||||
// PromoteScenario turns a scenario into a real paliad.projects 'case' row
|
||||
// in a single transaction (PRD §10 — no partial promotions). It promotes
|
||||
// the scenario's primary proceeding (the lowest-ordinal top-level
|
||||
// triplet) plus its spawned descendants (the CCR child etc., whose rules
|
||||
// fold into the primary's timeline under the active flags). Additional
|
||||
// unrelated top-level proceedings are left in the scenario and reported
|
||||
// via ProceedingsSkipped — v1 promotes one case file per call, matching
|
||||
// the singular acceptance criterion (one project, navigate to one id);
|
||||
// the scenario stays visible as 'promoted' for historical reference and
|
||||
// can seed a second promotion later.
|
||||
//
|
||||
// The cascade, all inside the tx:
|
||||
// 1. INSERT paliad.projects (type='case', client metadata from the
|
||||
// wizard, proceeding_type_id + scenario_flags from the primary
|
||||
// triplet, origin_scenario_id = scenario.id).
|
||||
// 2. INSERT the creator as team lead + any wizard-selected colleagues.
|
||||
// 3. INSERT parties from the wizard's step-2 payload.
|
||||
// 4. For each event under the promoted proceedings: filed → a completed
|
||||
// deadline (due_date + completed_at = actual_date); planned → an open
|
||||
// ('pending') deadline with the computed due_date; skipped → no row.
|
||||
// Planned events with no computable date (court-set / conditional /
|
||||
// no actual_date) are skipped and counted.
|
||||
// 5. UPDATE the scenario: status='promoted', promoted_project_id = new.
|
||||
//
|
||||
// Any error rolls the whole transaction back.
|
||||
func (s *ScenarioBuilderService) PromoteScenario(ctx context.Context, userID, scenarioID uuid.UUID, input PromoteScenarioInput) (*PromoteResult, error) {
|
||||
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if sc.Status == "promoted" {
|
||||
return nil, fmt.Errorf("%w: scenario is already promoted", ErrInvalidInput)
|
||||
}
|
||||
title := strings.TrimSpace(input.Title)
|
||||
if title == "" {
|
||||
return nil, fmt.Errorf("%w: title is required", ErrInvalidInput)
|
||||
}
|
||||
if input.OurSide != nil {
|
||||
if err := validateOurSide(*input.OurSide); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
for i := range input.Parties {
|
||||
if strings.TrimSpace(input.Parties[i].Name) == "" {
|
||||
return nil, fmt.Errorf("%w: party %d has a blank name", ErrInvalidInput, i+1)
|
||||
}
|
||||
}
|
||||
for _, tm := range input.TeamMembers {
|
||||
if tm.UserID == uuid.Nil {
|
||||
return nil, fmt.Errorf("%w: team member has an empty user_id", ErrInvalidInput)
|
||||
}
|
||||
if tm.Responsibility != "" && !IsValidResponsibility(tm.Responsibility) {
|
||||
return nil, fmt.Errorf("%w: invalid responsibility %q", ErrInvalidInput, tm.Responsibility)
|
||||
}
|
||||
}
|
||||
|
||||
// Parent visibility (mirrors ProjectService.Create): a litigation
|
||||
// parent the caller can't see would leak the new sub-tree.
|
||||
if input.ParentID != nil && s.projects != nil {
|
||||
if _, perr := s.projects.GetByID(ctx, userID, *input.ParentID); perr != nil {
|
||||
return nil, fmt.Errorf("%w: litigation parent not visible", ErrForbidden)
|
||||
}
|
||||
}
|
||||
|
||||
// Load the proceeding + event tree.
|
||||
proceedings := []BuilderProceeding{}
|
||||
if err := s.db.SelectContext(ctx, &proceedings, `
|
||||
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
parent_scenario_proceeding_id, spawn_anchor_event_id, ordinal,
|
||||
stichtag, detailgrad, appeal_target, collapsed, created_at, updated_at
|
||||
FROM paliad.scenario_proceedings
|
||||
WHERE scenario_id = $1
|
||||
ORDER BY ordinal ASC, created_at ASC`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("load proceedings: %w", err)
|
||||
}
|
||||
if len(proceedings) == 0 {
|
||||
return nil, fmt.Errorf("%w: scenario has no proceedings to promote", ErrInvalidInput)
|
||||
}
|
||||
|
||||
// Primary = first top-level proceeding (lowest ordinal). Collect it +
|
||||
// its spawned descendants; those form the one case file we promote.
|
||||
var primary *BuilderProceeding
|
||||
for i := range proceedings {
|
||||
if proceedings[i].ParentScenarioProceedingID == nil {
|
||||
primary = &proceedings[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if primary == nil {
|
||||
return nil, fmt.Errorf("%w: scenario has no top-level proceeding", ErrInvalidInput)
|
||||
}
|
||||
promoteSet := collectProceedingSubtree(proceedings, primary.ID)
|
||||
topLevelCount := 0
|
||||
for i := range proceedings {
|
||||
if proceedings[i].ParentScenarioProceedingID == nil {
|
||||
topLevelCount++
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the primary proceeding's catalog code (the calc engine keys
|
||||
// off code, not id).
|
||||
var primaryCode string
|
||||
if err := s.db.GetContext(ctx, &primaryCode,
|
||||
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, primary.ProceedingTypeID); err != nil {
|
||||
return nil, fmt.Errorf("resolve proceeding code: %w", err)
|
||||
}
|
||||
|
||||
// Resolve our_side: explicit wizard value wins; otherwise fold the
|
||||
// primary triplet's perspective down to the project axis.
|
||||
ourSide := input.OurSide
|
||||
if ourSide == nil {
|
||||
ourSide = primary.PrimaryParty
|
||||
}
|
||||
|
||||
// Compute the primary proceeding's timeline so planned events get real
|
||||
// dates. The CCR child's rules fold into this timeline under the
|
||||
// primary's flags (sub-track routing), so one calc covers the whole
|
||||
// promoted subtree. Keyed by lowercased rule id → display name/code/date.
|
||||
type computed struct {
|
||||
name string
|
||||
code string
|
||||
dueDate string
|
||||
}
|
||||
timelineByRule := map[string]computed{}
|
||||
if s.fristenrechner != nil {
|
||||
stichtag := promoteStichtag(primary, sc)
|
||||
opts := CalcOptions{Flags: scenarioFlagsTruthyKeys(primary.ScenarioFlags)}
|
||||
tl, cerr := s.fristenrechner.Calculate(ctx, primaryCode, stichtag, opts)
|
||||
if cerr != nil {
|
||||
// A calc failure is not fatal — filed events still carry their
|
||||
// own actual_date. Planned events then fall to DeadlinesSkipped.
|
||||
tl = nil
|
||||
}
|
||||
if tl != nil {
|
||||
for _, e := range tl.Deadlines {
|
||||
if e.RuleID == "" {
|
||||
continue
|
||||
}
|
||||
timelineByRule[strings.ToLower(e.RuleID)] = computed{
|
||||
name: e.Name, code: e.Code, dueDate: e.DueDate,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Load events for the promoted proceedings only.
|
||||
events := []BuilderEvent{}
|
||||
if err := s.db.SelectContext(ctx, &events, `
|
||||
SELECT e.id, e.scenario_proceeding_id, e.sequencing_rule_id,
|
||||
e.procedural_event_id, e.custom_label, e.state, e.actual_date,
|
||||
e.skip_reason, e.notes, e.horizon_optional, e.created_at, e.updated_at
|
||||
FROM paliad.scenario_events e
|
||||
JOIN paliad.scenario_proceedings sp ON sp.id = e.scenario_proceeding_id
|
||||
WHERE sp.scenario_id = $1
|
||||
ORDER BY e.created_at ASC`, scenarioID); err != nil {
|
||||
return nil, fmt.Errorf("load events: %w", err)
|
||||
}
|
||||
|
||||
result := &PromoteResult{
|
||||
ProceedingsSkipped: topLevelCount - 1,
|
||||
}
|
||||
newProjectID := uuid.New()
|
||||
|
||||
err = s.withAuditTx(ctx, "scenario_builder: promote scenario", func(tx *sqlx.Tx) error {
|
||||
now := time.Now().UTC()
|
||||
|
||||
// 1. Project row. path is filled by the BEFORE INSERT trigger
|
||||
// (projects_sync_path); '' satisfies the NOT NULL constraint.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.projects
|
||||
(id, type, parent_id, path, title, reference, status, created_by,
|
||||
case_number, client_number, proceeding_type_id, our_side,
|
||||
scenario_flags, origin_scenario_id, metadata, created_at, updated_at)
|
||||
VALUES ($1, 'case', $2, '', $3, $4, 'active', $5,
|
||||
$6, $7, $8, $9, $10::jsonb, $11, '{}'::jsonb, $12, $12)`,
|
||||
newProjectID, input.ParentID, title, input.Reference, userID,
|
||||
nullableTrimmed(stringPtrOrNil(input.CaseNumber)),
|
||||
nullableTrimmed(input.ClientNumber),
|
||||
primary.ProceedingTypeID, nullableOurSide(ourSide),
|
||||
[]byte(primary.ScenarioFlags), scenarioID, now); err != nil {
|
||||
return fmt.Errorf("insert project: %w", err)
|
||||
}
|
||||
|
||||
// 2a. Creator as team lead (RLS visibility, matches Create).
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, 'lead', 'lead', false, $2)`, newProjectID, userID); err != nil {
|
||||
return fmt.Errorf("insert creator team row: %w", err)
|
||||
}
|
||||
// 2b. Wizard-selected colleagues.
|
||||
for _, tm := range input.TeamMembers {
|
||||
if tm.UserID == userID {
|
||||
continue // creator already added as lead
|
||||
}
|
||||
resp := tm.Responsibility
|
||||
if resp == "" {
|
||||
resp = ResponsibilityMember
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.project_teams (project_id, user_id, role, responsibility, inherited, added_by)
|
||||
VALUES ($1, $2, $3, $4, false, $5)
|
||||
ON CONFLICT (project_id, user_id) DO UPDATE
|
||||
SET role = EXCLUDED.role, responsibility = EXCLUDED.responsibility`,
|
||||
newProjectID, tm.UserID, legacyRoleFromResponsibility(resp), resp, userID); err != nil {
|
||||
return fmt.Errorf("insert team member: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Parties.
|
||||
for _, p := range input.Parties {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.parties (project_id, name, role, representative, contact_info)
|
||||
VALUES ($1, $2, $3, $4, '{}'::jsonb)`,
|
||||
newProjectID, strings.TrimSpace(p.Name), p.Role, p.Representative); err != nil {
|
||||
return fmt.Errorf("insert party: %w", err)
|
||||
}
|
||||
result.PartiesCreated++
|
||||
}
|
||||
|
||||
// 4. Deadlines from the promoted proceedings' events.
|
||||
for _, ev := range events {
|
||||
if !promoteSet[ev.ScenarioProceedingID] {
|
||||
continue
|
||||
}
|
||||
if ev.State == "skipped" {
|
||||
continue
|
||||
}
|
||||
if ev.SequencingRuleID == nil {
|
||||
// Free-form / procedural-event-only cards have no rule to
|
||||
// anchor a deadline on in v1 — skip (counts as skipped only
|
||||
// when it was a dated plan; here just leave it out).
|
||||
continue
|
||||
}
|
||||
ruleKey := strings.ToLower(ev.SequencingRuleID.String())
|
||||
comp := timelineByRule[ruleKey]
|
||||
title := comp.name
|
||||
if strings.TrimSpace(title) == "" {
|
||||
title = "Litigation-Builder Frist"
|
||||
}
|
||||
ruleCode := comp.code
|
||||
|
||||
if ev.State == "filed" && ev.ActualDate != nil {
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(project_id, title, rule_code, due_date, sequencing_rule_id,
|
||||
status, completed_at, source, approval_status)
|
||||
VALUES ($1, $2, $3, $4::date, $5, 'completed', $6::timestamptz, 'rule', 'legacy')`,
|
||||
newProjectID, title, nullableTrimmed(&ruleCode), *ev.ActualDate,
|
||||
*ev.SequencingRuleID, *ev.ActualDate); err != nil {
|
||||
return fmt.Errorf("insert filed deadline: %w", err)
|
||||
}
|
||||
result.DeadlinesCreated++
|
||||
continue
|
||||
}
|
||||
|
||||
// planned — need a date. Prefer an explicit actual_date
|
||||
// (court-set override the user pinned), else the computed date.
|
||||
var dueDate *time.Time
|
||||
if ev.ActualDate != nil {
|
||||
dueDate = ev.ActualDate
|
||||
} else if comp.dueDate != "" {
|
||||
if d, perr := time.Parse("2006-01-02", comp.dueDate); perr == nil {
|
||||
dueDate = &d
|
||||
}
|
||||
}
|
||||
if dueDate == nil {
|
||||
result.DeadlinesSkipped++
|
||||
continue
|
||||
}
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(project_id, title, rule_code, due_date, sequencing_rule_id,
|
||||
status, source, approval_status)
|
||||
VALUES ($1, $2, $3, $4::date, $5, 'pending', 'rule', 'legacy')`,
|
||||
newProjectID, title, nullableTrimmed(&ruleCode), *dueDate,
|
||||
*ev.SequencingRuleID); err != nil {
|
||||
return fmt.Errorf("insert planned deadline: %w", err)
|
||||
}
|
||||
result.DeadlinesCreated++
|
||||
}
|
||||
|
||||
// 5. Flip the scenario to promoted.
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.scenarios
|
||||
SET status = 'promoted', promoted_project_id = $1, updated_at = now()
|
||||
WHERE id = $2`, newProjectID, scenarioID); err != nil {
|
||||
return fmt.Errorf("mark scenario promoted: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("promote scenario: %w", err)
|
||||
}
|
||||
result.ProjectID = newProjectID
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// collectProceedingSubtree returns the set of proceeding ids rooted at
|
||||
// rootID (inclusive), walking parent_scenario_proceeding_id downwards.
|
||||
func collectProceedingSubtree(all []BuilderProceeding, rootID uuid.UUID) map[uuid.UUID]bool {
|
||||
set := map[uuid.UUID]bool{rootID: true}
|
||||
// Iterate to a fixpoint; depth is tiny (<=2 today) so a few passes suffice.
|
||||
for changed := true; changed; {
|
||||
changed = false
|
||||
for i := range all {
|
||||
p := &all[i]
|
||||
if p.ParentScenarioProceedingID != nil && set[*p.ParentScenarioProceedingID] && !set[p.ID] {
|
||||
set[p.ID] = true
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// promoteStichtag picks the calc anchor for the promote timeline: the
|
||||
// primary proceeding's own stichtag, else the scenario default, else today.
|
||||
func promoteStichtag(primary *BuilderProceeding, sc *BuilderScenario) string {
|
||||
if primary.Stichtag != nil {
|
||||
return primary.Stichtag.Format("2006-01-02")
|
||||
}
|
||||
if sc.Stichtag != nil {
|
||||
return sc.Stichtag.Format("2006-01-02")
|
||||
}
|
||||
return time.Now().UTC().Format("2006-01-02")
|
||||
}
|
||||
|
||||
// scenarioFlagsTruthyKeys returns the flag keys set to boolean true in the
|
||||
// builder's scenario_flags jsonb — the array shape the calc engine's
|
||||
// CalcOptions.Flags consumes.
|
||||
func scenarioFlagsTruthyKeys(raw json.RawMessage) []string {
|
||||
if len(raw) == 0 {
|
||||
return nil
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
return nil
|
||||
}
|
||||
out := []string{}
|
||||
for k, v := range m {
|
||||
if b, ok := v.(bool); ok && b {
|
||||
out = append(out, k)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// stringPtrOrNil normalises a *string so an all-whitespace value becomes
|
||||
// nil before nullableTrimmed sees it (case_number empty → NULL column).
|
||||
func stringPtrOrNil(p *string) *string {
|
||||
if p == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(*p) == "" {
|
||||
return nil
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
@@ -83,7 +83,7 @@ func TestScenarioBuilderService(t *testing.T) {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
|
||||
svc := NewScenarioBuilderService(pool, nil, nil)
|
||||
svc := NewScenarioBuilderService(pool, nil, nil, nil)
|
||||
|
||||
// 1. Create a scenario for the owner. Empty name should default.
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{})
|
||||
@@ -318,7 +318,7 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) {
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc)
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────
|
||||
// Phase A — Akte-backed scenario writes through to project tables.
|
||||
@@ -459,6 +459,190 @@ func TestScenarioBuilderAkteDualWrite(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestScenarioBuilderPromote pins B5's load-bearing contract
|
||||
// (m/paliad#153 / t-paliad-350 / PRD §2.4 + §5.4 + §10): PromoteScenario
|
||||
// creates a paliad.projects 'case' row transactionally, cascades parties
|
||||
// + deadlines, flips the scenario to 'promoted' with a back-ref, and
|
||||
// makes the original scenario read-only afterwards.
|
||||
func TestScenarioBuilderPromote(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
owner := uuid.New()
|
||||
var createdProjectID uuid.UUID
|
||||
cleanup := func() {
|
||||
if createdProjectID != uuid.Nil {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, createdProjectID)
|
||||
}
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.scenarios WHERE owner_id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, owner)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, owner)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO auth.users (id, email) VALUES ($1, 'promote-owner-test@hlc.com')`, owner); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, lang)
|
||||
VALUES ($1, 'promote-owner-test@hlc.com', 'Promote Owner', 'munich', 'de')`, owner); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
var ptID int
|
||||
if err := pool.GetContext(ctx, &ptID,
|
||||
`SELECT id FROM paliad.proceeding_types WHERE code = $1 AND is_active = true LIMIT 1`,
|
||||
CodeUPCInfringement); err != nil {
|
||||
t.Fatalf("look up upc.inf id: %v", err)
|
||||
}
|
||||
// Two distinct rule ids: one filed, one planned (with an explicit
|
||||
// actual_date so the planned deadline lands even without a calc engine).
|
||||
var ruleIDs []uuid.UUID
|
||||
if err := pool.SelectContext(ctx, &ruleIDs,
|
||||
`SELECT id FROM paliad.sequencing_rules
|
||||
WHERE proceeding_type_id = $1 AND is_active = true AND lifecycle_state = 'published'
|
||||
ORDER BY sequence_order NULLS LAST, id LIMIT 2`, ptID); err != nil {
|
||||
t.Fatalf("look up sequencing_rules: %v", err)
|
||||
}
|
||||
if len(ruleIDs) < 2 {
|
||||
t.Skipf("need >=2 published rules for upc.inf; got %d", len(ruleIDs))
|
||||
}
|
||||
|
||||
userSvc := NewUserService(pool)
|
||||
projSvc := NewProjectService(pool, userSvc)
|
||||
flagsSvc := NewScenarioFlagsService(pool, projSvc)
|
||||
// fristenrechner nil — planned events carry an explicit actual_date in
|
||||
// this test, so the cascade doesn't need computed dates.
|
||||
svc := NewScenarioBuilderService(pool, projSvc, flagsSvc, nil)
|
||||
|
||||
sc, err := svc.CreateScenario(ctx, owner, CreateBuilderScenarioInput{Name: "Promote-Test"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateScenario: %v", err)
|
||||
}
|
||||
pr, err := svc.AddProceeding(ctx, owner, sc.ID, AddProceedingInput{
|
||||
ProceedingTypeID: ptID,
|
||||
PrimaryParty: ptrString("defendant"),
|
||||
ScenarioFlags: json.RawMessage(`{"with_ccr": true}`),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("AddProceeding: %v", err)
|
||||
}
|
||||
filedDate := mustDate(t, "2026-01-15")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[0], State: ptrString("filed"), ActualDate: &filedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent filed: %v", err)
|
||||
}
|
||||
plannedDate := mustDate(t, "2026-04-01")
|
||||
if _, err := svc.AddEvent(ctx, owner, sc.ID, pr.ID, AddEventInput{
|
||||
SequencingRuleID: &ruleIDs[1], State: ptrString("planned"), ActualDate: &plannedDate,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddEvent planned: %v", err)
|
||||
}
|
||||
|
||||
res, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{
|
||||
Title: "Becker ./. X — UPC",
|
||||
CaseNumber: ptrString("UPC_CFI_123/2026"),
|
||||
OurSide: ptrString("defendant"),
|
||||
Parties: []PromotePartyInput{
|
||||
{Name: "Becker GmbH", Role: ptrString("defendant")},
|
||||
{Name: "X Corp", Role: ptrString("claimant")},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("PromoteScenario: %v", err)
|
||||
}
|
||||
createdProjectID = res.ProjectID
|
||||
if res.ProjectID == uuid.Nil {
|
||||
t.Fatal("PromoteScenario returned nil project id")
|
||||
}
|
||||
if res.PartiesCreated != 2 {
|
||||
t.Errorf("PartiesCreated = %d, want 2", res.PartiesCreated)
|
||||
}
|
||||
if res.DeadlinesCreated != 2 {
|
||||
t.Errorf("DeadlinesCreated = %d, want 2 (1 filed + 1 planned-with-date)", res.DeadlinesCreated)
|
||||
}
|
||||
|
||||
// Project row exists, is a 'case', carries origin_scenario_id + flags.
|
||||
var proj struct {
|
||||
Type string `db:"type"`
|
||||
OriginScenario *uuid.UUID `db:"origin_scenario_id"`
|
||||
ProceedingType *int `db:"proceeding_type_id"`
|
||||
OurSide *string `db:"our_side"`
|
||||
ScenarioFlags json.RawMessage `db:"scenario_flags"`
|
||||
CaseNumber *string `db:"case_number"`
|
||||
}
|
||||
if err := pool.GetContext(ctx, &proj,
|
||||
`SELECT type, origin_scenario_id, proceeding_type_id, our_side, scenario_flags, case_number
|
||||
FROM paliad.projects WHERE id = $1`, res.ProjectID); err != nil {
|
||||
t.Fatalf("load promoted project: %v", err)
|
||||
}
|
||||
if proj.Type != "case" {
|
||||
t.Errorf("project type = %q, want case", proj.Type)
|
||||
}
|
||||
if proj.OriginScenario == nil || *proj.OriginScenario != sc.ID {
|
||||
t.Errorf("origin_scenario_id = %v, want %v", proj.OriginScenario, sc.ID)
|
||||
}
|
||||
if proj.ProceedingType == nil || *proj.ProceedingType != ptID {
|
||||
t.Errorf("proceeding_type_id = %v, want %d", proj.ProceedingType, ptID)
|
||||
}
|
||||
if proj.OurSide == nil || *proj.OurSide != "defendant" {
|
||||
t.Errorf("our_side = %v, want defendant", proj.OurSide)
|
||||
}
|
||||
|
||||
// Scenario flipped to promoted with the back-ref.
|
||||
var after BuilderScenario
|
||||
if err := pool.GetContext(ctx, &after,
|
||||
`SELECT id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes, project_id, description, created_by, created_at, updated_at
|
||||
FROM paliad.scenarios WHERE id = $1`, sc.ID); err != nil {
|
||||
t.Fatalf("reload scenario: %v", err)
|
||||
}
|
||||
if after.Status != "promoted" {
|
||||
t.Errorf("scenario status = %q, want promoted", after.Status)
|
||||
}
|
||||
if after.PromotedProjectID == nil || *after.PromotedProjectID != res.ProjectID {
|
||||
t.Errorf("promoted_project_id = %v, want %v", after.PromotedProjectID, res.ProjectID)
|
||||
}
|
||||
|
||||
// Deadlines + parties physically present.
|
||||
var deadlineCount, partyCount int
|
||||
pool.GetContext(ctx, &deadlineCount,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines WHERE project_id = $1`, res.ProjectID)
|
||||
pool.GetContext(ctx, &partyCount,
|
||||
`SELECT COUNT(*) FROM paliad.parties WHERE project_id = $1`, res.ProjectID)
|
||||
if deadlineCount != 2 {
|
||||
t.Errorf("deadlines in DB = %d, want 2", deadlineCount)
|
||||
}
|
||||
if partyCount != 2 {
|
||||
t.Errorf("parties in DB = %d, want 2", partyCount)
|
||||
}
|
||||
|
||||
// Promoted scenario is now read-only: further PATCH is rejected.
|
||||
if _, err := svc.PatchScenario(ctx, owner, sc.ID, PatchBuilderScenarioInput{
|
||||
Name: ptrString("rename-after-promote"),
|
||||
}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("PatchScenario after promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
// Re-promoting is rejected.
|
||||
if _, err := svc.PromoteScenario(ctx, owner, sc.ID, PromoteScenarioInput{Title: "again"}); !errors.Is(err, ErrInvalidInput) {
|
||||
t.Errorf("re-promote = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
}
|
||||
|
||||
// mustDate parses an ISO date or fails the test. Helper for the
|
||||
// dual-write test above.
|
||||
func mustDate(t *testing.T, s string) time.Time {
|
||||
|
||||
178
internal/services/submission_autoname.go
Normal file
178
internal/services/submission_autoname.go
Normal file
@@ -0,0 +1,178 @@
|
||||
package services
|
||||
|
||||
// Auto-naming for freshly-created submission drafts (t-paliad-352 /
|
||||
// m/paliad#155). A new project-bound draft gets a sortable, legal-
|
||||
// convention default title instead of the bare "Entwurf N" counter:
|
||||
//
|
||||
// <YYYY-MM-DD> <ClientName> ./. <ForumShort> ./. <OpponentName>
|
||||
//
|
||||
// The date leads so drafts sort chronologically; " ./. " is the German
|
||||
// legal shorthand for "gegen". The three identity segments are the
|
||||
// client we act for, the forum the proceeding runs in, and the opposing
|
||||
// party — exactly the trio m named ("CLIENTNAME / UPC / OPPONENTNAME").
|
||||
//
|
||||
// Missing-segment rule: any segment that resolves empty is dropped
|
||||
// together with its leading separator, so a project without an opponent
|
||||
// yet renders "2026-05-31 Bayer AG ./. UPC" (no trailing separator) and
|
||||
// a project-less draft never reaches this path at all (it keeps the
|
||||
// "Entwurf N" counter — see SubmissionDraftService.Create).
|
||||
//
|
||||
// v1.1 customization hook: the template is hardcoded here in v1. When m
|
||||
// promotes naming to a per-user / per-firm / per-base setting (issue
|
||||
// #155 Q4), the override string lands as an extra parameter on
|
||||
// AutoSubmissionTitle (or a small template struct) and the segment
|
||||
// resolvers below stay as the value source. Nothing else needs to move.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// submissionTitleSep is the separator between identity segments —
|
||||
// " ./. " is the German legal convention for "gegen" / "versus".
|
||||
const submissionTitleSep = " ./. "
|
||||
|
||||
// AutoSubmissionTitle assembles the auto-generated draft title from the
|
||||
// resolved identity pieces. Pure and table-testable — every DB hop
|
||||
// happens in the caller (SubmissionDraftService.autoNameForProject).
|
||||
//
|
||||
// clientName is passed separately because the client we act for is the
|
||||
// root ancestor of the project tree, not a field on the draft's own
|
||||
// project node; the caller walks the path to resolve it. ourSide and
|
||||
// the proceeding type both come off the draft's project node, the
|
||||
// parties hang directly off it.
|
||||
//
|
||||
// The date is always present (formatted in Europe/Berlin to match the
|
||||
// today.* render vars); the three identity segments are appended only
|
||||
// when non-empty.
|
||||
func AutoSubmissionTitle(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType) string {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
date := now.Format("2006-01-02")
|
||||
|
||||
segments := make([]string, 0, 3)
|
||||
if c := strings.TrimSpace(clientName); c != "" {
|
||||
segments = append(segments, c)
|
||||
}
|
||||
if f := submissionForumShort(pt); f != "" {
|
||||
segments = append(segments, f)
|
||||
}
|
||||
ourSide := ""
|
||||
if project != nil {
|
||||
ourSide = derefString(project.OurSide)
|
||||
}
|
||||
if o := submissionOpponentName(parties, ourSide); o != "" {
|
||||
segments = append(segments, o)
|
||||
}
|
||||
|
||||
if len(segments) == 0 {
|
||||
return date
|
||||
}
|
||||
return date + " " + strings.Join(segments, submissionTitleSep)
|
||||
}
|
||||
|
||||
// submissionForumShort maps a proceeding type to the short forum label
|
||||
// used in the auto-name. The jurisdiction is the forum for the
|
||||
// supranational / office tracks (UPC, EPA, DPMA); German court
|
||||
// proceedings disambiguate by the court that hears them (LG / OLG /
|
||||
// BGH / BPatG), which is the tail segment of the proceeding code
|
||||
// (de.inf.lg → LG, de.null.bpatg → BPatG). nil / unknown → "".
|
||||
func submissionForumShort(pt *models.ProceedingType) string {
|
||||
if pt == nil {
|
||||
return ""
|
||||
}
|
||||
switch j := strings.ToUpper(strings.TrimSpace(derefString(pt.Jurisdiction))); j {
|
||||
case "":
|
||||
return ""
|
||||
case "DE":
|
||||
return germanCourtShort(pt.Code)
|
||||
default:
|
||||
// UPC / EPA / DPMA and any future jurisdiction are their own
|
||||
// forum label.
|
||||
return j
|
||||
}
|
||||
}
|
||||
|
||||
// germanCourtShort returns the court abbreviation from the tail segment
|
||||
// of a German proceeding code (the part after the last "."). Known
|
||||
// courts get their canonical casing; anything else falls back to the
|
||||
// uppercased tail so a new German proceeding still yields a label.
|
||||
func germanCourtShort(code string) string {
|
||||
parts := strings.Split(code, ".")
|
||||
tail := strings.ToLower(strings.TrimSpace(parts[len(parts)-1]))
|
||||
switch tail {
|
||||
case "":
|
||||
return ""
|
||||
case "lg":
|
||||
return "LG"
|
||||
case "olg":
|
||||
return "OLG"
|
||||
case "bgh":
|
||||
return "BGH"
|
||||
case "bpatg":
|
||||
return "BPatG"
|
||||
default:
|
||||
return strings.ToUpper(tail)
|
||||
}
|
||||
}
|
||||
|
||||
// submissionOpponentName picks the name of the primary opposing party
|
||||
// given the side we act for. We act actively (claimant / applicant /
|
||||
// appellant) → the opponent is on the defendant bucket; we act
|
||||
// reactively (defendant / respondent) → the opponent is the claimant.
|
||||
// An unknown / unset side (third_party, other, NULL) can't fix a
|
||||
// posture, so no opponent is derived (the segment is omitted). The
|
||||
// first party of the opposing bucket wins — PartyService.ListForProject
|
||||
// orders by name, so the pick is deterministic for a given project.
|
||||
func submissionOpponentName(parties []models.Party, ourSide string) string {
|
||||
var want string
|
||||
switch sidePosture(ourSide) {
|
||||
case "active":
|
||||
want = "defendant"
|
||||
case "reactive":
|
||||
want = "claimant"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
for i := range parties {
|
||||
if partyRoleBucket(parties[i].Role) == want {
|
||||
if n := strings.TrimSpace(parties[i].Name); n != "" {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// sidePosture folds the our_side sub-role vocabulary (t-paliad-222)
|
||||
// down to the active / reactive axis. Returns "" for sides that have no
|
||||
// clear posture (third_party, other) or an unset value.
|
||||
func sidePosture(ourSide string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(ourSide)) {
|
||||
case "claimant", "applicant", "appellant":
|
||||
return "active"
|
||||
case "defendant", "respondent":
|
||||
return "reactive"
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// partyRoleBucket folds a party's free-text role into the
|
||||
// claimant / defendant / other buckets. German and English spellings
|
||||
// both fold in; everything else (Streithelfer, Patentinhaberin, …) is
|
||||
// "other". Shared with addPartyVars so the two paths can't drift.
|
||||
func partyRoleBucket(role *string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(derefString(role))) {
|
||||
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
||||
return "claimant"
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
return "defendant"
|
||||
default:
|
||||
return "other"
|
||||
}
|
||||
}
|
||||
224
internal/services/submission_autoname_test.go
Normal file
224
internal/services/submission_autoname_test.go
Normal file
@@ -0,0 +1,224 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
func party(name, role string) models.Party {
|
||||
return models.Party{Name: name, Role: strPtr(role)}
|
||||
}
|
||||
|
||||
func proceeding(jurisdiction, code string) *models.ProceedingType {
|
||||
return &models.ProceedingType{Jurisdiction: strPtr(jurisdiction), Code: code}
|
||||
}
|
||||
|
||||
func projectSide(side string) *models.Project {
|
||||
if side == "" {
|
||||
return &models.Project{}
|
||||
}
|
||||
return &models.Project{OurSide: strPtr(side)}
|
||||
}
|
||||
|
||||
// noon UTC on 2026-05-31 → 14:00 Europe/Berlin (CEST), same calendar day.
|
||||
var fixedNow = time.Date(2026, 5, 31, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func TestAutoSubmissionTitle(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
clientName string
|
||||
project *models.Project
|
||||
parties []models.Party
|
||||
pt *models.ProceedingType
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "full data — UPC, we are claimant",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 Bayer AG ./. UPC ./. Novartis Pharma",
|
||||
},
|
||||
{
|
||||
name: "full data — German court, we are respondent",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("respondent"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("DE", "de.null.bpatg"),
|
||||
want: "2026-05-31 Bayer AG ./. BPatG ./. Acme Generics",
|
||||
},
|
||||
{
|
||||
name: "no opponent — opposing bucket empty",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Bayer AG", "Klägerin")}, // only our own side
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 Bayer AG ./. UPC",
|
||||
},
|
||||
{
|
||||
name: "no forum — proceeding type missing",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("respondent"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: nil,
|
||||
want: "2026-05-31 Bayer AG ./. Acme Generics",
|
||||
},
|
||||
{
|
||||
name: "no client — client segment omitted",
|
||||
clientName: "",
|
||||
project: projectSide("claimant"),
|
||||
parties: []models.Party{party("Novartis Pharma", "Beklagte")},
|
||||
pt: proceeding("UPC", "upc.inf.cfi"),
|
||||
want: "2026-05-31 UPC ./. Novartis Pharma",
|
||||
},
|
||||
{
|
||||
name: "all identity segments missing — date only",
|
||||
clientName: "",
|
||||
project: projectSide(""), // no our_side → no opponent posture
|
||||
parties: nil,
|
||||
pt: nil,
|
||||
want: "2026-05-31",
|
||||
},
|
||||
{
|
||||
name: "unknown side — opponent omitted even with parties",
|
||||
clientName: "Bayer AG",
|
||||
project: projectSide("third_party"),
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("EPA", "epa.opp.opd"),
|
||||
want: "2026-05-31 Bayer AG ./. EPA",
|
||||
},
|
||||
{
|
||||
name: "nil project — opponent omitted, client + forum stand",
|
||||
clientName: "Bayer AG",
|
||||
project: nil,
|
||||
parties: []models.Party{party("Acme Generics", "Klägerin")},
|
||||
pt: proceeding("DPMA", "dpma.opp.dpma"),
|
||||
want: "2026-05-31 Bayer AG ./. DPMA",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
got := AutoSubmissionTitle(fixedNow, c.clientName, c.project, c.parties, c.pt)
|
||||
if got != c.want {
|
||||
t.Errorf("AutoSubmissionTitle = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAutoSubmissionTitleBerlinDate locks the Europe/Berlin localisation:
|
||||
// 22:30 UTC on 2026-05-31 is already 00:30 on 2026-06-01 in CEST, so the
|
||||
// date segment must roll over.
|
||||
func TestAutoSubmissionTitleBerlinDate(t *testing.T) {
|
||||
lateUTC := time.Date(2026, 5, 31, 22, 30, 0, 0, time.UTC)
|
||||
got := AutoSubmissionTitle(lateUTC, "Bayer AG", projectSide("claimant"),
|
||||
[]models.Party{party("Novartis", "Beklagte")}, proceeding("UPC", "upc.inf.cfi"))
|
||||
want := "2026-06-01 Bayer AG ./. UPC ./. Novartis"
|
||||
if got != want {
|
||||
t.Errorf("AutoSubmissionTitle (late UTC) = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionForumShort(t *testing.T) {
|
||||
cases := []struct {
|
||||
pt *models.ProceedingType
|
||||
want string
|
||||
}{
|
||||
{nil, ""},
|
||||
{proceeding("UPC", "upc.inf.cfi"), "UPC"},
|
||||
{proceeding("EPA", "epa.opp.opd"), "EPA"},
|
||||
{proceeding("DPMA", "dpma.opp.dpma"), "DPMA"},
|
||||
{proceeding("DE", "de.inf.lg"), "LG"},
|
||||
{proceeding("DE", "de.inf.olg"), "OLG"},
|
||||
{proceeding("DE", "de.inf.bgh"), "BGH"},
|
||||
{proceeding("DE", "de.null.bpatg"), "BPatG"},
|
||||
{proceeding("DE", "de.null.bgh"), "BGH"},
|
||||
{proceeding("DE", "de.foo.amtsgericht"), "AMTSGERICHT"}, // unknown court → uppercased tail
|
||||
{proceeding("de", "de.inf.lg"), "LG"}, // lowercase jurisdiction folds
|
||||
{proceeding("", ""), ""}, // no jurisdiction
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := submissionForumShort(c.pt); got != c.want {
|
||||
t.Errorf("submissionForumShort(%+v) = %q, want %q", c.pt, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSubmissionOpponentName(t *testing.T) {
|
||||
claimantA := party("Acme", "Klägerin")
|
||||
defendantB := party("Novartis", "Beklagte")
|
||||
other := party("Streithelfer X", "Streithelfer")
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
parties []models.Party
|
||||
ourSide string
|
||||
want string
|
||||
}{
|
||||
{"active → first defendant", []models.Party{claimantA, defendantB}, "claimant", "Novartis"},
|
||||
{"reactive → first claimant", []models.Party{claimantA, defendantB}, "respondent", "Acme"},
|
||||
{"applicant (active) → defendant", []models.Party{defendantB}, "applicant", "Novartis"},
|
||||
{"appellant (active) → defendant", []models.Party{defendantB}, "appellant", "Novartis"},
|
||||
{"defendant (reactive) → claimant", []models.Party{claimantA}, "defendant", "Acme"},
|
||||
{"unknown side → none", []models.Party{claimantA, defendantB}, "third_party", ""},
|
||||
{"empty side → none", []models.Party{claimantA, defendantB}, "", ""},
|
||||
{"no opposing party → none", []models.Party{claimantA, other}, "claimant", ""},
|
||||
{"opposing bucket only 'other' → none", []models.Party{other}, "respondent", ""},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := submissionOpponentName(c.parties, c.ourSide); got != c.want {
|
||||
t.Errorf("submissionOpponentName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniqueDraftName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
base string
|
||||
existing []string
|
||||
want string
|
||||
}{
|
||||
{"free", "2026-05-31 Bayer AG ./. UPC", nil, "2026-05-31 Bayer AG ./. UPC"},
|
||||
{"first clash → (2)", "2026-05-31 Bayer AG ./. UPC",
|
||||
[]string{"2026-05-31 Bayer AG ./. UPC"}, "2026-05-31 Bayer AG ./. UPC (2)"},
|
||||
{"two clash → (3)", "2026-05-31 Bayer AG ./. UPC",
|
||||
[]string{"2026-05-31 Bayer AG ./. UPC", "2026-05-31 Bayer AG ./. UPC (2)"},
|
||||
"2026-05-31 Bayer AG ./. UPC (3)"},
|
||||
{"gap reused → (2)", "X",
|
||||
[]string{"X", "X (3)"}, "X (2)"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := uniqueDraftName(c.base, c.existing); got != c.want {
|
||||
t.Errorf("uniqueDraftName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextDraftName(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
existing []string
|
||||
lang string
|
||||
want string
|
||||
}{
|
||||
{"empty de", nil, "de", "Entwurf 1"},
|
||||
{"empty en", nil, "en", "Draft 1"},
|
||||
{"highest+1", []string{"Entwurf 1", "Entwurf 3"}, "de", "Entwurf 4"},
|
||||
{"ignores foreign names", []string{"2026-05-31 Bayer AG"}, "de", "Entwurf 1"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if got := nextDraftName(c.existing, c.lang); got != c.want {
|
||||
t.Errorf("nextDraftName = %q, want %q", got, c.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
129
internal/services/submission_draft_autoname_live_test.go
Normal file
129
internal/services/submission_draft_autoname_live_test.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for the submission-draft auto-naming scheme
|
||||
// (t-paliad-352 / m/paliad#155). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Verifies the shipped Create flow end-to-end against real Postgres:
|
||||
// a project-bound draft is auto-named "<date> <client> ./. <forum> ./.
|
||||
// <opponent>" rather than "Entwurf N", the segments resolve from the
|
||||
// real project tree (client = root ancestor, forum = proceeding-type
|
||||
// jurisdiction, opponent = opposing party by our_side), and a second
|
||||
// draft on the same slot de-duplicates with a " (2)" suffix.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_AutoName_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New()
|
||||
email := "autoname-" + userID.String()[:8] + "@hlc.com"
|
||||
var clientID, caseID uuid.UUID
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.parties WHERE project_id = $1`, caseID)
|
||||
// Children first (FK), then root.
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.project_teams WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, caseID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.projects WHERE id = $1`, clientID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Auto Name', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
// Client root → case child. The case carries the proceeding type
|
||||
// (UPC) and our_side (claimant), the party is the opponent.
|
||||
client, err := projects.Create(ctx, userID, CreateProjectInput{
|
||||
Type: "client", Title: "Bayer AG",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create client project: %v", err)
|
||||
}
|
||||
clientID = client.ID
|
||||
|
||||
ptID := 8 // upc.inf.cfi → jurisdiction UPC
|
||||
side := "claimant"
|
||||
caseProj, err := projects.Create(ctx, userID, CreateProjectInput{
|
||||
Type: "case", Title: "Streitsache", ParentID: &client.ID,
|
||||
ProceedingTypeID: &ptID, OurSide: &side,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create case project: %v", err)
|
||||
}
|
||||
caseID = caseProj.ID
|
||||
|
||||
beklagte := "Beklagte"
|
||||
if _, err := parties.Create(ctx, userID, caseProj.ID, CreatePartyInput{
|
||||
Name: "Novartis Pharma", Role: &beklagte,
|
||||
}); err != nil {
|
||||
t.Fatalf("create party: %v", err)
|
||||
}
|
||||
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
today := time.Now().In(loc).Format("2006-01-02")
|
||||
wantBase := today + " Bayer AG ./. UPC ./. Novartis Pharma"
|
||||
|
||||
d1, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 1: %v", err)
|
||||
}
|
||||
if d1.Name != wantBase {
|
||||
t.Fatalf("draft 1 name = %q, want %q", d1.Name, wantBase)
|
||||
}
|
||||
|
||||
// Second draft on the same (project, code) slot must de-duplicate.
|
||||
d2, err := drafts.Create(ctx, userID, &caseProj.ID, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft 2: %v", err)
|
||||
}
|
||||
want2 := wantBase + " (2)"
|
||||
if d2.Name != want2 {
|
||||
t.Fatalf("draft 2 name = %q, want %q", d2.Name, want2)
|
||||
}
|
||||
|
||||
// A project-less draft keeps the legacy Entwurf-N counter.
|
||||
dless, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create project-less draft: %v", err)
|
||||
}
|
||||
if dless.Name != "Entwurf 1" {
|
||||
t.Fatalf("project-less draft name = %q, want %q", dless.Name, "Entwurf 1")
|
||||
}
|
||||
}
|
||||
111
internal/services/submission_draft_keyword_live_test.go
Normal file
111
internal/services/submission_draft_keyword_live_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for the user-replaceable filename keyword
|
||||
// (t-paliad-354). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Exercises the real Update → Get code path against Postgres: setting the
|
||||
// override merges into composer_meta.filename_keyword without clobbering
|
||||
// other composer keys, clearing it removes only that key, and the value
|
||||
// reads back through the same jsonb decode the export handler relies on.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_FilenameKeyword_Live(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New()
|
||||
email := "kw-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Keyword Tester', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
|
||||
// A project-less draft is the simplest fixture — no project tree
|
||||
// needed to exercise composer_meta persistence.
|
||||
d, err := drafts.Create(ctx, userID, nil, "upc.inf.cfi.sod", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create draft: %v", err)
|
||||
}
|
||||
|
||||
// Pre-seed an unrelated composer_meta key to prove the merge/delete
|
||||
// only touches filename_keyword.
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts SET composer_meta = '{"other":"keep-me"}'::jsonb WHERE id = $1`,
|
||||
d.ID); err != nil {
|
||||
t.Fatalf("seed composer_meta: %v", err)
|
||||
}
|
||||
|
||||
// Set the override.
|
||||
kw := "Replik Hauptantrag"
|
||||
got, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &kw})
|
||||
if err != nil {
|
||||
t.Fatalf("update set keyword: %v", err)
|
||||
}
|
||||
if v, _ := got.ComposerMeta["filename_keyword"].(string); v != kw {
|
||||
t.Fatalf("after set: filename_keyword = %q, want %q", v, kw)
|
||||
}
|
||||
if v, _ := got.ComposerMeta["other"].(string); v != "keep-me" {
|
||||
t.Fatalf("after set: unrelated key 'other' = %q, want %q (merge clobbered it)", v, "keep-me")
|
||||
}
|
||||
|
||||
// Read back through Get (the path the export handler uses).
|
||||
reload, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get after set: %v", err)
|
||||
}
|
||||
if v, _ := reload.ComposerMeta["filename_keyword"].(string); v != kw {
|
||||
t.Fatalf("reload: filename_keyword = %q, want %q", v, kw)
|
||||
}
|
||||
|
||||
// Clear the override (empty string) — only filename_keyword should go.
|
||||
empty := ""
|
||||
cleared, err := drafts.Update(ctx, userID, d.ID, DraftPatch{FilenameKeyword: &empty})
|
||||
if err != nil {
|
||||
t.Fatalf("update clear keyword: %v", err)
|
||||
}
|
||||
if _, present := cleared.ComposerMeta["filename_keyword"]; present {
|
||||
t.Fatalf("after clear: filename_keyword still present: %v", cleared.ComposerMeta)
|
||||
}
|
||||
if v, _ := cleared.ComposerMeta["other"].(string); v != "keep-me" {
|
||||
t.Fatalf("after clear: unrelated key 'other' = %q, want %q (delete removed too much)", v, "keep-me")
|
||||
}
|
||||
}
|
||||
@@ -63,12 +63,17 @@ type SubmissionDraft struct {
|
||||
// ON DELETE SET NULL keeps a draft renderable if its base is
|
||||
// removed; the lawyer picks a new one via the sidebar.
|
||||
BaseID *uuid.UUID `db:"base_id" json:"base_id,omitempty"`
|
||||
// TemplateVersionID pins an uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). NULL = render via base_id Composer path or
|
||||
// the v1 fallback; non-NULL = render the pinned version's carrier.
|
||||
// The export/preview path checks this first. ON DELETE SET NULL.
|
||||
TemplateVersionID *uuid.UUID `db:"template_version_id" json:"template_version_id,omitempty"`
|
||||
// ComposerMetaRaw / ComposerMeta — Composer-side metadata jsonb.
|
||||
// Slice A: empty default. Future slices populate section_order,
|
||||
// hidden_sections, etc.
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
ComposerMetaRaw []byte `db:"composer_meta" json:"-"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
@@ -170,6 +175,22 @@ type DraftPatch struct {
|
||||
// content is unaffected — the base swap is render-side only.
|
||||
// t-paliad-313.
|
||||
BaseID **uuid.UUID
|
||||
|
||||
// TemplateVersionID pins (or clears) an uploaded docforge template
|
||||
// version. Same three-state two-level pointer as BaseID:
|
||||
// nil → no change
|
||||
// *p == nil → clear (back to base_id / v1)
|
||||
// **p → pin the version (validated via TemplateStore.GetVersion)
|
||||
// t-paliad-349 slice 7.
|
||||
TemplateVersionID **uuid.UUID
|
||||
|
||||
// FilenameKeyword sets (or clears) the user override that leads the
|
||||
// exported document name "<date> <keyword> (<case>)" (t-paliad-354).
|
||||
// Stored under composer_meta.filename_keyword — no dedicated column:
|
||||
// nil → no change
|
||||
// *p == "" → clear the key (back to the auto-derived rule name)
|
||||
// *p == "x" → set the override
|
||||
FilenameKeyword *string
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
@@ -186,7 +207,7 @@ const draftColumns = `id, project_id, submission_code, user_id, name, language,
|
||||
variables, selected_parties,
|
||||
last_exported_at, last_exported_sha,
|
||||
last_imported_at,
|
||||
base_id, composer_meta,
|
||||
base_id, template_version_id, composer_meta,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
@@ -239,7 +260,7 @@ func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name, d.language,
|
||||
d.variables, d.selected_parties,
|
||||
d.last_exported_at, d.last_exported_sha, d.last_imported_at,
|
||||
d.base_id, d.composer_meta,
|
||||
d.base_id, d.template_version_id, d.composer_meta,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
@@ -343,12 +364,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
|
||||
// creates with base_id=NULL — Composer is additive, the v1 fallback
|
||||
// path remains valid.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
var project *models.Project
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
p, err := s.projects.GetByID(ctx, userID, *projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
project = p
|
||||
}
|
||||
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
|
||||
name, err := s.newDraftName(ctx, userID, project, projectID, submissionCode, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -418,20 +442,94 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, p
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
||||
// suffix if two callers race; the unique constraint on the table is
|
||||
// the final guard.
|
||||
// newDraftName picks the title for a freshly-created draft. Project-
|
||||
// bound drafts get the auto-name scheme (t-paliad-352 / m/paliad#155) —
|
||||
// "<date> <client> ./. <forum> ./. <opponent>", de-duplicated against
|
||||
// the user's existing drafts for the same (project, submission_code).
|
||||
// Project-less drafts (and any project-bound draft whose auto-name
|
||||
// resolves to nothing) fall back to the "Entwurf N" / "Draft N"
|
||||
// counter.
|
||||
//
|
||||
// A nil projectID scopes the search to the user's project-less drafts
|
||||
// for this submission_code — matches the row-uniqueness contract on
|
||||
// the DB side (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
|
||||
prefix := "Entwurf"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "Draft"
|
||||
// Only Create calls this — existing drafts are never renamed (the
|
||||
// scheme is create-time only, per #155). A lawyer's later manual rename
|
||||
// flows through Update and is left untouched.
|
||||
func (s *SubmissionDraftService) newDraftName(ctx context.Context, userID uuid.UUID, project *models.Project, projectID *uuid.UUID, submissionCode, lang string) (string, error) {
|
||||
existing, err := s.existingDraftNames(ctx, projectID, submissionCode, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if project != nil {
|
||||
auto, err := s.autoNameForProject(ctx, time.Now(), project)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if strings.TrimSpace(auto) != "" {
|
||||
return uniqueDraftName(auto, existing), nil
|
||||
}
|
||||
}
|
||||
return nextDraftName(existing, lang), nil
|
||||
}
|
||||
|
||||
// autoNameForProject resolves the three identity segments for a
|
||||
// project-bound draft and hands them to the pure AutoSubmissionTitle
|
||||
// assembler. The client is the root ancestor of the project tree (the
|
||||
// 'client' node), the proceeding type and our_side come off the draft's
|
||||
// own project node, and the parties hang directly off it.
|
||||
//
|
||||
// A failure to resolve the client / proceeding type is not fatal —
|
||||
// AutoSubmissionTitle just omits the empty segment — so the only errors
|
||||
// returned here are genuine DB faults.
|
||||
func (s *SubmissionDraftService) autoNameForProject(ctx context.Context, now time.Time, project *models.Project) (string, error) {
|
||||
clientName, err := s.clientNameForProject(ctx, project.ID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
pt, err := s.vars.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var parties []models.Party
|
||||
if err := s.db.SelectContext(ctx, &parties,
|
||||
`SELECT id, project_id, name, role, representative, contact_info,
|
||||
created_at, updated_at
|
||||
FROM paliad.parties
|
||||
WHERE project_id = $1
|
||||
ORDER BY name`, project.ID); err != nil {
|
||||
return "", fmt.Errorf("auto-name: load parties: %w", err)
|
||||
}
|
||||
|
||||
return AutoSubmissionTitle(now, clientName, project, parties, pt), nil
|
||||
}
|
||||
|
||||
// clientNameForProject returns the title of the 'client' ancestor in
|
||||
// the project's path (the firm's mandant). Empty string when the tree
|
||||
// has no client node — the auto-name then omits the client segment.
|
||||
func (s *SubmissionDraftService) clientNameForProject(ctx context.Context, projectID uuid.UUID) (string, error) {
|
||||
var title string
|
||||
err := s.db.GetContext(ctx, &title,
|
||||
`SELECT p.title
|
||||
FROM paliad.projects target
|
||||
JOIN paliad.projects p
|
||||
ON p.id = ANY(string_to_array(target.path, '.')::uuid[])
|
||||
WHERE target.id = $1 AND p.type = 'client'
|
||||
LIMIT 1`, projectID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("auto-name: resolve client name: %w", err)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
// existingDraftNames returns the names already in use for the
|
||||
// (project, submission_code, user) slot. A nil projectID scopes to the
|
||||
// user's project-less drafts for this submission_code — matching the
|
||||
// DB unique contract (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) existingDraftNames(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID) ([]string, error) {
|
||||
var names []string
|
||||
var err error
|
||||
if projectID == nil {
|
||||
@@ -446,16 +544,48 @@ func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *u
|
||||
*projectID, submissionCode, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scan existing draft names: %w", err)
|
||||
return nil, fmt.Errorf("scan existing draft names: %w", err)
|
||||
}
|
||||
return names, nil
|
||||
}
|
||||
|
||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
||||
// suffix if two callers race; the unique constraint on the table is
|
||||
// the final guard. Pure over the supplied name list.
|
||||
func nextDraftName(existing []string, lang string) string {
|
||||
prefix := "Entwurf"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "Draft"
|
||||
}
|
||||
highest := 0
|
||||
for _, n := range names {
|
||||
for _, n := range existing {
|
||||
var idx int
|
||||
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
||||
highest = idx
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1), nil
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1)
|
||||
}
|
||||
|
||||
// uniqueDraftName returns base unchanged when it's free, otherwise
|
||||
// appends " (N)" with the lowest N≥2 that isn't taken. Mirrors the
|
||||
// "race → unique constraint is the final guard" contract of
|
||||
// nextDraftName; pure over the supplied name list.
|
||||
func uniqueDraftName(base string, existing []string) string {
|
||||
taken := make(map[string]struct{}, len(existing))
|
||||
for _, n := range existing {
|
||||
taken[n] = struct{}{}
|
||||
}
|
||||
if _, clash := taken[base]; !clash {
|
||||
return base
|
||||
}
|
||||
for i := 2; ; i++ {
|
||||
cand := fmt.Sprintf("%s (%d)", base, i)
|
||||
if _, clash := taken[cand]; !clash {
|
||||
return cand
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update patches the draft. Variables is replace-semantics — pass the
|
||||
@@ -567,6 +697,30 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.TemplateVersionID != nil {
|
||||
newTV := *patch.TemplateVersionID // *uuid.UUID — nil means clear
|
||||
// Existence is enforced by the FK + validated at the handler via
|
||||
// TemplateStore.GetVersion (clean 404); here we just set it.
|
||||
setParts = append(setParts, fmt.Sprintf("template_version_id = $%d", idx))
|
||||
args = append(args, newTV)
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.FilenameKeyword != nil {
|
||||
// Targeted jsonb merge so other composer_meta keys survive. An
|
||||
// empty override removes the key entirely, restoring the
|
||||
// auto-derived rule name as the filename keyword (t-paliad-354).
|
||||
kw := strings.TrimSpace(*patch.FilenameKeyword)
|
||||
if kw == "" {
|
||||
setParts = append(setParts, "composer_meta = composer_meta - 'filename_keyword'")
|
||||
} else {
|
||||
setParts = append(setParts,
|
||||
fmt.Sprintf("composer_meta = composer_meta || jsonb_build_object('filename_keyword', $%d::text)", idx))
|
||||
args = append(args, kw)
|
||||
idx++
|
||||
}
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
}
|
||||
@@ -878,7 +1032,6 @@ func normalizeDraftLanguage(lang string) string {
|
||||
return "de"
|
||||
}
|
||||
|
||||
|
||||
// Compile-time guard: ensure the *models.User reference in the import
|
||||
// graph doesn't get optimised away by linters. The service doesn't
|
||||
// dereference User directly — that happens in SubmissionVarsService —
|
||||
|
||||
184
internal/services/submission_draft_template_live_test.go
Normal file
184
internal/services/submission_draft_template_live_test.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package services
|
||||
|
||||
// Live-DB test for generation-on-uploaded-templates (t-paliad-349 slice 7).
|
||||
// Skipped without TEST_DATABASE_URL. Verifies the shipped draft-service
|
||||
// change end-to-end against real Postgres:
|
||||
// 1. submission_drafts.template_version_id round-trips through
|
||||
// Update → Get (the column-sync + patch path), and clears to NULL.
|
||||
// 2. An uploaded template's carrier renders via the v1 Export path:
|
||||
// {{firm.name}} in the carrier substitutes to the branding name.
|
||||
//
|
||||
// This is the verification the head greenlit (option C) before the
|
||||
// shipped-code change is committed.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
func TestSubmissionDraft_TemplateVersionPin(t *testing.T) {
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping live DB test")
|
||||
}
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
pool, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := uuid.New()
|
||||
email := "tplpin-" + userID.String()[:8] + "@hlc.com"
|
||||
cleanup := func() {
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.submission_drafts WHERE user_id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.templates WHERE created_by = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM paliad.users WHERE id = $1`, userID)
|
||||
pool.ExecContext(ctx, `DELETE FROM auth.users WHERE id = $1`, userID)
|
||||
}
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
if _, err := pool.ExecContext(ctx, `INSERT INTO auth.users (id, email) VALUES ($1, $2)`, userID, email); err != nil {
|
||||
t.Fatalf("seed auth.users: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`INSERT INTO paliad.users (id, email, display_name, office, global_role, lang)
|
||||
VALUES ($1, $2, 'Tpl Pin', 'munich', 'standard', 'de')`, userID, email); err != nil {
|
||||
t.Fatalf("seed paliad.users: %v", err)
|
||||
}
|
||||
|
||||
users := NewUserService(pool)
|
||||
projects := NewProjectService(pool, users)
|
||||
parties := NewPartyService(pool, projects)
|
||||
vars := NewSubmissionVarsService(pool, projects, parties, users)
|
||||
renderer := NewSubmissionRenderer()
|
||||
drafts := NewSubmissionDraftService(pool, projects, vars, renderer)
|
||||
store := NewPgTemplateStore(pool)
|
||||
|
||||
// Uploaded template whose carrier carries a {{firm.name}} slot.
|
||||
carrier := minimalDocxWithBody(t, `<w:p><w:r><w:t>Von {{firm.name}}</w:t></w:r></w:p>`)
|
||||
tmpl, err := store.Create(ctx,
|
||||
docforge.TemplateMetaInput{NameDE: "Pin-Test", NameEN: "Pin test", CreatedBy: userID.String()},
|
||||
docforge.TemplateVersionInput{CarrierBytes: carrier, CreatedBy: userID.String()})
|
||||
if err != nil {
|
||||
t.Fatalf("store.Create: %v", err)
|
||||
}
|
||||
if tmpl.VersionID == "" {
|
||||
t.Fatalf("template VersionID empty — generation can't pin it")
|
||||
}
|
||||
versionID := uuid.MustParse(tmpl.VersionID)
|
||||
|
||||
// Project-less draft on a code that has a published rule (so Build
|
||||
// resolves). No composer attached → plain draft.
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("drafts.Create: %v", err)
|
||||
}
|
||||
if d.TemplateVersionID != nil {
|
||||
t.Errorf("fresh draft has a template pin: %v", d.TemplateVersionID)
|
||||
}
|
||||
|
||||
// --- Pin the version via Update, read it back via Get.
|
||||
pin := &versionID
|
||||
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &pin}); err != nil {
|
||||
t.Fatalf("Update(pin): %v", err)
|
||||
}
|
||||
got, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after pin: %v", err)
|
||||
}
|
||||
if got.TemplateVersionID == nil || *got.TemplateVersionID != versionID {
|
||||
t.Fatalf("pinned template_version_id = %v; want %s", got.TemplateVersionID, versionID)
|
||||
}
|
||||
|
||||
// --- The uploaded carrier renders via Export: {{firm.name}} → "HLC".
|
||||
out, _, err := drafts.Export(ctx, got, carrier)
|
||||
if err != nil {
|
||||
t.Fatalf("Export: %v", err)
|
||||
}
|
||||
doc := unzipDocumentXML(t, out)
|
||||
if strings.Contains(doc, "{{firm.name}}") {
|
||||
t.Errorf("placeholder not substituted; doc=%s", doc)
|
||||
}
|
||||
if !strings.Contains(doc, "HLC") {
|
||||
t.Errorf("firm.name did not resolve to HLC; doc=%s", doc)
|
||||
}
|
||||
|
||||
// --- Clearing the pin sets it back to NULL.
|
||||
var nilPin *uuid.UUID
|
||||
if _, err := drafts.Update(ctx, userID, d.ID, DraftPatch{TemplateVersionID: &nilPin}); err != nil {
|
||||
t.Fatalf("Update(clear): %v", err)
|
||||
}
|
||||
cleared, err := drafts.Get(ctx, userID, d.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("Get after clear: %v", err)
|
||||
}
|
||||
if cleared.TemplateVersionID != nil {
|
||||
t.Errorf("template_version_id = %v after clear; want nil", cleared.TemplateVersionID)
|
||||
}
|
||||
}
|
||||
|
||||
// minimalDocxWithBody builds a tiny valid .docx (zip) whose document.xml
|
||||
// body is the given inner XML.
|
||||
func minimalDocxWithBody(t *testing.T, inner string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
add := func(name, body string) {
|
||||
w, err := zw.Create(name)
|
||||
if err != nil {
|
||||
t.Fatalf("zip create %s: %v", name, err)
|
||||
}
|
||||
if _, err := io.WriteString(w, body); err != nil {
|
||||
t.Fatalf("zip write %s: %v", name, err)
|
||||
}
|
||||
}
|
||||
add("[Content_Types].xml",
|
||||
`<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">`+
|
||||
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/></Types>`)
|
||||
add("word/document.xml",
|
||||
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`+
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`+
|
||||
`<w:body>`+inner+`</w:body></w:document>`)
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("zip close: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
func unzipDocumentXML(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("open zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
data, _ := io.ReadAll(rc)
|
||||
return string(data)
|
||||
}
|
||||
t.Fatal("document.xml not found in output")
|
||||
return ""
|
||||
}
|
||||
@@ -412,11 +412,10 @@ func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.Proceeding
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimants, defendants, others []models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger", "klägerin", "klaegerin":
|
||||
switch partyRoleBucket(parties[i].Role) {
|
||||
case "claimant":
|
||||
claimants = append(claimants, parties[i])
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
case "defendant":
|
||||
defendants = append(defendants, parties[i])
|
||||
default:
|
||||
others = append(others, parties[i])
|
||||
|
||||
55
internal/services/submission_vars_catalogue_test.go
Normal file
55
internal/services/submission_vars_catalogue_test.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// The variable catalogue is the single source of truth for the sidebar
|
||||
// form + authoring palette labels (t-paliad-349 slice 5). These checks
|
||||
// pin its integrity so a resolver Keys() edit can't silently ship a
|
||||
// malformed entry or a duplicate key.
|
||||
func TestSubmissionVariableCatalogue(t *testing.T) {
|
||||
cat := SubmissionVariableCatalogue()
|
||||
if len(cat) == 0 {
|
||||
t.Fatal("catalogue is empty")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for _, e := range cat {
|
||||
if e.Key == "" || e.LabelDE == "" || e.LabelEN == "" || e.Group == "" {
|
||||
t.Errorf("incomplete catalogue entry: %+v", e)
|
||||
}
|
||||
if seen[e.Key] {
|
||||
t.Errorf("duplicate catalogue key: %q", e.Key)
|
||||
}
|
||||
seen[e.Key] = true
|
||||
}
|
||||
|
||||
// Spot-check one key per namespace resolves with the expected label.
|
||||
want := map[string]struct{ group, de string }{
|
||||
"firm.name": {"firm", "Kanzlei"},
|
||||
"today.long_de": {"today", "Heute (DE lang)"},
|
||||
"user.display_name": {"user", "Bearbeiter"},
|
||||
"project.case_number": {"project", "Aktenzeichen (Gericht)"},
|
||||
"parties.claimant.name": {"parties", "Klägerin"},
|
||||
"procedural_event.legal_source_pretty": {"procedural_event", "Rechtsgrundlage"},
|
||||
"deadline.due_date": {"deadline", "Frist (ISO)"},
|
||||
}
|
||||
byKey := map[string]struct{ group, de string }{}
|
||||
for _, e := range cat {
|
||||
byKey[e.Key] = struct{ group, de string }{e.Group, e.LabelDE}
|
||||
}
|
||||
for k, exp := range want {
|
||||
got, ok := byKey[k]
|
||||
if !ok {
|
||||
t.Errorf("catalogue missing expected key %q", k)
|
||||
continue
|
||||
}
|
||||
if got.group != exp.group || got.de != exp.de {
|
||||
t.Errorf("catalogue[%q] = {%q, %q}; want {%q, %q}", k, got.group, got.de, exp.group, exp.de)
|
||||
}
|
||||
}
|
||||
|
||||
// The legacy rule.* aliases must be present for labelFor coverage.
|
||||
if !seen["rule.name"] || !seen["rule.legal_source_pretty"] {
|
||||
t.Error("legacy rule.* aliases missing from catalogue")
|
||||
}
|
||||
}
|
||||
@@ -30,23 +30,67 @@ var (
|
||||
_ docforge.VariableResolver = deadlineResolver{}
|
||||
)
|
||||
|
||||
// vk is a terse constructor for a catalogue entry in the given group.
|
||||
func vk(group, key, de, en string) docforge.VariableKey {
|
||||
return docforge.VariableKey{Key: key, LabelDE: de, LabelEN: en, Group: group}
|
||||
}
|
||||
|
||||
// SubmissionVariableCatalogue returns the full variable catalogue for the
|
||||
// submission resolvers — every (key, DE/EN label, namespace) the sidebar
|
||||
// form and the authoring palette can offer. Built from the resolvers'
|
||||
// Keys() with no entity state, so it needs no DB call. This is the single
|
||||
// source of truth for variable labels, replacing the duplicated TS
|
||||
// VARIABLE_LABELS table (t-paliad-349 slice 5).
|
||||
func SubmissionVariableCatalogue() []docforge.VariableKey {
|
||||
return docforge.NewResolverSet(
|
||||
firmResolver{},
|
||||
todayResolver{},
|
||||
userResolver{},
|
||||
proceduralEventResolver{},
|
||||
projectResolver{},
|
||||
partiesResolver{},
|
||||
deadlineResolver{},
|
||||
).Catalogue()
|
||||
}
|
||||
|
||||
// firmResolver populates firm.* from process-wide branding.
|
||||
type firmResolver struct{}
|
||||
|
||||
func (firmResolver) Namespace() string { return "firm" }
|
||||
func (firmResolver) Populate(bag PlaceholderMap) { addFirmVars(bag) }
|
||||
func (firmResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("firm", "firm.name", "Kanzlei", "Firm"),
|
||||
vk("firm", "firm.signature_block", "Signatur-Block", "Signature block"),
|
||||
}
|
||||
}
|
||||
|
||||
// todayResolver populates today.* from the build-time clock.
|
||||
type todayResolver struct{ now time.Time }
|
||||
|
||||
func (todayResolver) Namespace() string { return "today" }
|
||||
func (r todayResolver) Populate(bag PlaceholderMap) { addTodayVars(bag, r.now) }
|
||||
func (todayResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("today", "today", "Heute", "Today"),
|
||||
vk("today", "today.iso", "Heute (ISO)", "Today (ISO)"),
|
||||
vk("today", "today.long_de", "Heute (DE lang)", "Today (DE long)"),
|
||||
vk("today", "today.long_en", "Heute (EN lang)", "Today (EN long)"),
|
||||
}
|
||||
}
|
||||
|
||||
// userResolver populates user.* from the caller's row.
|
||||
type userResolver struct{ user *models.User }
|
||||
|
||||
func (userResolver) Namespace() string { return "user" }
|
||||
func (r userResolver) Populate(bag PlaceholderMap) { addUserVars(bag, r.user) }
|
||||
func (userResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("user", "user.display_name", "Bearbeiter", "Author"),
|
||||
vk("user", "user.email", "E-Mail", "Email"),
|
||||
vk("user", "user.office", "Büro", "Office"),
|
||||
}
|
||||
}
|
||||
|
||||
// proceduralEventResolver populates procedural_event.* and the legacy
|
||||
// rule.* alias from the published deadline_rule.
|
||||
@@ -57,6 +101,27 @@ type proceduralEventResolver struct {
|
||||
|
||||
func (proceduralEventResolver) Namespace() string { return "procedural_event" }
|
||||
func (r proceduralEventResolver) Populate(bag PlaceholderMap) { addRuleVars(bag, r.rule, r.lang) }
|
||||
func (proceduralEventResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("procedural_event", "procedural_event.code", "Code (Verfahrensschritt)", "Code (procedural event)"),
|
||||
vk("procedural_event", "procedural_event.name", "Verfahrensschritt", "Procedural event"),
|
||||
vk("procedural_event", "procedural_event.name_de", "Verfahrensschritt (DE)", "Procedural event (DE)"),
|
||||
vk("procedural_event", "procedural_event.name_en", "Verfahrensschritt (EN)", "Procedural event (EN)"),
|
||||
vk("procedural_event", "procedural_event.legal_source", "Rechtsgrundlage (Code)", "Legal source (code)"),
|
||||
vk("procedural_event", "procedural_event.legal_source_pretty", "Rechtsgrundlage", "Legal source"),
|
||||
vk("procedural_event", "procedural_event.primary_party", "Partei (typisch)", "Primary party"),
|
||||
vk("procedural_event", "procedural_event.event_kind", "Art des Verfahrensschritts", "Procedural-event kind"),
|
||||
// Legacy rule.* aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
vk("procedural_event", "rule.submission_code", "Schriftsatz-Code (legacy)", "Submission code (legacy)"),
|
||||
vk("procedural_event", "rule.name", "Schriftsatz (legacy)", "Submission (legacy)"),
|
||||
vk("procedural_event", "rule.name_de", "Schriftsatz (DE, legacy)", "Submission (DE, legacy)"),
|
||||
vk("procedural_event", "rule.name_en", "Schriftsatz (EN, legacy)", "Submission (EN, legacy)"),
|
||||
vk("procedural_event", "rule.legal_source", "Rechtsgrundlage (Code, legacy)", "Legal source (code, legacy)"),
|
||||
vk("procedural_event", "rule.legal_source_pretty", "Rechtsgrundlage (legacy)", "Legal source (legacy)"),
|
||||
vk("procedural_event", "rule.primary_party", "Partei (typisch, legacy)", "Primary party (legacy)"),
|
||||
vk("procedural_event", "rule.event_type", "Schriftsatz-Typ (legacy)", "Event type (legacy)"),
|
||||
}
|
||||
}
|
||||
|
||||
// projectResolver populates project.* from the project + its proceeding type.
|
||||
type projectResolver struct {
|
||||
@@ -67,6 +132,28 @@ type projectResolver struct {
|
||||
|
||||
func (projectResolver) Namespace() string { return "project" }
|
||||
func (r projectResolver) Populate(bag PlaceholderMap) { addProjectVars(bag, r.project, r.pt, r.lang) }
|
||||
func (projectResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("project", "project.title", "Projekttitel", "Project title"),
|
||||
vk("project", "project.reference", "Aktenzeichen (intern)", "Internal reference"),
|
||||
vk("project", "project.case_number", "Aktenzeichen (Gericht)", "Court case number"),
|
||||
vk("project", "project.court", "Gericht", "Court"),
|
||||
vk("project", "project.patent_number", "Patentnummer", "Patent number"),
|
||||
vk("project", "project.patent_number_upc", "Patentnummer (UPC-Format)", "Patent number (UPC format)"),
|
||||
vk("project", "project.filing_date", "Anmeldedatum", "Filing date"),
|
||||
vk("project", "project.grant_date", "Erteilungsdatum", "Grant date"),
|
||||
vk("project", "project.our_side", "Unsere Seite", "Our side"),
|
||||
vk("project", "project.our_side_de", "Unsere Seite (DE)", "Our side (DE)"),
|
||||
vk("project", "project.our_side_en", "Unsere Seite (EN)", "Our side (EN)"),
|
||||
vk("project", "project.instance_level", "Instanz", "Instance"),
|
||||
vk("project", "project.client_number", "Mandantennummer", "Client number"),
|
||||
vk("project", "project.matter_number", "Matter-Nummer", "Matter number"),
|
||||
vk("project", "project.proceeding.code", "Verfahrenstyp (Code)", "Proceeding type (code)"),
|
||||
vk("project", "project.proceeding.name", "Verfahrenstyp", "Proceeding type"),
|
||||
vk("project", "project.proceeding.name_de", "Verfahrenstyp (DE)", "Proceeding type (DE)"),
|
||||
vk("project", "project.proceeding.name_en", "Verfahrenstyp (EN)", "Proceeding type (EN)"),
|
||||
}
|
||||
}
|
||||
|
||||
// partiesResolver populates parties.* from the (already filtered) party list.
|
||||
type partiesResolver struct{ parties []models.Party }
|
||||
@@ -74,6 +161,21 @@ type partiesResolver struct{ parties []models.Party }
|
||||
func (partiesResolver) Namespace() string { return "parties" }
|
||||
func (r partiesResolver) Populate(bag PlaceholderMap) { addPartyVars(bag, r.parties) }
|
||||
|
||||
// Keys returns the flat, user-facing party forms (the power-user override
|
||||
// rows the sidebar shows). The indexed (parties.claimant.0.name) and
|
||||
// joined (parties.claimants) forms Populate also emits are not catalogue
|
||||
// entries — they're resolved into the bag but not offered in the palette.
|
||||
func (partiesResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("parties", "parties.claimant.name", "Klägerin", "Claimant"),
|
||||
vk("parties", "parties.claimant.representative", "Klägerin-Vertreter", "Claimant representative"),
|
||||
vk("parties", "parties.defendant.name", "Beklagte", "Defendant"),
|
||||
vk("parties", "parties.defendant.representative", "Beklagten-Vertreter", "Defendant representative"),
|
||||
vk("parties", "parties.other.name", "Weitere Partei", "Other party"),
|
||||
vk("parties", "parties.other.representative", "Weitere-Partei-Vertreter", "Other party representative"),
|
||||
}
|
||||
}
|
||||
|
||||
// deadlineResolver populates deadline.* from the next pending deadline.
|
||||
type deadlineResolver struct {
|
||||
deadline *models.Deadline
|
||||
@@ -85,3 +187,14 @@ func (deadlineResolver) Namespace() string { return "deadline" }
|
||||
func (r deadlineResolver) Populate(bag PlaceholderMap) {
|
||||
addDeadlineVars(bag, r.deadline, r.project, r.lang)
|
||||
}
|
||||
func (deadlineResolver) Keys() []docforge.VariableKey {
|
||||
return []docforge.VariableKey{
|
||||
vk("deadline", "deadline.due_date", "Frist (ISO)", "Deadline (ISO)"),
|
||||
vk("deadline", "deadline.due_date_long_de", "Frist (DE lang)", "Deadline (DE long)"),
|
||||
vk("deadline", "deadline.due_date_long_en", "Frist (EN lang)", "Deadline (EN long)"),
|
||||
vk("deadline", "deadline.original_due_date", "Ursprüngliche Frist", "Original deadline"),
|
||||
vk("deadline", "deadline.computed_from", "Frist berechnet aus", "Deadline computed from"),
|
||||
vk("deadline", "deadline.title", "Frist-Titel", "Deadline title"),
|
||||
vk("deadline", "deadline.source", "Frist-Quelle", "Deadline source"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,19 +40,20 @@ func NewPgTemplateStore(db *sqlx.DB) *PgTemplateStore {
|
||||
// templateMetaRow scans the catalog metadata + the current version number
|
||||
// (via LEFT JOIN, 0 when no version pinned yet).
|
||||
type templateMetaRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
Slug *string `db:"slug"`
|
||||
NameDE string `db:"name_de"`
|
||||
NameEN string `db:"name_en"`
|
||||
Kind string `db:"kind"`
|
||||
SourceFormat string `db:"source_format"`
|
||||
Firm *string `db:"firm"`
|
||||
IsActive bool `db:"is_active"`
|
||||
Version int `db:"version"`
|
||||
ID uuid.UUID `db:"id"`
|
||||
Slug *string `db:"slug"`
|
||||
NameDE string `db:"name_de"`
|
||||
NameEN string `db:"name_en"`
|
||||
Kind string `db:"kind"`
|
||||
SourceFormat string `db:"source_format"`
|
||||
Firm *string `db:"firm"`
|
||||
IsActive bool `db:"is_active"`
|
||||
Version int `db:"version"`
|
||||
VersionID *uuid.UUID `db:"version_id"`
|
||||
}
|
||||
|
||||
func (r templateMetaRow) toMeta() docforge.TemplateMeta {
|
||||
return docforge.TemplateMeta{
|
||||
m := docforge.TemplateMeta{
|
||||
ID: r.ID.String(),
|
||||
Slug: derefString(r.Slug),
|
||||
NameDE: r.NameDE,
|
||||
@@ -63,11 +64,16 @@ func (r templateMetaRow) toMeta() docforge.TemplateMeta {
|
||||
IsActive: r.IsActive,
|
||||
Version: r.Version,
|
||||
}
|
||||
if r.VersionID != nil {
|
||||
m.VersionID = r.VersionID.String()
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
const templateMetaColumns = `t.id, t.slug, t.name_de, t.name_en, t.kind,
|
||||
t.source_format, t.firm, t.is_active,
|
||||
COALESCE(v.version, 0) AS version`
|
||||
COALESCE(v.version, 0) AS version,
|
||||
v.id AS version_id`
|
||||
|
||||
const templateMetaFrom = `FROM paliad.templates t
|
||||
LEFT JOIN paliad.template_versions v
|
||||
@@ -162,6 +168,7 @@ func (s *PgTemplateStore) GetVersion(ctx context.Context, versionID string) (*do
|
||||
return nil, fmt.Errorf("get template version meta: %w", err)
|
||||
}
|
||||
tmpl := &docforge.Template{TemplateMeta: meta.toMeta(), CarrierBytes: vr.Carrier}
|
||||
tmpl.VersionID = vid.String() // the resolved version is the one requested
|
||||
tmpl.Stylemap = decodeStylemap(vr.Stylemap)
|
||||
slots, err := s.loadSlots(ctx, vid)
|
||||
if err != nil {
|
||||
|
||||
172
pkg/docforge/docx/authoring.go
Normal file
172
pkg/docforge/docx/authoring.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package docx
|
||||
|
||||
// Authoring support — the .docx side of the docforge authoring surface
|
||||
// (t-paliad-349 slice 6). Two operations back the "upload a base .docx →
|
||||
// place variable slots" flow:
|
||||
//
|
||||
// ImportForAuthoring — parse an uploaded .docx into a run-addressable
|
||||
// preview (one <span data-run="N"> per <w:t>, in document order) plus
|
||||
// the slots already present in the carrier.
|
||||
// InjectSlot — replace a selected piece of text inside run N with a
|
||||
// {{slot_key}} placeholder, returning the new carrier bytes. The
|
||||
// placeholder is the sentinel that locates the slot (PRD §5 lean) and
|
||||
// the same token the generation-time renderer substitutes.
|
||||
//
|
||||
// Both walk runs in the same order (paragraphs, then <w:t> within), so the
|
||||
// data-run indices the preview emits address exactly the runs InjectSlot
|
||||
// targets. Injection keys on the selected text
|
||||
// (not a byte/UTF-16 offset) so umlauts in German prose can't desync the
|
||||
// client's selection from the server's slice.
|
||||
//
|
||||
// v1 scope (PRD §2.1): text-level slots inside body paragraphs. A run is a
|
||||
// <w:t> within a <w:p>; selections spanning runs or sitting in
|
||||
// headers/footers/tables are out of scope and surface as an error the UI
|
||||
// turns into "select within a single text span".
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// AuthoringView is the parsed, run-addressable form of an uploaded
|
||||
// template, ready for the authoring editor.
|
||||
type AuthoringView struct {
|
||||
// PreviewHTML is the body rendered as paragraphs of run spans:
|
||||
// <p>…<span class="docforge-run" data-run="N">text</span>…</p>.
|
||||
// The client attaches selection handling to the run spans; data-run
|
||||
// is the index InjectSlot expects.
|
||||
PreviewHTML string
|
||||
// Slots are the {{placeholder}} tokens already present in the
|
||||
// carrier (so re-opening a saved template shows its slots).
|
||||
Slots []docforge.TemplateSlot
|
||||
}
|
||||
|
||||
// ImportForAuthoring parses carrierBytes (any .docx/.dotm/...) into an
|
||||
// AuthoringView. Runs the .dotm→.docx pre-pass so macro templates import
|
||||
// cleanly.
|
||||
func ImportForAuthoring(carrierBytes []byte) (*AuthoringView, error) {
|
||||
clean, err := ConvertDotmToDocx(carrierBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring import: convert: %w", err)
|
||||
}
|
||||
documentXML, _, err := splitBaseZip(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring import: %w", err)
|
||||
}
|
||||
return &AuthoringView{
|
||||
PreviewHTML: authoringPreviewHTML(documentXML),
|
||||
Slots: detectSlots(documentXML),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// authoringPreviewHTML renders the body as run-addressable HTML. One <p>
|
||||
// per <w:p>; one <span class="docforge-run" data-run="N"> per <w:t>, with
|
||||
// the decoded run text HTML-escaped. N is the global run index in
|
||||
// document-then-paragraph order — the same order InjectSlot walks.
|
||||
func authoringPreviewHTML(documentXML []byte) string {
|
||||
var out bytes.Buffer
|
||||
runIdx := 0
|
||||
paras := wParagraphRegex.FindAll(documentXML, -1)
|
||||
for _, para := range paras {
|
||||
out.WriteString("<p>")
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(para, -1) {
|
||||
text := xmlDecode(string(m[2]))
|
||||
out.WriteString(`<span class="docforge-run" data-run="`)
|
||||
out.WriteString(strconv.Itoa(runIdx))
|
||||
out.WriteString(`">`)
|
||||
out.WriteString(htmlEscape(text))
|
||||
out.WriteString(`</span>`)
|
||||
runIdx++
|
||||
}
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return "<p></p>"
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// detectSlots returns the distinct {{placeholder}} tokens present in the
|
||||
// document body, in first-appearance order.
|
||||
func detectSlots(documentXML []byte) []docforge.TemplateSlot {
|
||||
seen := map[string]bool{}
|
||||
var slots []docforge.TemplateSlot
|
||||
// Match against decoded text so a placeholder split by an entity is
|
||||
// still found the same way the renderer would substitute it.
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(documentXML, -1) {
|
||||
text := xmlDecode(string(m[2]))
|
||||
for _, pm := range placeholderRegex.FindAllStringSubmatch(text, -1) {
|
||||
key := pm[1]
|
||||
if seen[key] {
|
||||
continue
|
||||
}
|
||||
seen[key] = true
|
||||
slots = append(slots, docforge.TemplateSlot{
|
||||
Key: key,
|
||||
Anchor: "{{" + key + "}}",
|
||||
OrderIndex: len(slots),
|
||||
})
|
||||
}
|
||||
}
|
||||
return slots
|
||||
}
|
||||
|
||||
// InjectSlot replaces the first occurrence of selectedText inside run
|
||||
// runIndex with a {{slotKey}} placeholder and returns the new carrier
|
||||
// bytes. Errors when the run is out of range or selectedText isn't found
|
||||
// in that run (a render/selection desync, or a cross-run selection).
|
||||
func InjectSlot(carrierBytes []byte, runIndex int, selectedText, slotKey string) ([]byte, error) {
|
||||
if selectedText == "" {
|
||||
return nil, fmt.Errorf("authoring inject: empty selection")
|
||||
}
|
||||
if !placeholderRegex.MatchString("{{" + slotKey + "}}") {
|
||||
return nil, fmt.Errorf("authoring inject: invalid slot key %q", slotKey)
|
||||
}
|
||||
clean, err := ConvertDotmToDocx(carrierBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring inject: convert: %w", err)
|
||||
}
|
||||
documentXML, parts, err := splitBaseZip(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring inject: %w", err)
|
||||
}
|
||||
|
||||
runIdx := 0
|
||||
injected := false
|
||||
newDoc := wParagraphRegex.ReplaceAllFunc(documentXML, func(para []byte) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(para, func(tnode []byte) []byte {
|
||||
idx := runIdx
|
||||
runIdx++
|
||||
if injected || idx != runIndex {
|
||||
return tnode
|
||||
}
|
||||
sub := wTextNodeRegex.FindSubmatch(tnode)
|
||||
attrs := string(sub[1])
|
||||
content := xmlDecode(string(sub[2]))
|
||||
before, after, found := strings.Cut(content, selectedText)
|
||||
if !found {
|
||||
return tnode // not found here — reported after the walk
|
||||
}
|
||||
newContent := before + "{{" + slotKey + "}}" + after
|
||||
if !strings.Contains(attrs, "xml:space") &&
|
||||
(strings.HasPrefix(newContent, " ") || strings.HasSuffix(newContent, " ")) {
|
||||
attrs += ` xml:space="preserve"`
|
||||
}
|
||||
injected = true
|
||||
return []byte(`<w:t` + attrs + `>` + xmlEncode(newContent) + `</w:t>`)
|
||||
})
|
||||
})
|
||||
if !injected {
|
||||
return nil, fmt.Errorf("authoring inject: selection %q not found in run %d", selectedText, runIndex)
|
||||
}
|
||||
|
||||
repacked, err := repackBaseZip(parts, newDoc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authoring inject: %w", err)
|
||||
}
|
||||
return repacked, nil
|
||||
}
|
||||
111
pkg/docforge/docx/authoring_test.go
Normal file
111
pkg/docforge/docx/authoring_test.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package docx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// docBody wraps a <w:body> inner string into a full document.xml.
|
||||
func docBody(inner string) string {
|
||||
return `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||
`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">` +
|
||||
`<w:body>` + inner + `</w:body></w:document>`
|
||||
}
|
||||
|
||||
func TestImportForAuthoring_PreviewIsRunAddressable(t *testing.T) {
|
||||
body := docBody(
|
||||
`<w:p><w:r><w:t>Az. 4c O 12/23</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:t>Klägerin</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
|
||||
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("ImportForAuthoring: %v", err)
|
||||
}
|
||||
// Three <w:t> → three run spans, indexed 0,1,2 in document order.
|
||||
for i, want := range []string{`data-run="0"`, `data-run="1"`, `data-run="2"`} {
|
||||
if !strings.Contains(view.PreviewHTML, want) {
|
||||
t.Errorf("preview missing %s (run %d); html=%s", want, i, view.PreviewHTML)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(view.PreviewHTML, "Az. 4c O 12/23") {
|
||||
t.Errorf("preview missing run text; html=%s", view.PreviewHTML)
|
||||
}
|
||||
// Two paragraphs.
|
||||
if n := strings.Count(view.PreviewHTML, "<p>"); n != 2 {
|
||||
t.Errorf("paragraph count = %d; want 2", n)
|
||||
}
|
||||
if len(view.Slots) != 0 {
|
||||
t.Errorf("fresh doc should have no slots; got %v", view.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportForAuthoring_DetectsExistingSlots(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Az. {{project.case_number}} vor {{project.court}}</w:t></w:r></w:p>`)
|
||||
view, err := ImportForAuthoring(minimalMergeDOCX(t, body))
|
||||
if err != nil {
|
||||
t.Fatalf("ImportForAuthoring: %v", err)
|
||||
}
|
||||
if len(view.Slots) != 2 {
|
||||
t.Fatalf("slots = %d; want 2 (%v)", len(view.Slots), view.Slots)
|
||||
}
|
||||
if view.Slots[0].Key != "project.case_number" || view.Slots[0].Anchor != "{{project.case_number}}" {
|
||||
t.Errorf("slot[0] = %+v; want project.case_number", view.Slots[0])
|
||||
}
|
||||
if view.Slots[1].Key != "project.court" {
|
||||
t.Errorf("slot[1].Key = %q; want project.court", view.Slots[1].Key)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_ReplacesSelectionWithPlaceholder(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Az. 4c O 12/23 vor dem LG</w:t></w:r></w:p>`)
|
||||
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "4c O 12/23", "project.case_number")
|
||||
if err != nil {
|
||||
t.Fatalf("InjectSlot: %v", err)
|
||||
}
|
||||
doc := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(doc, "Az. {{project.case_number}} vor dem LG") {
|
||||
t.Errorf("injected doc wrong; got %s", doc)
|
||||
}
|
||||
// Round-trips: re-importing finds the new slot.
|
||||
view, err := ImportForAuthoring(out)
|
||||
if err != nil {
|
||||
t.Fatalf("re-import: %v", err)
|
||||
}
|
||||
if len(view.Slots) != 1 || view.Slots[0].Key != "project.case_number" {
|
||||
t.Errorf("re-imported slots = %v; want [project.case_number]", view.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_TargetsTheNamedRun(t *testing.T) {
|
||||
// "GmbH" appears in run 1 only; "Müller" (with umlaut) in run 0.
|
||||
body := docBody(
|
||||
`<w:p><w:r><w:t>Müller</w:t></w:r><w:r><w:t xml:space="preserve"> GmbH</w:t></w:r></w:p>`)
|
||||
out, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Müller", "parties.claimant.name")
|
||||
if err != nil {
|
||||
t.Fatalf("InjectSlot: %v", err)
|
||||
}
|
||||
doc := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(doc, "{{parties.claimant.name}}") {
|
||||
t.Errorf("umlaut selection not replaced; got %s", doc)
|
||||
}
|
||||
if !strings.Contains(doc, " GmbH") {
|
||||
t.Errorf("run 1 should be untouched; got %s", doc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_ErrorsWhenSelectionNotInRun(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
|
||||
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Goodbye", "firm.name"); err == nil {
|
||||
t.Error("expected error when selection absent from run; got nil")
|
||||
}
|
||||
// Out-of-range run index.
|
||||
if _, err := InjectSlot(minimalMergeDOCX(t, body), 9, "Hello", "firm.name"); err == nil {
|
||||
t.Error("expected error for out-of-range run index; got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInjectSlot_RejectsInvalidSlotKey(t *testing.T) {
|
||||
body := docBody(`<w:p><w:r><w:t>Hello</w:t></w:r></w:p>`)
|
||||
if _, err := InjectSlot(minimalMergeDOCX(t, body), 0, "Hello", "9bad-key!"); err == nil {
|
||||
t.Error("expected error for invalid slot key; got nil")
|
||||
}
|
||||
}
|
||||
@@ -240,7 +240,7 @@ var anchorKeyRegex = regexp.MustCompile(`^[A-Za-z0-9_]+$`)
|
||||
// the body — from the start of the opening anchor's <w:p> element
|
||||
// through the end of the closing anchor's </w:p>.
|
||||
type anchorPair struct {
|
||||
key string
|
||||
key string
|
||||
openStart int // start of <w:p> for the opening anchor
|
||||
closeEnd int // index just past </w:p> for the closing anchor
|
||||
}
|
||||
@@ -251,10 +251,10 @@ type anchorPair struct {
|
||||
// span is non-overlapping.
|
||||
func findAllAnchorPairs(body string) []anchorPair {
|
||||
type marker struct {
|
||||
key string
|
||||
key string
|
||||
paraStart int
|
||||
paraEnd int
|
||||
isOpen bool
|
||||
isOpen bool
|
||||
}
|
||||
var markers []marker
|
||||
|
||||
|
||||
@@ -185,7 +185,12 @@ func SanitiseSubmissionFileName(s string) string {
|
||||
s = umlautFolder.Replace(s)
|
||||
s = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\':
|
||||
// Path separators and the rest of the Windows-reserved set —
|
||||
// fold to underscore so a case number like "UPC_CFI_123/2026"
|
||||
// stays one filesystem-safe segment. Spaces and parentheses are
|
||||
// intentionally preserved: the human-facing download name
|
||||
// "<date> <keyword> (<case>)" relies on them (t-paliad-354).
|
||||
case '/', '\\', ':', '*', '?', '<', '>', '|':
|
||||
return '_'
|
||||
case '"', '\'':
|
||||
return -1
|
||||
|
||||
@@ -241,9 +241,12 @@ func TestSanitiseSubmissionFileName(t *testing.T) {
|
||||
"Klageerwiderung": "Klageerwiderung",
|
||||
"Berufungsbegründung": "Berufungsbegruendung",
|
||||
"Schriftsatz/Anlage": "Schriftsatz_Anlage",
|
||||
`Statement of "Defence"`: "Statement of Defence",
|
||||
` Klage `: "Klage",
|
||||
"Größe": "Groesse",
|
||||
`Statement of "Defence"`: "Statement of Defence",
|
||||
` Klage `: "Klage",
|
||||
"Größe": "Groesse",
|
||||
"UPC_CFI_123/2026": "UPC_CFI_123_2026",
|
||||
"a:b*c?d<e>f|g": "a_b_c_d_e_f_g",
|
||||
"Klageerwiderung (Frist)": "Klageerwiderung (Frist)",
|
||||
}
|
||||
for in, want := range cases {
|
||||
t.Run(in, func(t *testing.T) {
|
||||
|
||||
39
pkg/docforge/docx/exporter.go
Normal file
39
pkg/docforge/docx/exporter.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package docx
|
||||
|
||||
import "mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
|
||||
// Exporter is the .docx implementation of docforge.Exporter — it renders a
|
||||
// neutral Document to OOXML body markup (t-paliad-349 slice 8). The
|
||||
// stylemap (block kind → Word paragraph style) and the optional hyperlink
|
||||
// allocator are baked in at construction, so RenderBody matches the
|
||||
// interface's format-neutral signature.
|
||||
//
|
||||
// This is the seam a future PDF/HTML exporter slots into: implement
|
||||
// docforge.Exporter, no engine change. The submission composer can render
|
||||
// section content through this exporter instead of calling
|
||||
// RenderDocumentToOOXML directly once a second format exists.
|
||||
type Exporter struct {
|
||||
Stylemap map[string]string
|
||||
Links HyperlinkAllocator
|
||||
}
|
||||
|
||||
// compile-time conformance.
|
||||
var _ docforge.Exporter = Exporter{}
|
||||
|
||||
// NewExporter builds a .docx exporter with the given stylemap + allocator.
|
||||
func NewExporter(stylemap map[string]string, links HyperlinkAllocator) Exporter {
|
||||
return Exporter{Stylemap: stylemap, Links: links}
|
||||
}
|
||||
|
||||
// Format returns the format id.
|
||||
func (Exporter) Format() string { return "docx" }
|
||||
|
||||
// MIMEType returns the .docx container MIME type.
|
||||
func (Exporter) MIMEType() string {
|
||||
return "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
||||
}
|
||||
|
||||
// RenderBody renders the Document to OOXML paragraph markup.
|
||||
func (e Exporter) RenderBody(doc docforge.Document) ([]byte, error) {
|
||||
return []byte(RenderDocumentToOOXML(doc, e.Stylemap, e.Links)), nil
|
||||
}
|
||||
34
pkg/docforge/docx/exporter_test.go
Normal file
34
pkg/docforge/docx/exporter_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package docx
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/markdown"
|
||||
)
|
||||
|
||||
func TestExporter_RenderBodyMatchesWalker(t *testing.T) {
|
||||
exp := NewExporter(map[string]string{"paragraph": "Body"}, nil)
|
||||
if exp.Format() != "docx" {
|
||||
t.Errorf("Format = %q; want docx", exp.Format())
|
||||
}
|
||||
if !strings.Contains(exp.MIMEType(), "wordprocessingml.document") {
|
||||
t.Errorf("MIMEType = %q", exp.MIMEType())
|
||||
}
|
||||
|
||||
md := "Hello **world**\n\n- item"
|
||||
// The Exporter must produce exactly what the walker entry point does
|
||||
// for the same input (both go markdown.Import → RenderDocumentToOOXML).
|
||||
body, err := exp.RenderBody(markdown.Import(md))
|
||||
if err != nil {
|
||||
t.Fatalf("RenderBody: %v", err)
|
||||
}
|
||||
want := RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": "Body"}, nil)
|
||||
if string(body) != want {
|
||||
t.Errorf("RenderBody mismatch:\n got %q\nwant %q", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// satisfies the interface (compile-time check mirrored at runtime).
|
||||
var _ docforge.Exporter = Exporter{}
|
||||
@@ -1,249 +1,78 @@
|
||||
package docx
|
||||
|
||||
// Markdown → OOXML walker for Composer section content (t-paliad-313
|
||||
// Slice B, design doc §9.2).
|
||||
// Markdown → OOXML rendering for Composer section content (t-paliad-313
|
||||
// Slice B/D; restructured in t-paliad-349 slice 8).
|
||||
//
|
||||
// Scope per the head's Slice B brief: paragraphs + inline bold/italic
|
||||
// only. Headings, lists, blockquote, links land in Slice D's rich-prose
|
||||
// pass. This walker is intentionally minimal — every Markdown construct
|
||||
// it doesn't recognise is rendered as a plain paragraph so the lawyer's
|
||||
// prose round-trips losslessly even when they hit Markdown the walker
|
||||
// doesn't yet understand.
|
||||
// Parsing now lives in pkg/docforge/markdown, which produces the neutral
|
||||
// docforge.Document. This file renders that Document into OOXML paragraph
|
||||
// elements (<w:p>…</w:p>) ready to splice into a .docx body. There is one
|
||||
// Markdown parser for docforge; this is the .docx exporter for its model.
|
||||
//
|
||||
// The output uses the base's stylemap.paragraph entry for the
|
||||
// <w:pStyle> on each paragraph so the styling matches the base's
|
||||
// typography (HLpat-Body-B0 on the HLC base, Normal on the neutral
|
||||
// base, etc.).
|
||||
//
|
||||
// Placeholders ({{path.dot.notation}}) are preserved verbatim — they
|
||||
// pass through the walker untouched and get substituted by the v1
|
||||
// SubmissionRenderer's placeholder pass after the composer assembly.
|
||||
//
|
||||
// Grammar supported:
|
||||
//
|
||||
// - Blank line → paragraph break
|
||||
// - `**bold**` → <w:r><w:rPr><w:b/></w:rPr><w:t>…</w:t></w:r>
|
||||
// - `*italic*` or `_italic_` → <w:r><w:rPr><w:i/></w:rPr>…</w:r>
|
||||
// - Otherwise → plain text run
|
||||
// Output uses the base's stylemap entry for each block kind on the
|
||||
// <w:pStyle>, so styling matches the base's typography (HLpat-Body-B0 on
|
||||
// the HLC base, Normal on the neutral base, etc.). Placeholders ({{key}})
|
||||
// ride through as literal run text and are substituted by the placeholder
|
||||
// pass after assembly.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/markdown"
|
||||
)
|
||||
|
||||
// HyperlinkAllocator hands the walker a `rId` for each external URL
|
||||
// it encounters in `[label](url)` inline links. The composer's
|
||||
// post-pass uses these allocations to mutate
|
||||
// `word/_rels/document.xml.rels` so the emitted `<w:hyperlink
|
||||
// r:id="…">` elements resolve correctly. Pass nil to drop links to
|
||||
// plain text (the label survives, the URL doesn't render).
|
||||
//
|
||||
// t-paliad-316 Slice D.
|
||||
// HyperlinkAllocator hands the renderer a `rId` for each external URL it
|
||||
// encounters in `[label](url)` inline links. The composer's post-pass uses
|
||||
// these allocations to mutate `word/_rels/document.xml.rels` so the emitted
|
||||
// `<w:hyperlink r:id="…">` elements resolve. Pass nil to drop links to
|
||||
// plain text (the label survives, the URL doesn't render). t-paliad-316.
|
||||
type HyperlinkAllocator func(url string) string
|
||||
|
||||
// RenderMarkdownToOOXML renders the given Markdown source into OOXML
|
||||
// paragraph elements (`<w:p>…</w:p>`), suitable for splicing into a
|
||||
// .docx body. Each paragraph carries `<w:pStyle w:val="<paragraphStyle>"/>`
|
||||
// when paragraphStyle is non-empty.
|
||||
//
|
||||
// Slice B shipped paragraphs + bold/italic. Slice D extends to
|
||||
// headings (h1/h2/h3), bullet/numbered lists, blockquote, and inline
|
||||
// hyperlinks via the optional HyperlinkAllocator.
|
||||
//
|
||||
// stylemap supplies the paragraph-style names for each kind:
|
||||
// stylemap["paragraph"] — default body
|
||||
// stylemap["heading_1/2/3"] — heading levels
|
||||
// stylemap["list_bullet"] — bullet list paragraph style
|
||||
// stylemap["list_numbered"] — numbered list paragraph style
|
||||
// stylemap["blockquote"] — blockquote
|
||||
// Missing entries fall back to the "paragraph" style.
|
||||
//
|
||||
// Empty input renders one empty paragraph so the splice site is
|
||||
// well-formed even when the lawyer hasn't typed anything in this
|
||||
// section.
|
||||
// RenderMarkdownToOOXML renders Markdown into OOXML paragraphs with a
|
||||
// single paragraph style. Slice B back-compat wrapper.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return RenderMarkdownToOOXMLWithStyles(md, map[string]string{"paragraph": paragraphStyle}, nil)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full Slice-D-aware entry
|
||||
// point. Slice B's RenderMarkdownToOOXML is a wrapper for back-compat.
|
||||
// RenderMarkdownToOOXMLWithStyles parses Markdown into a docforge.Document
|
||||
// and renders it to OOXML. stylemap maps each block kind (paragraph,
|
||||
// heading_1/2/3, list_bullet, list_numbered, blockquote) to a Word
|
||||
// paragraph style; missing entries fall back to the "paragraph" style.
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
return RenderDocumentToOOXML(markdown.Import(md), stylemap, links)
|
||||
}
|
||||
|
||||
// RenderDocumentToOOXML renders a neutral Document to OOXML paragraphs —
|
||||
// the .docx side of the docforge importer→model→exporter pipeline. Any
|
||||
// Document (Markdown today, a foreign-doc importer later) renders the same
|
||||
// way.
|
||||
func RenderDocumentToOOXML(doc docforge.Document, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
defaultStyle := stylemap["paragraph"]
|
||||
if md == "" {
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
blocks := splitMarkdownBlocks(md)
|
||||
if len(blocks) == 0 {
|
||||
return emptyParagraph(defaultStyle)
|
||||
}
|
||||
// Numbered-list counter resets on every non-numbered block so
|
||||
// "1. A\n2. B\n\n1. C" renders as 1./2./1. (the lawyer's input
|
||||
// determined the ordinal, the walker just renders).
|
||||
numberedCounter := 0
|
||||
// "1. A\n2. B\n\n1. C" renders 1./2./1. — the input determined the
|
||||
// ordinal, the renderer just emits it.
|
||||
numbered := 0
|
||||
var b strings.Builder
|
||||
for _, blk := range blocks {
|
||||
style := stylemap[blk.styleKey]
|
||||
for _, blk := range doc.Blocks {
|
||||
style := stylemap[string(blk.Kind)]
|
||||
if style == "" {
|
||||
style = defaultStyle
|
||||
}
|
||||
if blk.styleKey == "list_numbered" {
|
||||
numberedCounter++
|
||||
if blk.Kind == docforge.KindListNumbered {
|
||||
numbered++
|
||||
} else {
|
||||
numberedCounter = 0
|
||||
numbered = 0
|
||||
}
|
||||
b.WriteString(renderBlockParagraph(blk, style, links, numberedCounter))
|
||||
b.WriteString(renderBlock(blk, style, links, numbered))
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// mdBlock is one rendered paragraph: a kind (paragraph / heading_*
|
||||
// / list_bullet / list_numbered / blockquote) and the inline content
|
||||
// text. List markers, heading hashes, blockquote `> ` etc. are
|
||||
// stripped from the text before storage.
|
||||
type mdBlock struct {
|
||||
styleKey string // "paragraph" | "heading_1" | "heading_2" | "heading_3" | "list_bullet" | "list_numbered" | "blockquote"
|
||||
text string
|
||||
}
|
||||
|
||||
// splitMarkdownBlocks parses the source into a sequence of blocks,
|
||||
// detecting heading / list / blockquote prefixes line-by-line. Blank
|
||||
// lines split paragraph runs (same semantics as splitMarkdownParagraphs)
|
||||
// but each line is also tagged with its block kind.
|
||||
//
|
||||
// Lines that look like block markers don't merge with their neighbours
|
||||
// even across blank lines — every list / heading / blockquote line is
|
||||
// its own block in the output. A run of unmarked lines collapses into
|
||||
// one "paragraph" block (so soft line breaks inside a paragraph still
|
||||
// concatenate).
|
||||
//
|
||||
// CRLF normalised to LF before parsing.
|
||||
func splitMarkdownBlocks(md string) []mdBlock {
|
||||
normalised := strings.ReplaceAll(md, "\r\n", "\n")
|
||||
lines := strings.Split(normalised, "\n")
|
||||
var blocks []mdBlock
|
||||
var pendingPara []string
|
||||
blankRun := 0
|
||||
|
||||
flushPara := func() {
|
||||
if len(pendingPara) > 0 {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: strings.Join(pendingPara, "\n")})
|
||||
pendingPara = nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, raw := range lines {
|
||||
line := raw
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(pendingPara) > 0 {
|
||||
flushPara()
|
||||
blankRun = 1
|
||||
continue
|
||||
}
|
||||
blankRun++
|
||||
continue
|
||||
}
|
||||
// Detect heading / list / blockquote markers BEFORE we accumulate
|
||||
// into the paragraph buffer.
|
||||
kind, payload, ok := detectBlockMarker(line)
|
||||
if ok {
|
||||
flushPara()
|
||||
// Emit spacing paragraphs equivalent to (blankRun - 1) extra.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
blankRun = 0
|
||||
blocks = append(blocks, mdBlock{styleKey: kind, text: payload})
|
||||
continue
|
||||
}
|
||||
// Plain paragraph line.
|
||||
if len(pendingPara) == 0 {
|
||||
// Starting a new paragraph after a blank run — emit
|
||||
// (blankRun-1) extra empty paragraphs for vertical spacing.
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, mdBlock{styleKey: "paragraph", text: ""})
|
||||
}
|
||||
}
|
||||
blankRun = 0
|
||||
pendingPara = append(pendingPara, line)
|
||||
}
|
||||
flushPara()
|
||||
return blocks
|
||||
}
|
||||
|
||||
// detectBlockMarker classifies a single line. Returns (styleKey,
|
||||
// payload-with-marker-stripped, true) for recognised markers; false
|
||||
// for plain paragraph lines.
|
||||
//
|
||||
// Recognised markers (Slice D):
|
||||
// # Heading → heading_1
|
||||
// ## Heading → heading_2
|
||||
// ### Heading → heading_3
|
||||
// - item / * item → list_bullet
|
||||
// 1. item / 2. item ... → list_numbered (any positive integer)
|
||||
// > quote → blockquote
|
||||
//
|
||||
// Leading whitespace inside the line is tolerated up to 3 spaces (per
|
||||
// CommonMark) so the lawyer's contentEditable indentation doesn't
|
||||
// hide the marker.
|
||||
func detectBlockMarker(line string) (string, string, bool) {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
// Cap to 3 spaces of leading indent — beyond that, treat as a
|
||||
// regular paragraph line (matches CommonMark).
|
||||
if len(line)-len(trimmed) > 3 {
|
||||
return "", "", false
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "### ") {
|
||||
return "heading_3", strings.TrimSpace(trimmed[4:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "## ") {
|
||||
return "heading_2", strings.TrimSpace(trimmed[3:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "# ") {
|
||||
return "heading_1", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "> ") {
|
||||
return "blockquote", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
|
||||
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
// Numbered: "N. " where N is one or more digits.
|
||||
if i := indexOfNumberedMarker(trimmed); i > 0 {
|
||||
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// indexOfNumberedMarker checks for "N. " or "N) " at the start of the
|
||||
// trimmed line; returns the byte index just past the marker, or -1 if
|
||||
// no marker present.
|
||||
func indexOfNumberedMarker(s string) int {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 {
|
||||
return -1
|
||||
}
|
||||
if i >= len(s) {
|
||||
return -1
|
||||
}
|
||||
if s[i] != '.' && s[i] != ')' {
|
||||
return -1
|
||||
}
|
||||
if i+1 >= len(s) || s[i+1] != ' ' {
|
||||
return -1
|
||||
}
|
||||
return i + 2
|
||||
}
|
||||
|
||||
// renderBlockParagraph emits one `<w:p>` for a block. List blocks
|
||||
// keep the same paragraph style as a default paragraph (the Slice D
|
||||
// design's contract — list styles come from the base's stylemap and
|
||||
// Word's numbering.xml is honoured by adding a leading bullet/number
|
||||
// prefix in the rendered text). This keeps the composer free of
|
||||
// numbering.xml mutations.
|
||||
func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
|
||||
// renderBlock emits one <w:p> for a block. List blocks get a visible
|
||||
// "• " / "N. " prefix run (the base stylemap handles indentation if it
|
||||
// defines a list style; the prefix at least surfaces the structure).
|
||||
func renderBlock(blk docforge.Block, paragraphStyle string, links HyperlinkAllocator, numberedOrdinal int) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
@@ -251,110 +80,61 @@ func renderBlockParagraph(blk mdBlock, paragraphStyle string, links HyperlinkAll
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
if blk.text == "" {
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r>`)
|
||||
b.WriteString(`</w:p>`)
|
||||
// An empty block is an intentional empty paragraph: one empty run.
|
||||
if len(blk.Spans) == 0 {
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
text := blk.text
|
||||
// List blocks emit a visible "• " / "N. " prefix run. The
|
||||
// stylemap entry handles paragraph indentation if the base
|
||||
// defines a list paragraph style; otherwise the prefix at least
|
||||
// surfaces the structure in plain Word. Lawyers who want Word's
|
||||
// auto-numbering reapply a list style post-export.
|
||||
switch blk.styleKey {
|
||||
case "list_bullet":
|
||||
switch blk.Kind {
|
||||
case docforge.KindListBullet:
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">• </w:t></w:r>`)
|
||||
case "list_numbered":
|
||||
case docforge.KindListNumbered:
|
||||
ordinal := numberedOrdinal
|
||||
if ordinal <= 0 {
|
||||
ordinal = 1
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve">`)
|
||||
b.WriteString(fmt.Sprintf("%d. ", ordinal))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
b.WriteString(strconv.Itoa(ordinal))
|
||||
b.WriteString(`. </w:t></w:r>`)
|
||||
}
|
||||
for _, run := range parseInlineRuns(text, links) {
|
||||
b.WriteString(run)
|
||||
for _, span := range blk.Spans {
|
||||
b.WriteString(renderInlineSpan(span, links))
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// parseInlineRuns extracts inline spans + hyperlink runs and serialises
|
||||
// each to OOXML. Hyperlinks become `<w:hyperlink r:id="RID">…runs…</w:hyperlink>`
|
||||
// where RID comes from the HyperlinkAllocator.
|
||||
func parseInlineRuns(text string, links HyperlinkAllocator) []string {
|
||||
// Phase 1: find all hyperlink spans `[label](url)` and split the
|
||||
// text around them.
|
||||
type segment struct {
|
||||
text string
|
||||
isLink bool
|
||||
url string
|
||||
}
|
||||
var segs []segment
|
||||
rest := text
|
||||
for {
|
||||
idx := strings.Index(rest, "[")
|
||||
if idx < 0 {
|
||||
if rest != "" {
|
||||
segs = append(segs, segment{text: rest})
|
||||
}
|
||||
break
|
||||
}
|
||||
// Find matching closing bracket, then a "(" right after.
|
||||
closeBracket := strings.Index(rest[idx:], "](")
|
||||
if closeBracket < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
closeParen := strings.Index(rest[idx+closeBracket:], ")")
|
||||
if closeParen < 0 {
|
||||
segs = append(segs, segment{text: rest})
|
||||
break
|
||||
}
|
||||
// idx = start of "["
|
||||
// idx+closeBracket = position of "]"
|
||||
// idx+closeBracket+1 = position of "("
|
||||
// idx+closeBracket+closeParen = position of ")"
|
||||
label := rest[idx+1 : idx+closeBracket]
|
||||
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
|
||||
if idx > 0 {
|
||||
segs = append(segs, segment{text: rest[:idx]})
|
||||
}
|
||||
segs = append(segs, segment{text: label, isLink: true, url: url})
|
||||
rest = rest[idx+closeBracket+closeParen+1:]
|
||||
}
|
||||
|
||||
var runs []string
|
||||
for _, seg := range segs {
|
||||
if seg.isLink && links != nil {
|
||||
rid := links(seg.url)
|
||||
if rid != "" {
|
||||
// renderInlineSpan emits one span. A hyperlink span (Link != "") becomes a
|
||||
// <w:hyperlink r:id="…"> wrapping its children when an allocator yields a
|
||||
// rId; otherwise the label children render as plain runs (URL dropped).
|
||||
func renderInlineSpan(span docforge.InlineSpan, links HyperlinkAllocator) string {
|
||||
if span.Link != "" {
|
||||
if links != nil {
|
||||
if rid := links(span.Link); rid != "" {
|
||||
var hb strings.Builder
|
||||
hb.WriteString(`<w:hyperlink r:id="`)
|
||||
hb.WriteString(xmlAttrEscape(rid))
|
||||
hb.WriteString(`">`)
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
hb.WriteString(renderRunWithLinkStyle(span))
|
||||
for _, child := range span.Children {
|
||||
hb.WriteString(renderRunWithLinkStyle(child))
|
||||
}
|
||||
hb.WriteString(`</w:hyperlink>`)
|
||||
runs = append(runs, hb.String())
|
||||
continue
|
||||
return hb.String()
|
||||
}
|
||||
}
|
||||
for _, span := range parseInlineSpans(seg.text) {
|
||||
runs = append(runs, renderRun(span))
|
||||
// No allocator / no rId — render the label as plain runs.
|
||||
var fb strings.Builder
|
||||
for _, child := range span.Children {
|
||||
fb.WriteString(renderRun(child))
|
||||
}
|
||||
return fb.String()
|
||||
}
|
||||
return runs
|
||||
return renderRun(span)
|
||||
}
|
||||
|
||||
// renderRunWithLinkStyle emits a hyperlink child run. Same B/I support
|
||||
// as renderRun, but additionally tags the run with the "Hyperlink"
|
||||
// character style (Word's built-in) so the link renders in the
|
||||
// document's hyperlink colour + underline.
|
||||
func renderRunWithLinkStyle(span inlineSpan) string {
|
||||
// renderRunWithLinkStyle emits a hyperlink child run with Word's built-in
|
||||
// "Hyperlink" character style (colour + underline), plus B/I.
|
||||
func renderRunWithLinkStyle(span docforge.InlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r><w:rPr><w:rStyle w:val="Hyperlink"/>`)
|
||||
if span.Bold {
|
||||
@@ -369,85 +149,8 @@ func renderRunWithLinkStyle(span inlineSpan) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// inlineSpan is one piece of inline content: a text payload plus
|
||||
// formatting flags. Bold and italic are independent — `***both***`
|
||||
// produces one span with both flags set.
|
||||
type inlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
}
|
||||
|
||||
// parseInlineSpans tokenises Markdown inline formatting into runs of
|
||||
// (text, bold, italic). The grammar is intentionally narrow:
|
||||
//
|
||||
// - `**…**` → bold
|
||||
// - `__…__` → bold (Markdown alternate)
|
||||
// - `*…*` → italic
|
||||
// - `_…_` → italic (Markdown alternate)
|
||||
// - Anything else flows through as plain text.
|
||||
//
|
||||
// Unbalanced delimiters fall through as literal characters — the
|
||||
// walker never errors on malformed Markdown. Nested formatting (e.g.
|
||||
// `**bold *bold-italic* bold**`) toggles flags as it walks.
|
||||
func parseInlineSpans(text string) []inlineSpan {
|
||||
var out []inlineSpan
|
||||
var cur strings.Builder
|
||||
bold := false
|
||||
italic := false
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, inlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
|
||||
cur.Reset()
|
||||
}
|
||||
i := 0
|
||||
n := len(text)
|
||||
for i < n {
|
||||
// Preserve {{...}} placeholders verbatim. Underscores and
|
||||
// other Markdown-significant chars inside a placeholder key
|
||||
// (e.g. {{project.case_number}}) must not be interpreted as
|
||||
// bold/italic delimiters — otherwise the key gets stripped of
|
||||
// its underscores and the v1 placeholder pass looks up the
|
||||
// wrong key, surfacing [KEIN WERT: project.casenumber] in the
|
||||
// preview.
|
||||
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
|
||||
rel := strings.Index(text[i+2:], "}}")
|
||||
if rel >= 0 {
|
||||
end := i + 2 + rel + 2
|
||||
cur.WriteString(text[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
// Unmatched {{ — fall through to plain character handling.
|
||||
}
|
||||
// Bold delimiters first (longer match wins over italic).
|
||||
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
|
||||
flush()
|
||||
bold = !bold
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if text[i] == '*' || text[i] == '_' {
|
||||
flush()
|
||||
italic = !italic
|
||||
i++
|
||||
continue
|
||||
}
|
||||
cur.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
if len(out) == 0 {
|
||||
out = append(out, inlineSpan{Text: ""})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// renderRun emits one `<w:r>` element for an inline span. Empty text
|
||||
// spans render as empty runs (Word accepts them; they're harmless).
|
||||
func renderRun(span inlineSpan) string {
|
||||
// renderRun emits one <w:r> for a plain (text/bold/italic) span.
|
||||
func renderRun(span docforge.InlineSpan) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:r>`)
|
||||
if span.Bold || span.Italic {
|
||||
@@ -466,34 +169,16 @@ func renderRun(span inlineSpan) string {
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// emptyParagraph returns one empty `<w:p>` with the given style. Used
|
||||
// when a section's content_md is empty so the splice site stays
|
||||
// well-formed.
|
||||
func emptyParagraph(paragraphStyle string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<w:p>`)
|
||||
if paragraphStyle != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(xmlAttrEscape(paragraphStyle))
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
b.WriteString(`<w:r><w:t xml:space="preserve"></w:t></w:r></w:p>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// xmlTextEscape escapes the five XML-significant characters for safe
|
||||
// insertion into <w:t> content. & first to avoid double-encoding.
|
||||
// xmlTextEscape escapes the XML-significant characters for <w:t> content.
|
||||
// Quotes/apostrophes are legal in element text — not escaped.
|
||||
func xmlTextEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
// Quotes and apostrophes are legal inside element text content;
|
||||
// no need to escape them here.
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlAttrEscape escapes for safe insertion into an attribute value
|
||||
// (e.g. `<w:pStyle w:val="…"/>`).
|
||||
// xmlAttrEscape escapes for an attribute value (e.g. <w:pStyle w:val="…"/>).
|
||||
func xmlAttrEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
|
||||
@@ -112,46 +112,6 @@ func TestRenderMarkdownToOOXML_PlaceholderUnderscoresPreserved(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
|
||||
// Direct guard on the inline scanner. {{project.case_number}} must
|
||||
// emit as a single non-italic span containing the full placeholder.
|
||||
spans := parseInlineSpans("{{project.case_number}}")
|
||||
if len(spans) != 1 {
|
||||
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
|
||||
}
|
||||
if spans[0].Italic || spans[0].Bold {
|
||||
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
|
||||
}
|
||||
if spans[0].Text != "{{project.case_number}}" {
|
||||
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_ItalicAroundPlaceholder(t *testing.T) {
|
||||
// Italic delimiters outside a placeholder still work; the placeholder
|
||||
// itself stays literal even when it sits between italics.
|
||||
spans := parseInlineSpans("_before_ {{x.y_z}} _after_")
|
||||
var saw struct {
|
||||
italicBefore bool
|
||||
placeholder bool
|
||||
italicAfter bool
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "before" {
|
||||
saw.italicBefore = true
|
||||
}
|
||||
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
|
||||
saw.placeholder = true
|
||||
}
|
||||
if s.Italic && s.Text == "after" {
|
||||
saw.italicAfter = true
|
||||
}
|
||||
}
|
||||
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
|
||||
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// extractPlaceholders pulls every {{...}} occurrence out of a Markdown
|
||||
// source. Tiny helper, only used by the regression test above.
|
||||
func extractPlaceholders(s string) []string {
|
||||
@@ -196,39 +156,6 @@ func TestRenderMarkdownToOOXML_CRLFNormalisation(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_Plain(t *testing.T) {
|
||||
spans := parseInlineSpans("hello world")
|
||||
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
|
||||
t.Errorf("expected single plain span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_UnderscoreItalic(t *testing.T) {
|
||||
spans := parseInlineSpans("_emph_")
|
||||
var italicHits int
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "emph" {
|
||||
italicHits++
|
||||
}
|
||||
}
|
||||
if italicHits != 1 {
|
||||
t.Errorf("expected one italic 'emph' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseInlineSpans_UnderscoreBold(t *testing.T) {
|
||||
spans := parseInlineSpans("__strong__")
|
||||
var boldHits int
|
||||
for _, s := range spans {
|
||||
if s.Bold && s.Text == "strong" {
|
||||
boldHits++
|
||||
}
|
||||
}
|
||||
if boldHits != 1 {
|
||||
t.Errorf("expected one bold 'strong' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Slice D — rich-prose constructs
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -349,35 +276,3 @@ func TestRenderMarkdownToOOXML_HyperlinkNilAllocatorFallsBackToPlain(t *testing.
|
||||
t.Errorf("hyperlink emitted without allocator: %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBlockMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
kind string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"# A", "heading_1", "A", true},
|
||||
{"## B", "heading_2", "B", true},
|
||||
{"### C", "heading_3", "C", true},
|
||||
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
|
||||
{" # too-deep", "", "", false}, // 4 spaces → not a heading
|
||||
{"- bullet", "list_bullet", "bullet", true},
|
||||
{"* star", "list_bullet", "star", true},
|
||||
{"1. one", "list_numbered", "one", true},
|
||||
{"42. forty-two", "list_numbered", "forty-two", true},
|
||||
{"1) paren", "list_numbered", "paren", true},
|
||||
{"1.no-space", "", "", false}, // ordinal needs trailing space
|
||||
{"> quote", "blockquote", "quote", true},
|
||||
{"plain", "", "", false},
|
||||
{"#nospace", "", "", false}, // heading needs space after hash
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
kind, payload, ok := detectBlockMarker(tc.in)
|
||||
if ok != tc.ok || kind != tc.kind || payload != tc.want {
|
||||
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
22
pkg/docforge/exporter.go
Normal file
22
pkg/docforge/exporter.go
Normal file
@@ -0,0 +1,22 @@
|
||||
package docforge
|
||||
|
||||
// Exporter renders a neutral Document into a target format's body markup.
|
||||
// docforge owns the interface; each format adapter implements it (the
|
||||
// .docx adapter in pkg/docforge/docx today; .pdf/.html/.md are future
|
||||
// siblings — PRD §4 B4: interface now, docx-only impl). Format-specific
|
||||
// configuration (a stylemap, a hyperlink allocator for .docx) is baked into
|
||||
// the concrete exporter at construction, so the interface stays
|
||||
// format-neutral.
|
||||
//
|
||||
// "Body markup" is the renderable content fragment, not a complete file —
|
||||
// for .docx it is the OOXML <w:p> run the composer splices into a carrier.
|
||||
// Container concerns (MIME type, packaging) are described by Format /
|
||||
// MIMEType and handled by the assembling layer.
|
||||
type Exporter interface {
|
||||
// Format is the short format id, e.g. "docx".
|
||||
Format() string
|
||||
// MIMEType is the container MIME type the assembled document carries.
|
||||
MIMEType() string
|
||||
// RenderBody renders the document to the format's body markup.
|
||||
RenderBody(doc Document) ([]byte, error)
|
||||
}
|
||||
230
pkg/docforge/markdown/importer.go
Normal file
230
pkg/docforge/markdown/importer.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// Package markdown imports Markdown source into the neutral
|
||||
// docforge.Document model (PRD §3.2 / §4 P4 — Markdown is the primary
|
||||
// input format). It is the single Markdown parser for docforge: the .docx
|
||||
// renderer consumes the Document this produces, so block-splitting and
|
||||
// inline tokenisation live here, not in the format adapter.
|
||||
//
|
||||
// Grammar (intentionally narrow — unrecognised syntax flows through as a
|
||||
// plain paragraph, so lawyer prose never errors):
|
||||
//
|
||||
// blank line → paragraph break
|
||||
// # / ## / ### Heading → heading_1 / 2 / 3
|
||||
// - item / * item → bullet list item
|
||||
// N. item / N) item → numbered list item
|
||||
// > quote → blockquote
|
||||
// **x** / __x__ → bold
|
||||
// *x* / _x_ → italic
|
||||
// [label](url) → hyperlink
|
||||
// {{key}} → preserved verbatim (substituted downstream)
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
)
|
||||
|
||||
// Import parses Markdown into a Document. Empty (or all-blank) input yields
|
||||
// a single empty paragraph so a splice site stays well-formed.
|
||||
func Import(md string) docforge.Document {
|
||||
blocks := splitBlocks(md)
|
||||
if len(blocks) == 0 {
|
||||
return docforge.Document{Blocks: []docforge.Block{{Kind: docforge.KindParagraph}}}
|
||||
}
|
||||
out := make([]docforge.Block, 0, len(blocks))
|
||||
for _, blk := range blocks {
|
||||
b := docforge.Block{Kind: docforge.BlockKind(blk.kind)}
|
||||
// An empty-text block is an intentional empty paragraph: leave
|
||||
// Spans nil so the exporter emits a single empty run.
|
||||
if blk.text != "" {
|
||||
b.Spans = parseInline(blk.text)
|
||||
}
|
||||
out = append(out, b)
|
||||
}
|
||||
return docforge.Document{Blocks: out}
|
||||
}
|
||||
|
||||
// rawBlock is the intermediate (kind, stripped-text) form before inline
|
||||
// parsing. kind values match docforge.BlockKind string values.
|
||||
type rawBlock struct {
|
||||
kind string
|
||||
text string
|
||||
}
|
||||
|
||||
// splitBlocks parses the source into a sequence of (kind, text) blocks,
|
||||
// detecting heading / list / blockquote prefixes line-by-line. A run of
|
||||
// unmarked lines collapses into one paragraph block (soft line breaks
|
||||
// inside a paragraph concatenate); each marked line is its own block.
|
||||
// Blank-run spacing emits extra empty paragraph blocks. CRLF normalised.
|
||||
func splitBlocks(md string) []rawBlock {
|
||||
normalised := strings.ReplaceAll(md, "\r\n", "\n")
|
||||
lines := strings.Split(normalised, "\n")
|
||||
var blocks []rawBlock
|
||||
var pendingPara []string
|
||||
blankRun := 0
|
||||
|
||||
flushPara := func() {
|
||||
if len(pendingPara) > 0 {
|
||||
blocks = append(blocks, rawBlock{kind: "paragraph", text: strings.Join(pendingPara, "\n")})
|
||||
pendingPara = nil
|
||||
}
|
||||
}
|
||||
|
||||
for _, line := range lines {
|
||||
if strings.TrimSpace(line) == "" {
|
||||
if len(pendingPara) > 0 {
|
||||
flushPara()
|
||||
blankRun = 1
|
||||
continue
|
||||
}
|
||||
blankRun++
|
||||
continue
|
||||
}
|
||||
if kind, payload, ok := detectBlockMarker(line); ok {
|
||||
flushPara()
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, rawBlock{kind: "paragraph", text: ""})
|
||||
}
|
||||
blankRun = 0
|
||||
blocks = append(blocks, rawBlock{kind: kind, text: payload})
|
||||
continue
|
||||
}
|
||||
if len(pendingPara) == 0 {
|
||||
for i := 1; i < blankRun; i++ {
|
||||
blocks = append(blocks, rawBlock{kind: "paragraph", text: ""})
|
||||
}
|
||||
}
|
||||
blankRun = 0
|
||||
pendingPara = append(pendingPara, line)
|
||||
}
|
||||
flushPara()
|
||||
return blocks
|
||||
}
|
||||
|
||||
// detectBlockMarker classifies a single line. Tolerates up to 3 leading
|
||||
// spaces (CommonMark) before treating the line as a plain paragraph.
|
||||
func detectBlockMarker(line string) (kind, payload string, ok bool) {
|
||||
trimmed := strings.TrimLeft(line, " ")
|
||||
if len(line)-len(trimmed) > 3 {
|
||||
return "", "", false
|
||||
}
|
||||
switch {
|
||||
case strings.HasPrefix(trimmed, "### "):
|
||||
return "heading_3", strings.TrimSpace(trimmed[4:]), true
|
||||
case strings.HasPrefix(trimmed, "## "):
|
||||
return "heading_2", strings.TrimSpace(trimmed[3:]), true
|
||||
case strings.HasPrefix(trimmed, "# "):
|
||||
return "heading_1", strings.TrimSpace(trimmed[2:]), true
|
||||
case strings.HasPrefix(trimmed, "> "):
|
||||
return "blockquote", strings.TrimSpace(trimmed[2:]), true
|
||||
case strings.HasPrefix(trimmed, "- "), strings.HasPrefix(trimmed, "* "):
|
||||
return "list_bullet", strings.TrimSpace(trimmed[2:]), true
|
||||
}
|
||||
if i := indexOfNumberedMarker(trimmed); i > 0 {
|
||||
return "list_numbered", strings.TrimSpace(trimmed[i:]), true
|
||||
}
|
||||
return "", "", false
|
||||
}
|
||||
|
||||
// indexOfNumberedMarker returns the byte index just past an "N. " / "N) "
|
||||
// marker at the start of s, or -1 when absent.
|
||||
func indexOfNumberedMarker(s string) int {
|
||||
i := 0
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if i == 0 || i >= len(s) {
|
||||
return -1
|
||||
}
|
||||
if s[i] != '.' && s[i] != ')' {
|
||||
return -1
|
||||
}
|
||||
if i+1 >= len(s) || s[i+1] != ' ' {
|
||||
return -1
|
||||
}
|
||||
return i + 2
|
||||
}
|
||||
|
||||
// parseInline splits text around [label](url) hyperlinks and tokenises the
|
||||
// rest into bold/italic spans. Hyperlinks become a span with Link set and
|
||||
// the label's spans as Children, preserving link boundaries.
|
||||
func parseInline(text string) []docforge.InlineSpan {
|
||||
var out []docforge.InlineSpan
|
||||
rest := text
|
||||
for {
|
||||
idx := strings.Index(rest, "[")
|
||||
if idx < 0 {
|
||||
if rest != "" {
|
||||
out = append(out, parseSpans(rest)...)
|
||||
}
|
||||
break
|
||||
}
|
||||
closeBracket := strings.Index(rest[idx:], "](")
|
||||
if closeBracket < 0 {
|
||||
out = append(out, parseSpans(rest)...)
|
||||
break
|
||||
}
|
||||
closeParen := strings.Index(rest[idx+closeBracket:], ")")
|
||||
if closeParen < 0 {
|
||||
out = append(out, parseSpans(rest)...)
|
||||
break
|
||||
}
|
||||
label := rest[idx+1 : idx+closeBracket]
|
||||
url := rest[idx+closeBracket+2 : idx+closeBracket+closeParen]
|
||||
if idx > 0 {
|
||||
out = append(out, parseSpans(rest[:idx])...)
|
||||
}
|
||||
out = append(out, docforge.InlineSpan{Link: url, Children: parseSpans(label)})
|
||||
rest = rest[idx+closeBracket+closeParen+1:]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// parseSpans tokenises Markdown inline bold/italic into spans, preserving
|
||||
// {{...}} placeholders verbatim (the b78a984 fix — underscores in a
|
||||
// placeholder key must not be read as italic delimiters). Empty input
|
||||
// yields one empty span.
|
||||
func parseSpans(text string) []docforge.InlineSpan {
|
||||
var out []docforge.InlineSpan
|
||||
var cur strings.Builder
|
||||
bold := false
|
||||
italic := false
|
||||
flush := func() {
|
||||
if cur.Len() == 0 {
|
||||
return
|
||||
}
|
||||
out = append(out, docforge.InlineSpan{Text: cur.String(), Bold: bold, Italic: italic})
|
||||
cur.Reset()
|
||||
}
|
||||
i := 0
|
||||
n := len(text)
|
||||
for i < n {
|
||||
if i+1 < n && text[i] == '{' && text[i+1] == '{' {
|
||||
if rel := strings.Index(text[i+2:], "}}"); rel >= 0 {
|
||||
end := i + 2 + rel + 2
|
||||
cur.WriteString(text[i:end])
|
||||
i = end
|
||||
continue
|
||||
}
|
||||
}
|
||||
if i+1 < n && (text[i:i+2] == "**" || text[i:i+2] == "__") {
|
||||
flush()
|
||||
bold = !bold
|
||||
i += 2
|
||||
continue
|
||||
}
|
||||
if text[i] == '*' || text[i] == '_' {
|
||||
flush()
|
||||
italic = !italic
|
||||
i++
|
||||
continue
|
||||
}
|
||||
cur.WriteByte(text[i])
|
||||
i++
|
||||
}
|
||||
flush()
|
||||
if len(out) == 0 {
|
||||
out = append(out, docforge.InlineSpan{Text: ""})
|
||||
}
|
||||
return out
|
||||
}
|
||||
145
pkg/docforge/markdown/importer_test.go
Normal file
145
pkg/docforge/markdown/importer_test.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package markdown
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Inline-span + block-marker tests, relocated from the docx walker when
|
||||
// parsing moved here (t-paliad-349 slice 8). parseSpans is the inline
|
||||
// tokeniser; detectBlockMarker classifies a line.
|
||||
|
||||
func TestParseSpans_PlaceholderWithUnderscoresIsLiteral(t *testing.T) {
|
||||
// {{project.case_number}} must emit as a single non-italic span
|
||||
// containing the full placeholder (the b78a984 fix).
|
||||
spans := parseSpans("{{project.case_number}}")
|
||||
if len(spans) != 1 {
|
||||
t.Fatalf("expected 1 span; got %d (%+v)", len(spans), spans)
|
||||
}
|
||||
if spans[0].Italic || spans[0].Bold {
|
||||
t.Errorf("placeholder must not be italic/bold; got %+v", spans[0])
|
||||
}
|
||||
if spans[0].Text != "{{project.case_number}}" {
|
||||
t.Errorf("placeholder text corrupted: got %q", spans[0].Text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpans_ItalicAroundPlaceholder(t *testing.T) {
|
||||
spans := parseSpans("_before_ {{x.y_z}} _after_")
|
||||
var saw struct {
|
||||
italicBefore bool
|
||||
placeholder bool
|
||||
italicAfter bool
|
||||
}
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "before" {
|
||||
saw.italicBefore = true
|
||||
}
|
||||
if !s.Italic && !s.Bold && strings.Contains(s.Text, "{{x.y_z}}") {
|
||||
saw.placeholder = true
|
||||
}
|
||||
if s.Italic && s.Text == "after" {
|
||||
saw.italicAfter = true
|
||||
}
|
||||
}
|
||||
if !saw.italicBefore || !saw.placeholder || !saw.italicAfter {
|
||||
t.Errorf("expected italic/placeholder/italic structure; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpans_Plain(t *testing.T) {
|
||||
spans := parseSpans("hello world")
|
||||
if len(spans) != 1 || spans[0].Bold || spans[0].Italic || spans[0].Text != "hello world" {
|
||||
t.Errorf("expected single plain span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpans_UnderscoreItalic(t *testing.T) {
|
||||
spans := parseSpans("_emph_")
|
||||
var italicHits int
|
||||
for _, s := range spans {
|
||||
if s.Italic && s.Text == "emph" {
|
||||
italicHits++
|
||||
}
|
||||
}
|
||||
if italicHits != 1 {
|
||||
t.Errorf("expected one italic 'emph' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpans_UnderscoreBold(t *testing.T) {
|
||||
spans := parseSpans("__strong__")
|
||||
var boldHits int
|
||||
for _, s := range spans {
|
||||
if s.Bold && s.Text == "strong" {
|
||||
boldHits++
|
||||
}
|
||||
}
|
||||
if boldHits != 1 {
|
||||
t.Errorf("expected one bold 'strong' span; got %+v", spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetectBlockMarker(t *testing.T) {
|
||||
cases := []struct {
|
||||
in string
|
||||
kind string
|
||||
want string
|
||||
ok bool
|
||||
}{
|
||||
{"# A", "heading_1", "A", true},
|
||||
{"## B", "heading_2", "B", true},
|
||||
{"### C", "heading_3", "C", true},
|
||||
{" # indented", "heading_1", "indented", true}, // up to 3 spaces tolerated
|
||||
{" # too-deep", "", "", false}, // 4 spaces → not a heading
|
||||
{"- bullet", "list_bullet", "bullet", true},
|
||||
{"* star", "list_bullet", "star", true},
|
||||
{"1. one", "list_numbered", "one", true},
|
||||
{"42. forty-two", "list_numbered", "forty-two", true},
|
||||
{"1) paren", "list_numbered", "paren", true},
|
||||
{"1.no-space", "", "", false}, // ordinal needs trailing space
|
||||
{"> quote", "blockquote", "quote", true},
|
||||
{"plain", "", "", false},
|
||||
{"#nospace", "", "", false}, // heading needs space after hash
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
kind, payload, ok := detectBlockMarker(tc.in)
|
||||
if ok != tc.ok || kind != tc.kind || payload != tc.want {
|
||||
t.Errorf("detectBlockMarker(%q) = (%q,%q,%v); want (%q,%q,%v)", tc.in, kind, payload, ok, tc.kind, tc.want, tc.ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestImport_Document spot-checks the neutral Document the importer
|
||||
// produces — block kinds, the link-span shape, and placeholder pass-through.
|
||||
func TestImport_Document(t *testing.T) {
|
||||
doc := Import("# Title\n\nBody **bold** and [label](http://x).\n\n- item")
|
||||
if len(doc.Blocks) != 3 {
|
||||
t.Fatalf("blocks = %d; want 3 (%+v)", len(doc.Blocks), doc.Blocks)
|
||||
}
|
||||
if doc.Blocks[0].Kind != "heading_1" {
|
||||
t.Errorf("block0 kind = %q; want heading_1", doc.Blocks[0].Kind)
|
||||
}
|
||||
if doc.Blocks[2].Kind != "list_bullet" {
|
||||
t.Errorf("block2 kind = %q; want list_bullet", doc.Blocks[2].Kind)
|
||||
}
|
||||
// The body paragraph carries a link span with Link set + children.
|
||||
var sawLink bool
|
||||
for _, s := range doc.Blocks[1].Spans {
|
||||
if s.Link == "http://x" && len(s.Children) > 0 {
|
||||
sawLink = true
|
||||
}
|
||||
}
|
||||
if !sawLink {
|
||||
t.Errorf("body block missing link span; got %+v", doc.Blocks[1].Spans)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImport_EmptyYieldsOneEmptyParagraph(t *testing.T) {
|
||||
doc := Import("")
|
||||
if len(doc.Blocks) != 1 || doc.Blocks[0].Kind != "paragraph" || len(doc.Blocks[0].Spans) != 0 {
|
||||
t.Errorf("empty import = %+v; want one empty paragraph block", doc.Blocks)
|
||||
}
|
||||
}
|
||||
58
pkg/docforge/model.go
Normal file
58
pkg/docforge/model.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package docforge
|
||||
|
||||
// The neutral document model — the format-independent representation an
|
||||
// importer produces and an exporter consumes (PRD §3.2). A Markdown
|
||||
// importer parses source into a Document; the .docx exporter renders a
|
||||
// Document into OOXML; a future PDF/HTML exporter renders the same
|
||||
// Document differently. The model carries editable content only —
|
||||
// placeholders ({{key}}) ride through as literal span text and are
|
||||
// substituted later by the format exporter's merge pass, exactly as in
|
||||
// the pre-model pipeline.
|
||||
//
|
||||
// Slice 8 (t-paliad-349) lands this model with two real consumers: the
|
||||
// Markdown importer (pkg/docforge/markdown) and the .docx renderer
|
||||
// (pkg/docforge/docx), which the shipped submission walker now routes
|
||||
// through — so there is one parser, not two.
|
||||
|
||||
// BlockKind is the logical kind of a block. Its string values are the
|
||||
// stylemap keys a format exporter looks up (paragraph, heading_1, …), so
|
||||
// the docx exporter maps Kind → Word paragraph style directly.
|
||||
type BlockKind string
|
||||
|
||||
const (
|
||||
KindParagraph BlockKind = "paragraph"
|
||||
KindHeading1 BlockKind = "heading_1"
|
||||
KindHeading2 BlockKind = "heading_2"
|
||||
KindHeading3 BlockKind = "heading_3"
|
||||
KindListBullet BlockKind = "list_bullet"
|
||||
KindListNumbered BlockKind = "list_numbered"
|
||||
KindBlockquote BlockKind = "blockquote"
|
||||
)
|
||||
|
||||
// Document is a sequence of blocks — the whole editable content.
|
||||
type Document struct {
|
||||
Blocks []Block
|
||||
}
|
||||
|
||||
// Block is one paragraph-level unit. Spans is its inline content; an empty
|
||||
// Spans slice is an intentional empty paragraph (vertical spacing).
|
||||
type Block struct {
|
||||
Kind BlockKind
|
||||
Spans []InlineSpan
|
||||
}
|
||||
|
||||
// InlineSpan is one run of inline content. A span is either:
|
||||
// - literal text with optional bold/italic (Link == "", Children nil), or
|
||||
// - a hyperlink (Link != "") whose label is the Children spans.
|
||||
//
|
||||
// Modelling a link as a span with Children (rather than a per-span Link
|
||||
// flag) preserves link boundaries: two adjacent links to the same URL stay
|
||||
// two distinct hyperlink spans, so the exporter emits them byte-identically
|
||||
// to the pre-model walker.
|
||||
type InlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
Link string // non-empty → this span is a hyperlink to Link
|
||||
Children []InlineSpan // hyperlink label content (only when Link != "")
|
||||
}
|
||||
@@ -13,7 +13,11 @@ type TemplateMeta struct {
|
||||
SourceFormat string // "docx"
|
||||
Firm string // may be empty
|
||||
IsActive bool
|
||||
Version int // current version number; 0 when no version exists yet
|
||||
Version int // current version number; 0 when no version exists yet
|
||||
VersionID string // current version row id; "" when no version exists yet.
|
||||
// A draft pins VersionID to snapshot this exact version (PRD §4 A3):
|
||||
// a later template edit creates a new version and re-points current,
|
||||
// but the pinned draft keeps rendering VersionID.
|
||||
}
|
||||
|
||||
// TemplateSlot is one variable slot placed in a template version's carrier.
|
||||
|
||||
@@ -18,14 +18,34 @@ package docforge
|
||||
// engine.
|
||||
type VariableResolver interface {
|
||||
// Namespace returns the dotted prefix this resolver owns, e.g.
|
||||
// "project". Informational — used for diagnostics and (later) the
|
||||
// authoring variable palette's grouping.
|
||||
// "project". Informational — used for diagnostics and as the default
|
||||
// group for this resolver's catalogue entries.
|
||||
Namespace() string
|
||||
|
||||
// Populate writes this resolver's keys into bag. Resolvers own
|
||||
// disjoint namespaces, so population order across resolvers does not
|
||||
// affect the final bag.
|
||||
Populate(bag PlaceholderMap)
|
||||
|
||||
// Keys returns the user-facing catalogue entries for this resolver —
|
||||
// the variables an authoring palette can offer and a sidebar form can
|
||||
// render, each with its bilingual label. This is the curated, static
|
||||
// surface (e.g. the flat parties.claimant.name form), not the full
|
||||
// possibly-dynamic key set Populate emits (e.g. the indexed
|
||||
// parties.claimant.0.name). Go owns these labels so the frontend form
|
||||
// and the authoring palette read one source of truth instead of a
|
||||
// duplicated TS table.
|
||||
Keys() []VariableKey
|
||||
}
|
||||
|
||||
// VariableKey is one catalogue entry: the placeholder key plus its
|
||||
// bilingual label and a group (the owning namespace by default). The
|
||||
// frontend maps groups onto its own lawyer-facing presentation sections.
|
||||
type VariableKey struct {
|
||||
Key string `json:"key"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// ResolverSet composes an ordered list of VariableResolvers into a single
|
||||
@@ -54,3 +74,16 @@ func (s *ResolverSet) BuildBag() PlaceholderMap {
|
||||
}
|
||||
return bag
|
||||
}
|
||||
|
||||
// Catalogue concatenates every resolver's Keys() in resolver order — the
|
||||
// full set of user-facing variables for a palette or form, with bilingual
|
||||
// labels. It does not require any per-call entity state, so a consumer can
|
||||
// build a metadata-only ResolverSet (resolvers constructed with nil
|
||||
// entities) purely to serve the catalogue.
|
||||
func (s *ResolverSet) Catalogue() []VariableKey {
|
||||
var out []VariableKey
|
||||
for _, r := range s.resolvers {
|
||||
out = append(out, r.Keys()...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
60
pkg/docforge/vars_test.go
Normal file
60
pkg/docforge/vars_test.go
Normal file
@@ -0,0 +1,60 @@
|
||||
package docforge
|
||||
|
||||
import "testing"
|
||||
|
||||
// fakeResolver is a test double: it owns a namespace, populates a fixed
|
||||
// set of key/value pairs, and advertises a fixed catalogue.
|
||||
type fakeResolver struct {
|
||||
ns string
|
||||
values map[string]string
|
||||
catalog []VariableKey
|
||||
}
|
||||
|
||||
func (f fakeResolver) Namespace() string { return f.ns }
|
||||
func (f fakeResolver) Keys() []VariableKey { return f.catalog }
|
||||
func (f fakeResolver) Populate(bag PlaceholderMap) {
|
||||
for k, v := range f.values {
|
||||
bag[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverSet_BuildBagMergesDisjointNamespaces(t *testing.T) {
|
||||
set := NewResolverSet(
|
||||
fakeResolver{ns: "a", values: map[string]string{"a.x": "1", "a.y": "2"}},
|
||||
fakeResolver{ns: "b", values: map[string]string{"b.z": "3"}},
|
||||
)
|
||||
bag := set.BuildBag()
|
||||
if len(bag) != 3 {
|
||||
t.Fatalf("bag size = %d; want 3", len(bag))
|
||||
}
|
||||
for k, want := range map[string]string{"a.x": "1", "a.y": "2", "b.z": "3"} {
|
||||
if bag[k] != want {
|
||||
t.Errorf("bag[%q] = %q; want %q", k, bag[k], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolverSet_AddAndCatalogueOrder(t *testing.T) {
|
||||
set := NewResolverSet(
|
||||
fakeResolver{ns: "a", catalog: []VariableKey{{Key: "a.x", Group: "a"}}},
|
||||
)
|
||||
set.Add(fakeResolver{ns: "b", catalog: []VariableKey{
|
||||
{Key: "b.y", Group: "b"},
|
||||
{Key: "b.z", Group: "b"},
|
||||
}})
|
||||
|
||||
cat := set.Catalogue()
|
||||
gotOrder := make([]string, len(cat))
|
||||
for i, e := range cat {
|
||||
gotOrder[i] = e.Key
|
||||
}
|
||||
want := []string{"a.x", "b.y", "b.z"} // resolver order, then Keys() order
|
||||
if len(gotOrder) != len(want) {
|
||||
t.Fatalf("catalogue len = %d; want %d", len(gotOrder), len(want))
|
||||
}
|
||||
for i := range want {
|
||||
if gotOrder[i] != want[i] {
|
||||
t.Errorf("catalogue[%d] = %q; want %q", i, gotOrder[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user