Compare commits
12 Commits
e1e8db7fc9
...
mai/farada
| Author | SHA1 | Date | |
|---|---|---|---|
| d834b36313 | |||
| 4092c889c4 | |||
| db1040968f | |||
| f292338919 | |||
| 2b240e7dd0 | |||
| c945cbd330 | |||
| 639ff4f672 | |||
| 264cc39a6b | |||
| 28d860a07d | |||
| d913f4fc30 | |||
| e091716f48 | |||
| 8763ab013c |
@@ -256,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**:
|
||||
|
||||
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,6 +1745,10 @@ 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.",
|
||||
@@ -3591,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",
|
||||
@@ -3640,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",
|
||||
@@ -4965,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",
|
||||
|
||||
@@ -503,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) {
|
||||
@@ -558,6 +558,7 @@ function paint(): void {
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintKeywordRow();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
@@ -1034,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;
|
||||
|
||||
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"
|
||||
|
||||
@@ -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-
|
||||
|
||||
@@ -542,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)
|
||||
@@ -552,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)
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -180,6 +180,11 @@ type submissionDraftPatchInput struct {
|
||||
// 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
|
||||
@@ -446,6 +451,7 @@ 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
|
||||
@@ -592,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.
|
||||
@@ -939,6 +945,10 @@ type globalDraftPatchInput struct {
|
||||
// (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 {
|
||||
@@ -950,6 +960,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
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 {
|
||||
@@ -962,6 +973,7 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.SelectedParties = a.SelectedParties
|
||||
g.BaseID = a.BaseID
|
||||
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
|
||||
@@ -1006,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
|
||||
@@ -1141,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()
|
||||
|
||||
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
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,14 @@ type DraftPatch struct {
|
||||
// **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
|
||||
@@ -356,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
|
||||
}
|
||||
@@ -431,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 {
|
||||
@@ -459,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
|
||||
@@ -589,6 +706,21 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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 != "")
|
||||
}
|
||||
Reference in New Issue
Block a user