Compare commits
122 Commits
mai/pasteu
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 49b61cfa73 | |||
| 671e31557e | |||
| 0c9fe78f6b | |||
| c8aa872802 | |||
| 6b0f731c61 | |||
| 298e1b22c8 | |||
| fbd6bf9bd0 | |||
| e751056d57 | |||
| 0eb6cd2ce6 | |||
| 8ccf64cf83 | |||
| 3d68de2568 | |||
| a70508a7d5 | |||
| 7ec1908383 | |||
| 064ba79ecb | |||
| 3f20823e18 | |||
| 017e535541 | |||
| f46b1b18a3 | |||
| f03b00ee89 | |||
| 310c993d19 | |||
| b09fc687bd | |||
| cd4ec074ca | |||
| 31ebec769a | |||
| 9dba2b627c | |||
| 8a51fbb9e4 | |||
| 327dc1169a | |||
| 08fb6ed501 | |||
| b582e62b2b | |||
| f152109bd3 | |||
| c1781c9a89 | |||
| 72ba8fb2b9 | |||
| 13166cd784 | |||
| 9e515551b2 | |||
| 63e973df88 | |||
| 8eba20f46a | |||
| d30556397a | |||
| 1882468780 | |||
| 3042bea3c2 | |||
| ea9dd261ae | |||
| 78f9aa4f46 | |||
| 19f9604827 | |||
| c303c01652 | |||
| 97a2742f10 | |||
| b26360111a | |||
| e914bac79a | |||
| 713a4d4206 | |||
| cd3cd0230c | |||
| cd793b1d98 | |||
| a50ddc3927 | |||
| c639c5695c | |||
| a05ae1f2ae | |||
| 7fe37bb550 | |||
| 57310ab3a4 | |||
| b99b6d6fb5 | |||
| 5468a7259d | |||
| 230306518d | |||
| 6e56b9d51f | |||
| 375d631f1b | |||
| e3a604b4c4 | |||
| 0763b7daa2 | |||
| c4e3a74e35 | |||
| 9d234f275f | |||
| 83d5ed27e0 | |||
| 73f379d305 | |||
| 9a5ee93f2e | |||
| 213be10ada | |||
| 6dd9befba3 | |||
| e10b5e6546 | |||
| cd3f7843a7 | |||
| 4920328b09 | |||
| 385abc7a98 | |||
| 94adeeb8cb | |||
| d834b36313 | |||
| 4092c889c4 | |||
| db1040968f | |||
| f292338919 | |||
| 2b240e7dd0 | |||
| c945cbd330 | |||
| 639ff4f672 | |||
| 264cc39a6b | |||
| 28d860a07d | |||
| d913f4fc30 | |||
| e091716f48 | |||
| 8763ab013c | |||
| e1e8db7fc9 | |||
| b746ec36c7 | |||
| 28aaafeb05 | |||
| f9331e9bb9 | |||
| e53bcf8cc2 | |||
| 68fcbc6fbf | |||
| 31e15d4b20 | |||
| a111a82640 | |||
| 63a9bedf7e | |||
| b8709b903d | |||
| 938222d602 | |||
| 47deeaf5ed | |||
| a2da501917 | |||
| 8ea78fd376 | |||
| e189d3fe6a | |||
| 58907554fc | |||
| 9b8a865c5f | |||
| f8067c2fe5 | |||
| 78a30a7ee0 | |||
| 091804923a | |||
| 9201501941 | |||
| 05247d7bd7 | |||
| a81581878e | |||
| 8d8a882f46 | |||
| 9679a98666 | |||
| fcdfba209d | |||
| 3e93e94d10 | |||
| 28ea103260 | |||
| 1c77cb6e67 | |||
| 1f6e586c63 | |||
| a4b865d6bd | |||
| a905911cf4 | |||
| 88c03e922f | |||
| 6bcac2dd20 | |||
| 46dc4ec94b | |||
| 6c1d8cc0cf | |||
| 0c857026a2 | |||
| 1b4b2e4758 | |||
| b78a984a7c |
@@ -11,7 +11,10 @@ COPY . .
|
||||
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /paliad ./cmd/server
|
||||
|
||||
FROM alpine:3.21
|
||||
RUN apk add --no-cache ca-certificates openssh-client
|
||||
# poppler-utils provides pdftoppm — used by the truthful base-preview render
|
||||
# (PDF→PNG per page) in the S4 preview pipeline (t-paliad-370). If absent, the
|
||||
# preview gracefully falls back to the structural HTML render.
|
||||
RUN apk add --no-cache ca-certificates openssh-client poppler-utils
|
||||
WORKDIR /app
|
||||
COPY --from=backend /paliad /app/paliad
|
||||
COPY --from=frontend /app/frontend/dist /app/dist
|
||||
|
||||
@@ -174,6 +174,9 @@ func main() {
|
||||
submissionComposerSvc := services.NewSubmissionComposer(submissionRenderer)
|
||||
// t-paliad-315 Slice C — building-block library.
|
||||
submissionBuildingBlockSvc := services.NewBuildingBlockService(pool, branding.Name)
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store
|
||||
// (Postgres bytea) backing the authoring surface.
|
||||
templateStoreSvc := services.NewPgTemplateStore(pool)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -190,6 +193,7 @@ func main() {
|
||||
SubmissionSection: submissionSectionSvc,
|
||||
SubmissionComposer: submissionComposerSvc,
|
||||
SubmissionBuildingBlock: submissionBuildingBlockSvc,
|
||||
TemplateStore: templateStoreSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
@@ -233,6 +237,7 @@ func main() {
|
||||
CardLayout: services.NewCardLayoutService(pool),
|
||||
DashboardLayout: services.NewDashboardLayoutService(pool),
|
||||
FirmDashboardDefault: services.NewFirmDashboardDefaultService(pool),
|
||||
FirmNameComposition: services.NewFirmNameCompositionService(pool),
|
||||
Projection: services.NewProjectionService(pool, projectSvc, deadlineSvc, appointmentSvc, services.NewFristenrechnerService(rules, holidays, courts), rules),
|
||||
// t-paliad-214 Slice 1 — personal-scope data export. firm name
|
||||
// is captured into __meta of every export and printed in the
|
||||
@@ -248,8 +253,11 @@ func main() {
|
||||
ScenarioFlags: services.NewScenarioFlagsService(pool, projectSvc),
|
||||
// t-paliad-340 / m/paliad#153 B0 (mig 157) — Litigation Builder.
|
||||
// CRUD over the new normalised scenarios + scenario_proceedings
|
||||
// + scenario_events + scenario_shares tables.
|
||||
ScenarioBuilder: services.NewScenarioBuilderService(pool),
|
||||
// + scenario_events + scenario_shares tables. B4 adds the
|
||||
// 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), services.NewFristenrechnerService(rules, holidays, courts)),
|
||||
}
|
||||
|
||||
// t-paliad-246 Slice A — Backup Mode runner. Wired only when
|
||||
|
||||
@@ -47,9 +47,32 @@ services:
|
||||
# restarts. Unset → /admin/backups returns 503 (BackupService gate).
|
||||
- PALIAD_EXPORT_DIR=${PALIAD_EXPORT_DIR:-/var/lib/paliad/exports}
|
||||
# - ANTHROPIC_API_KEY=${ANTHROPIC_API_KEY} # Phase H (AI Frist-Extraktion), currently deferred
|
||||
# Truthful base-preview render (t-paliad-370 S4). GOTENBERG_URL points at
|
||||
# the gotenberg sidecar below (LibreOffice-as-HTTP, .docx→PDF). Unset →
|
||||
# preview gracefully falls back to structural HTML. PREVIEW_DPI optional.
|
||||
- GOTENBERG_URL=${GOTENBERG_URL:-http://gotenberg:3000}
|
||||
- PREVIEW_DPI=${PREVIEW_DPI:-110}
|
||||
volumes:
|
||||
- paliad_exports:/var/lib/paliad/exports
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
- gotenberg
|
||||
|
||||
# LibreOffice-as-a-service sidecar for the truthful base-preview render
|
||||
# (t-paliad-370 S4). Converts the export .docx → PDF over HTTP; paliad then
|
||||
# rasterises PDF→PNG via poppler. Internal only (not exposed publicly).
|
||||
# NOTE (fonts): stock gotenberg ships Liberation fonts (metric-compatible
|
||||
# with Arial/Times) → preview layout is accurate, glyphs ~99% but not pixel-
|
||||
# identical to the firm's Arial. A custom image baking the firm's licensed
|
||||
# fonts is the pixel-perfect upgrade (t-paliad-371, m's call).
|
||||
gotenberg:
|
||||
image: gotenberg/gotenberg:8
|
||||
restart: unless-stopped
|
||||
expose:
|
||||
- "3000"
|
||||
command:
|
||||
- gotenberg
|
||||
- --api-timeout=30s
|
||||
|
||||
volumes:
|
||||
paliad_exports:
|
||||
|
||||
357
docs/design-generation-quality-2026-06-01.md
Normal file
357
docs/design-generation-quality-2026-06-01.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# Generation-quality diagnosis — docforge loop (2026-06-01)
|
||||
|
||||
**Task:** t-paliad-363 · researcher (kepler) · READ-ONLY diagnosis + sliced fix-plan
|
||||
**Trigger:** m exercised the docforge loop live 2026-06-01 ~15:33 (global `/submissions/new`
|
||||
picker → start draft "Ohne Projekt" → generate) and surfaced 3 real problems.
|
||||
**Scope:** investigate + plan only. No code/data/template edits this shift.
|
||||
|
||||
Three problems, increasing depth:
|
||||
|
||||
1. **P1** — the global picker lists court/trigger acts, not just party submissions.
|
||||
2. **P2** — the grouped picker table looks layout-broken (proceeding name "in the row flow").
|
||||
3. **P3** — a generated project-less draft is both **unstyled** (no HL Patents Style) and
|
||||
**unfilled** (every caption/party value `[KEIN WERT: …]`). This is the docforge core.
|
||||
|
||||
---
|
||||
|
||||
## P1 — picker lists court acts, not just party submissions
|
||||
|
||||
### What m saw
|
||||
"Service of OLG Judgment" (`de.inf.bgh.urteil_olg`, *Zustellung OLG-Urteil*) appears in the
|
||||
draftable-submission picker. That is a **court/trigger act** (the service of the lower-court
|
||||
judgment that *starts* the appeal clock), not a party submission anyone drafts.
|
||||
|
||||
### Root cause
|
||||
The catalog query (`loadSubmissionCatalog`, `internal/handlers/submissions.go:177-211`) filters:
|
||||
|
||||
```sql
|
||||
WHERE dr.is_active = true AND dr.lifecycle_state = 'published'
|
||||
AND dr.event_type = 'filing'
|
||||
AND dr.submission_code IS NOT NULL AND dr.submission_code <> ''
|
||||
```
|
||||
|
||||
The model **already has a correct court-act tier**: 16 rows with `event_type='decision'` and
|
||||
`primary_party='court'` (e.g. `de.inf.olg.urteil_olg` "OLG-Urteil", `upc.inf.cfi.decision`
|
||||
"Entscheidung"). Those are correctly excluded by `event_type='filing'`.
|
||||
|
||||
But **8 anchor/trigger rows are mislabeled** `event_type='filing'` with `primary_party='both'`.
|
||||
They are the "Zustellung …" (service of judgment) / "Veröffentlichung der Erteilung"
|
||||
(publication-of-grant) events that **start** a proceeding's deadline chain. They are used
|
||||
**structurally as `parent_id` anchors** — e.g. `de.inf.bgh.urteil_olg` is the parent of
|
||||
Revisionsfrist, Revisionsbegründung, NZB, Mündliche Verhandlung BGH, BGH-Urteil. They are not
|
||||
draftable by a party.
|
||||
|
||||
### Affected rows (the 8 to re-kind)
|
||||
|
||||
| submission_code | proceeding | name | event_type (is) | primary_party (is) | anchors (children) |
|
||||
|---|---|---|---|---|---|
|
||||
| `de.inf.bgh.urteil_olg` | de.inf.bgh | Zustellung OLG-Urteil | filing | both | Revisionsfrist, Revisionsbegr., NZB, NZB-Begr., Termin, BGH-Urteil |
|
||||
| `de.inf.olg.urteil_lg` | de.inf.olg | Zustellung LG-Urteil | filing | both | Berufungsschrift, Berufungsbegr., Termin, OLG-Urteil |
|
||||
| `de.null.bgh.urteil_bpatg` | de.null.bgh | Zustellung BPatG-Urteil | filing | both | Berufungsschrift, Berufungsbegr., Termin, BGH-Urteil |
|
||||
| `dpma.appeal.bgh.entsch_bpatg` | dpma.appeal.bgh | Zustellung BPatG-Entscheidung | filing | both | Rechtsbeschwerde, BGH-Entscheidung |
|
||||
| `dpma.appeal.bpatg.entscheidung` | dpma.appeal.bpatg | Zustellung DPMA-Entscheidung | filing | both | Beschwerde, Termin, BPatG-Entscheidung |
|
||||
| `epa.opp.boa.entsch` | epa.opp.boa | Zustellung der Beschwerdeentscheidung | filing | both | Beschwerdeeinlegung, Beschwerdebegr. |
|
||||
| `dpma.opp.dpma.publish` | dpma.opp.dpma | Veröffentlichung der Erteilung | filing | both | Einspruchsfrist, DPMA-Entscheidung |
|
||||
| `epa.opp.opd.grant` | epa.opp.opd | Veröffentlichung der Erteilung | filing | both | Einspruchsfrist |
|
||||
|
||||
(Verified read-only via Supabase MCP against `paliad.deadline_rules_unified`, 2026-06-01.)
|
||||
|
||||
### The clean-filter question — and why a pure picker-filter does NOT work
|
||||
The task hypothesised "exclude `primary_party='court'`". **That does not catch these 8** — they
|
||||
carry `primary_party='both'`, not `'court'`. And `'both'` cannot be excluded wholesale: many real
|
||||
party submissions are `'both'` (Berufungsbegründung, Anschlussberufung, every UPC appeal brief).
|
||||
There is **no existing column value that isolates the 8** from genuine party submissions — the only
|
||||
thing wrong with them is the mislabel itself. So a stronger picker WHERE-clause cannot cleanly fix
|
||||
this without first fixing the data.
|
||||
|
||||
### → FORK (i): data-fix vs filter — RECOMMENDATION: **data correction**
|
||||
|
||||
**Recommended — data correction (re-kind the 8 anchor rows):**
|
||||
Set on each of the 8 rows: `event_type = 'decision'` and `primary_party = 'court'`, matching the
|
||||
16 sibling court-act rows the model already has. Then the *existing* picker filter
|
||||
(`event_type='filing'`) excludes them automatically — **no handler change**.
|
||||
- **Safe for anchoring:** the deadline chain links via `parent_id` (uuid FK), *not* via
|
||||
`event_type`/`primary_party`. Re-kinding does not touch the FK, so Revisionsfrist &co. still
|
||||
anchor correctly.
|
||||
- **`submission_code` retained:** the 16 sibling `decision` rows keep their submission_codes too
|
||||
(they're used as stable anchor identifiers), so retaining them here is consistent — no need to
|
||||
strip.
|
||||
- **Caveat to confirm before applying:** verify nothing computes a *deadline trigger* off
|
||||
`event_type='filing'` for these specific rows (a quick grep of the deadline engine for
|
||||
`event_type` filters). The `parent_id` graph is the structural link, so this is expected-safe,
|
||||
but the coder should confirm.
|
||||
|
||||
**Defensive hardening (do in addition, not instead):** add `AND dr.primary_party IS DISTINCT FROM
|
||||
'court'` to `loadSubmissionCatalog`'s WHERE clause. After the data fix the 8 rows are `'court'`, so
|
||||
this is belt-and-braces: any future mis-kinded court act can't leak into the picker even if its
|
||||
`event_type` is wrong.
|
||||
|
||||
**Why not "stronger filter only":** there is no filter on current columns that removes the 8
|
||||
without also removing legitimate `'both'` party submissions. A filter alone cannot fix mislabeled
|
||||
data — it would have to encode a brittle name-pattern blocklist ("Zustellung%/Veröffentlichung%"),
|
||||
which is the kind of tech debt the project bars.
|
||||
|
||||
---
|
||||
|
||||
## P2 — grouped picker table layout
|
||||
|
||||
### What m saw
|
||||
The full proceeding name ("Revision / Non-admission Appeal BGH (Infringement) de.inf.bgh") appears
|
||||
"in the row flow" so the columns (Schriftsatz | Partei | Rechtsgrundlage | Entwurf starten) look
|
||||
broken.
|
||||
|
||||
### Root cause — it is NOT structural; it is **visual weight**
|
||||
The grouped rows are hydrated by `frontend/src/client/submissions-new.ts`. The group header is
|
||||
**already a correct full-width colspan row** (committed `a911a2d`, 2026-05-23 — i.e. m saw this
|
||||
exact code):
|
||||
|
||||
```ts
|
||||
// client/submissions-new.ts:167-174
|
||||
const colspan = 4;
|
||||
html.push(`<tr class="entity-table-group-header"><th colspan="${colspan}" scope="colgroup">
|
||||
<span class="entity-table-group-header__name">${esc(group.name)}</span>
|
||||
<span class="entity-table-group-header__code">${esc(code)}</span></th></tr>`);
|
||||
```
|
||||
|
||||
CSS for it exists at `frontend/src/styles/global.css:6066-6109`. So structurally the name spans all
|
||||
4 columns — it is not crammed into column 1.
|
||||
|
||||
**The actual defect is contrast.** The group-header band paints
|
||||
`background: var(--color-bg-subtle)` (`global.css:6074`) = **`#f7f3f0`** — and the table `thead`
|
||||
column-label row uses the **same** `--color-bg-subtle` (`global.css:7979`), over a near-cream page
|
||||
background, with faint `--color-border: #e1dcd6` top/bottom rules. Result: the proceeding-name band
|
||||
is barely distinguishable from the column-header band directly above it and from the page. The eye
|
||||
reads it as un-delimited floating text sitting between the column labels and the data — "the name is
|
||||
in the row flow and the columns look broken." The long EN proceeding names amplify it.
|
||||
|
||||
(Could not confirm against the live render — `/submissions/new` is auth-gated and redirects to
|
||||
`/login`. Diagnosis is from source + the CSS variable values, which are conclusive about contrast.)
|
||||
|
||||
### → Fix (quick — pure CSS, no fork)
|
||||
Give `.entity-table-group-header th` a distinctly stronger treatment so each proceeding group reads
|
||||
as its own band, clearly separated from the column header:
|
||||
- a heavier/darker background than the `thead` (e.g. a deeper tint, or the midnight/lime accent the
|
||||
`--own` modifier already uses at `6089-6093`), **and/or**
|
||||
- a heavier top border / a small accent rule, plus a touch more vertical padding.
|
||||
|
||||
Anchor: `frontend/src/styles/global.css:6066-6109`. No TS change needed — the markup is already
|
||||
correct. The sibling project-scoped tab (`client/submissions.ts:137-141`) shares the same classes,
|
||||
so the CSS fix improves both at once.
|
||||
|
||||
(If the coder finds the live deploy *doesn't* show the colspan header at all, the secondary
|
||||
hypothesis is a stale `/assets/submissions-new.js` bundle — verify the deployed bundle includes the
|
||||
`a911a2d` grouping code. Source on `main` is correct as of this diagnosis.)
|
||||
|
||||
---
|
||||
|
||||
## P3 — generated Rubrum is UNSTYLED + UNFILLED (the docforge core)
|
||||
|
||||
m generated a draft from the global picker ("Ohne Projekt") and got: letterhead filled
|
||||
(`Schriftsatz von HLC / Matthias Siebels, duesseldorf`) but **every** caption/party/project value =
|
||||
`[KEIN WERT: …]` and the Rubrum is **plain text with no HL Patents Style formatting**. Two distinct
|
||||
layers, diagnosed separately.
|
||||
|
||||
### P3(a) — UNSTYLED — confirmed: it's `BuildFallbackSkeleton` output
|
||||
|
||||
**The path m hit.** `resolveSubmissionTemplate` (`internal/handlers/submission_drafts.go:1359-1408`)
|
||||
is a 7-tier fallback chain. For any code without a dedicated per-code template (only
|
||||
`de.inf.lg.erwidg` has one), and with the firm/universal skeletons rejected by the merge-safe guard
|
||||
(see below), generation lands on **tier 6 — `docx.BuildFallbackSkeleton(lang)`**
|
||||
(`pkg/docforge/docx/fallback_skeleton.go`).
|
||||
|
||||
**What that emits.** A minimal in-process .docx whose `styles.xml`
|
||||
(`fallback_skeleton.go:121-138`) defines only three generic styles:
|
||||
`Heading1` (bold 14pt), `Heading2` (bold 12pt), `Normal` (nothing). The Rubrum is built from those
|
||||
(`buildFallbackDocumentXML`, lines 215-269) — `fbHeading2` / `fbPlain` / `fbBold`. **No HL Patents
|
||||
Style typography, no named Rubrum styles, no firm font/numbering.** m's "plain text" is exactly
|
||||
this. Confirmed.
|
||||
|
||||
**Why the styled base isn't used (the merge-safe guard).** Tiers 3/4/5 fetch the firm/universal
|
||||
skeletons from mWorkRepo but are **guarded by `docx.HasMergePlaceholders`** (lines 1378/1384/1388).
|
||||
I fetched the firm-skeleton
|
||||
(`HL/mWorkRepo:6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx`, 157 KB) and unzipped it:
|
||||
|
||||
- **`styles.xml` carries the FULL HL Patents Style catalogue** — including the exact named styles a
|
||||
Rubrum needs:
|
||||
- `HLpat-Table-Recitals-Party`, `HLpat-Table-Recitals-PartyDetails`,
|
||||
`HLpat-Table-Recitals-PartyRoles`, `HLpat-Table-Recitals-Sequencers` — the party-block (Rubrum)
|
||||
styles (e.g. `Recitals-Party` = `basedOn Body1`, bold, numbered list `numId 4`).
|
||||
- `HLpat-Heading-H1..H5`, `HLpat-Body-B0/B1`, `HLpat-Header-Section/Subsection`,
|
||||
`HLpat-Signature`, `SignatureBlock`, `HLpat-Requests-Intro`, `HLpat-Citation`, …
|
||||
- full letterhead: `header1/header2` + `footer1/footer2` + `media/image1.jpeg` (logo) +
|
||||
`media/image2.jpg`, theme1.xml, fontTable.xml.
|
||||
- **But its `document.xml` body is ANCHORS-ONLY** — ten `{{#section:KEY}}…{{/section:KEY}}` pairs
|
||||
(`letterhead`, `caption`, `introduction`, `requests`, `facts`, `legal_argument`, `evidence`,
|
||||
`exhibits`, `closing`, `signature`) and **zero `{{key}}` merge placeholders**.
|
||||
|
||||
So `HasMergePlaceholders` correctly rejects it for the **merge** path (feeding it to `merge.go`
|
||||
would print literal `{{#section:…}}` junk — the t-paliad-358 A-S1 regression). The styled base is a
|
||||
**Composer base**, consumed by `pkg/docforge/docx/compose.go` (exists, 22 KB), which splices section
|
||||
content into those anchors. **The merge path and the Composer path are different engines, and
|
||||
one-click /generate + project-less export run the merge path.**
|
||||
|
||||
**The styled HL base therefore already carries every named paragraph style the Rubrum needs.** The
|
||||
gap is purely that generation runs the merge path (→ bare in-process skeleton) rather than a styled
|
||||
base.
|
||||
|
||||
#### → FORK (ii): styling approach — three framed options
|
||||
|
||||
- **Option A — drive generation off the Composer path (the "real docforge").**
|
||||
Route one-click /generate and the project-less draft-export through `compose.go` with the
|
||||
firm-skeleton base + the section seeds (`submission_bases.section_spec`, migs 146/150; the caption
|
||||
seed already exists per t-paliad-358). Output is fully HL-styled (Rubrum in `HLpat-Table-Recitals-*`,
|
||||
headings in `HLpat-Heading-*`, signature in `HLpat-Signature`, logo letterhead) **and** carries
|
||||
seeded section bodies. *Cost:* the biggest change — generation currently never calls the Composer
|
||||
for these entry points; needs routing + the project-less bag wired into the Composer.
|
||||
*This is the long-term correct path.*
|
||||
|
||||
- **Option B — author a merge-safe styled firm-skeleton (tracer-bullet, RECOMMENDED first).**
|
||||
Start from the firm-skeleton's full part-set (its `styles.xml`, theme, fonts, headers/footers,
|
||||
logo media) and replace **only** its anchors-only `document.xml` body with a **merge-safe Rubrum
|
||||
body** that uses the HLpat style IDs (`HLpat-Table-Recitals-Party` for party lines,
|
||||
`HLpat-Heading-H2` for section heads, `HLpat-Signature` for the signature block) and the same
|
||||
`{{key}}` / `{{caption.*}}` placeholders the current fallback already uses. Publish it as a
|
||||
merge-safe `_firm-skeleton.docx` (or a new slug). The resolver's **tier 4 already prefers a
|
||||
merge-safe firm-skeleton automatically** — its own comment says: *"should a merge-safe
|
||||
firm-skeleton (with letterhead) be restored later it is preferred again automatically"*
|
||||
(`submission_drafts.go:1352-1354`). So this needs **no handler change** — drop in the file and the
|
||||
guard picks it. Output: HL-styled Rubrum + letterhead via the existing merge path.
|
||||
*Cost:* author one .docx body (the styles/letterhead already exist in the base); smallest blast
|
||||
radius; reuses the entire existing merge pipeline and the `caption.*` keys.
|
||||
|
||||
- **Option C — graft real styles into `BuildFallbackSkeleton`.**
|
||||
Embed the firm `styles.xml` + theme + letterhead into the in-process builder and map its
|
||||
paragraphs to HLpat styles. Keeps a code-only last-resort that is also styled. *Cost:* duplicates
|
||||
the base's 89 KB styles.xml into Go and must track it on every firm-template change — more
|
||||
maintenance than B for the same visual result. Only worth it if we want even the never-reached
|
||||
last-ditch tier styled.
|
||||
|
||||
**Recommendation:** **B first** (tracer bullet — slots into the existing tier-4 hook, styled Rubrum
|
||||
this iteration), **A as the strategic target** (full Composer-driven generation with seeded
|
||||
sections). C is not recommended.
|
||||
|
||||
### P3(b) — UNFILLED (`[KEIN WERT]`) — confirmed cause + a pinned resolver-wiring gap
|
||||
|
||||
**Confirmed cause: no project context.** The draft was started "Ohne Projekt" → `project_id` NULL.
|
||||
In `SubmissionVarsService.Build` (`internal/services/submission_vars.go:125-216`), the project-less
|
||||
branch is **lines 171-178**:
|
||||
|
||||
```go
|
||||
if in.ProjectID == nil {
|
||||
// Project-less draft (t-paliad-243): no project / parties / deadline state to resolve.
|
||||
out.Placeholders = docforge.NewResolverSet(resolvers...).BuildBag()
|
||||
return out, nil
|
||||
}
|
||||
```
|
||||
|
||||
`resolvers` at that point is **only** `firmResolver, todayResolver, userResolver,
|
||||
proceduralEventResolver` (lines 158-163). It does **not** include `projectResolver`,
|
||||
**`captionResolver`**, `partiesResolver`, or `deadlineResolver` (those are appended only on the
|
||||
project branch, lines 203-208). So `caption.*`, `project.*`, `parties.*` are never populated →
|
||||
every one renders `[KEIN WERT: …]`. firm/user/today DID fill (letterhead correct) — consistent with
|
||||
m's screenshot.
|
||||
|
||||
**The resolver-wiring gap (pinned).** This is the important finding the task asked for. Even with
|
||||
no project, the draft has a `submission_code`, and:
|
||||
|
||||
- the `rule` is **already loaded** on this path (`loadPublishedRule`, line 138 — *before* the
|
||||
`ProjectID == nil` check), and
|
||||
- `DeadlineRule.ProceedingTypeID *int` is populated (`internal/models/…:114`; `proceeding_type_id`
|
||||
is in `ruleColumns`, `deadline_rule_service.go:42`), and
|
||||
- `resolveCaption(p, pt)` is a **pure function that tolerates a nil project**
|
||||
(`submission_vars.go:453-533`): it guards `if pt != nil` (464) and `if p != nil` (506). With `pt`
|
||||
set and `p == nil` it resolves **heading / subject / claimant_designation / defendant_designation
|
||||
/ versus** from the proceeding alone (jurisdiction + dotted-code "nature" segment + the mig-137
|
||||
role-label overrides). Only the *appeal/cassation* designation upgrade needs
|
||||
`project.instance_level`, which simply falls back to the civil default when absent.
|
||||
|
||||
**So `caption.heading_de` is empty on the project-less path purely because the branch never loads
|
||||
the ProceedingType from `rule.ProceedingTypeID` and never runs `captionResolver` — not because the
|
||||
data is missing.** The submission_code *does* tie the draft to a proceeding; the wiring just doesn't
|
||||
use it. This is a genuine gap, independently fixable from the product decision below.
|
||||
|
||||
Minimal fix shape (for the coder, if m chooses to fill-what-we-can): in the `ProjectID == nil`
|
||||
branch, `pt, _ := s.loadProceedingType(ctx, rule.ProceedingTypeID)` (the helper already tolerates a
|
||||
nil id, lines 275-291) and append `captionResolver{project: nil, pt: pt, lang}` (+ optionally a
|
||||
proceeding-only `projectResolver` for `project.proceeding.name`). That fills the caption wording and
|
||||
the proceeding line; party names / case number / court stay blank.
|
||||
|
||||
#### → FORK (iii): require-Akte vs fill-what-we-can
|
||||
|
||||
- **Option 1 — require an Akte/project to draft.** A draftable submission must be bound to a
|
||||
project (drop or disable "Ohne Projekt", or block generation until linked). Rubrum is *fully*
|
||||
filled (project + parties + caption all present). Simplest correctness, but removes the
|
||||
quick project-less drafting affordance t-paliad-243 deliberately added.
|
||||
- **Option 2 — fill-what-we-can on a project-less draft (RECOMMENDED, pairs with the wiring fix).**
|
||||
Keep "Ohne Projekt" but resolve `caption.*` (heading / designations / versus / subject) and the
|
||||
proceeding line from the submission_code's proceeding, leaving only party names / case number /
|
||||
court blank for the lawyer. The Rubrum then renders with correct procedural wording instead of
|
||||
five `[KEIN WERT]`s; the genuinely project-specific blanks remain honest gaps. This is the
|
||||
resolver-wiring fix above; it does not require the require-Akte product change.
|
||||
|
||||
**Recommendation:** **Option 2** — it's the smaller change, preserves the t-paliad-243 affordance,
|
||||
and turns a wall of `[KEIN WERT]` into a real (if partial) Rubrum. m may *additionally* prefer
|
||||
"start from Akte" as the *primary* CTA for a fully-filled document, but that's a UX nudge, not a
|
||||
hard gate.
|
||||
|
||||
---
|
||||
|
||||
## Sliced fix-plan (tracer-bullet first)
|
||||
|
||||
Ordered cheap → meaty. Each slice is independently shippable.
|
||||
|
||||
1. **P1 data-fix (quick, data-only).** Re-kind the 8 anchor rows
|
||||
(`event_type='decision'`, `primary_party='court'`) in `paliad.deadline_rules_unified`. No code.
|
||||
Confirm the deadline engine doesn't trigger off `event_type='filing'` for them first (expected
|
||||
safe — anchoring is `parent_id`). *Optionally* add the `primary_party IS DISTINCT FROM 'court'`
|
||||
guard to `loadSubmissionCatalog` as belt-and-braces.
|
||||
2. **P2 layout (quick, CSS-only).** Strengthen `.entity-table-group-header th`
|
||||
(`global.css:6066-6077`) so each proceeding band is visually distinct from the column header.
|
||||
Verify the deployed `submissions-new.js` bundle is current.
|
||||
3. **P3(b) fill-what-we-can (small, Go).** Wire `captionResolver` (and a proceeding-only
|
||||
`projectResolver`) into the project-less branch of `SubmissionVarsService.Build` using
|
||||
`rule.ProceedingTypeID`. Turns the Rubrum wording from `[KEIN WERT]` into resolved procedural
|
||||
labels; party/case/court stay blank. (Decide FORK iii first — this *is* Option 2.)
|
||||
4. **P3(a) styling — tracer bullet (Option B, medium).** Author a merge-safe styled firm-skeleton
|
||||
(firm-skeleton part-set + a `{{key}}`/`{{caption.*}}` Rubrum body in `HLpat-Table-Recitals-*` /
|
||||
`HLpat-Heading-*` / `HLpat-Signature`). Drops into the existing tier-4 hook — no handler change.
|
||||
Combined with slice 3, the project-less draft now renders an HL-styled, partially-filled Rubrum.
|
||||
5. **P3(a) styling — strategic (Option A, meaty, later).** Route generation through the Composer
|
||||
(`compose.go`) off the firm-skeleton base with section seeds, for fully-styled documents with
|
||||
seeded section bodies. Larger change; do after slices 1-4 validate.
|
||||
|
||||
---
|
||||
|
||||
## The three forks for m
|
||||
|
||||
- **(i) P1 — data-fix vs filter:** → **data correction** (re-kind the 8 anchor rows to
|
||||
`decision`/`court`; existing filter then excludes them). A picker-filter alone *cannot* isolate
|
||||
them (`primary_party='both'`, shared with real submissions). Optional `primary_party<>'court'`
|
||||
guard as hardening.
|
||||
- **(ii) P3 styling — Composer vs merge-safe styled skeleton vs grafted fallback:** → **Option B**
|
||||
(merge-safe styled firm-skeleton) as the tracer bullet; **Option A** (Composer-driven) as the
|
||||
strategic target. The styled base already carries all HL Patents Style + Rubrum styles — the work
|
||||
is a styled body, not new styles.
|
||||
- **(iii) P3 fill — require-Akte vs fill-what-we-can:** → **Option 2** (fill caption.* from the
|
||||
submission_code's proceeding on project-less drafts; leave party/case/court blank). Pairs with the
|
||||
pinned resolver-wiring fix.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — evidence index
|
||||
|
||||
- Picker filter: `internal/handlers/submissions.go:177-211`.
|
||||
- P1 affected rows + the 16-row correct `decision`/`court` tier + `parent_id` anchoring:
|
||||
Supabase MCP read-only on `paliad.deadline_rules_unified` (2026-06-01).
|
||||
- P2 group header: `frontend/src/client/submissions-new.ts:167-174` (commit `a911a2d`, 2026-05-23);
|
||||
CSS `frontend/src/styles/global.css:6066-6109`; `--color-bg-subtle:#f7f3f0` (`:28`), same as
|
||||
`thead` (`:7979`); auth-gated render not directly observed.
|
||||
- P3(a) unstyled fallback: `pkg/docforge/docx/fallback_skeleton.go:53-89,121-138,215-269`;
|
||||
resolver chain + merge-safe guard `internal/handlers/submission_drafts.go:1359-1408` (esp.
|
||||
1352-1354, 1378/1384/1388/1395).
|
||||
- P3(a) styled base: `HL/mWorkRepo:6 - material/Templates/Word/Paliad/HLC/_firm-skeleton.docx`
|
||||
(fetched + unzipped: full HLpat styles incl. `HLpat-Table-Recitals-*`; anchors-only body);
|
||||
Composer `pkg/docforge/docx/compose.go`; registry `internal/handlers/files.go:94-101`.
|
||||
- P3(b) project-less gap: `internal/services/submission_vars.go:125-216` (branch 171-178),
|
||||
`resolveCaption` 453-533, `loadProceedingType` 275-291, `rule` load 138; `DeadlineRule.
|
||||
ProceedingTypeID` (`internal/models/…:114`, `ruleColumns` `deadline_rule_service.go:42`).
|
||||
306
docs/design-rubrum-letterhead-autofill-2026-06-01.md
Normal file
306
docs/design-rubrum-letterhead-autofill-2026-06-01.md
Normal file
@@ -0,0 +1,306 @@
|
||||
# Rubrum + Briefkopf auto-fill — gap map (templates ↔ var bag ↔ data model)
|
||||
|
||||
**Task:** t-paliad-357 · **Author:** kepler (researcher) · **Date:** 2026-06-01
|
||||
**Status:** AUDIT + GAP-MAP ONLY. No code or template edits made. Head reviews
|
||||
before any wiring.
|
||||
|
||||
m's ask (2026-06-01 12:01): the **current** document templates should fill the
|
||||
**letterhead (Briefkopf)** and **recitals/Rubrum (case caption)** from project
|
||||
data on generation — "filled depending on project". This is the
|
||||
content-correctness layer, downstream of the (code-complete) docforge engine and
|
||||
parallel to leibniz's nomen naming train.
|
||||
|
||||
---
|
||||
|
||||
## TL;DR — the one fork for m
|
||||
|
||||
**The basic Rubrum is *already* wired and works today** (party names,
|
||||
representatives, role designations, case number, court, patent number — all
|
||||
data-driven, in both the demo per-code template and the Composer caption seed).
|
||||
**The letterhead is *not* data-driven at all** (the real HL letterhead is
|
||||
hardcoded inside the firm-skeleton's Word header/footer parts; `firm.signature_block`
|
||||
is empty). And the Rubrum we have is only a *basic* caption — a forum-correct one
|
||||
needs structured data paliad does not capture.
|
||||
|
||||
So the decision is **how complete a Rubrum we target**:
|
||||
|
||||
| | **Option A — wire what data already supports** | **Option B — forum-correct Rubrum** |
|
||||
|---|---|---|
|
||||
| Rubrum content | name · representative · role · case no. · court · patent | + structured address · Rechtsform · Sitz (registered office) · gesetzl. Vertreter · service addresses · court chamber/address |
|
||||
| Data model | **no new columns** — uses existing `parties.*` + `project.*` | **new structured fields** on `parties` (+ maybe `projects`) + capture UI |
|
||||
| Letterhead | tidy the existing path (firm.name/signature_block) | same as A (letterhead is orthogonal to the A/B choice) |
|
||||
| Effort | small — mostly template-seed wording + plug `firm.signature_block` | a proper feature — schema migration + party-form rework + Composer reseed |
|
||||
| Forum-correctness | a *workable* caption, not a *filing-correct* one | meets UPC RoP r.13 / ZPO §253 party-designation requirements |
|
||||
|
||||
Everything in Slice 1–2 below is Option A and is independent of the decision.
|
||||
Option B is Slice 3+ and is the part that needs m's go/no-go.
|
||||
|
||||
---
|
||||
|
||||
## 1. Architecture — there are THREE fill realities, not one
|
||||
|
||||
The audit's biggest correction to the starting mental model: "the templates" are
|
||||
not one thing, and the letterhead does **not** live where the Rubrum lives.
|
||||
|
||||
### Path 1 — legacy one-click `/generate` → `merge.go` (`SubmissionRenderer.Render`)
|
||||
- Handler `submissions.go:316` → `resolveSubmissionTemplate` → `RenderProjectSubmission`
|
||||
→ `renderer.Render` (`pkg/docforge/docx/merge.go`).
|
||||
- **Substitutes `{{key}}` tokens in `word/document.xml` *and* in `word/header*.xml`
|
||||
/ `word/footer*.xml`** (`isWordXMLEntry`, merge.go:189). So this path *can* fill a
|
||||
letterhead in a Word header — **if the header contains `{{placeholders}}`. None
|
||||
of the shipped headers do** (see §2).
|
||||
- Template chosen by a 6-tier fallback (`submission_drafts.go:1341`): per-(code,lang)
|
||||
→ per-code → EN-skeleton → firm-skeleton → universal-skeleton → HL-Patents-Style.
|
||||
|
||||
### Path 2 — Composer → `compose.go` (`Composer.Compose`)
|
||||
- Draft editor with a `base_id` set (t-paliad-313/315/317). Handler
|
||||
`submission_drafts.go:712` → `submissionComposer.Compose`.
|
||||
- Assembles `word/document.xml` from the draft's **`paliad.submission_sections`
|
||||
rows** (one per section: letterhead, caption, …), splicing each into the
|
||||
carrier's `{{#section:KEY}}` anchor, then substitutes `{{placeholder}}` inside the
|
||||
section bodies.
|
||||
- **Headers/footers pass through byte-for-byte UNTOUCHED** (compose.go:68, :188).
|
||||
So a Composer doc keeps the base .docx's letterhead chrome verbatim — it is
|
||||
never data-driven on this path.
|
||||
- Section bodies are seeded on draft-create from the base's
|
||||
`section_spec.defaults[*].seed_md_{de,en}` (migrations 146 / 150).
|
||||
|
||||
### Path 3 — skeleton as a direct merge fallback (a latent bug)
|
||||
- For any submission_code **without** a per-code template, `/generate` (Path 1)
|
||||
falls through to tiers 4/5 and renders the **firm/universal skeleton through
|
||||
merge.go**. But those skeletons contain only `{{#section:letterhead}}`-style
|
||||
*block markers*, which `placeholderRegex` (`[A-Za-z]…`) does **not** match (they
|
||||
start with `#`). **Result: the output Word doc shows literal
|
||||
`{{#section:letterhead}}` … text.** Only `de.inf.lg.erwidg` has a real per-code
|
||||
template today, so every other code's one-click `/generate` is exposed to this.
|
||||
⚠️ **Flag to verify with head** — may be masked if `/generate` is only surfaced
|
||||
for codes that have a per-code template.
|
||||
|
||||
> **Implication for m's ask:** "fill the letterhead from project data" means
|
||||
> different work on each path. On Path 1 it means *putting `{{firm.*}}` placeholders
|
||||
> into a header part*. On Path 2 it means *the letterhead is a body section already*
|
||||
> (and the chrome stays hardcoded in the base). These should be reconciled, not
|
||||
> both wired blindly — see Slice 2.
|
||||
|
||||
---
|
||||
|
||||
## 2. Gap table — TEMPLATE side
|
||||
|
||||
Fetched live from mgit (`m/mWorkRepo`, `6 - material/Templates/Word/…`), unzipped,
|
||||
inspected `document.xml` + every `header*.xml`/`footer*.xml`.
|
||||
|
||||
| Template | Has header/footer? | Letterhead | Rubrum / caption | Verdict |
|
||||
|---|---|---|---|---|
|
||||
| **`HLC/de.inf.lg.erwidg.docx`** (per-code, the only wired code) | no | *pseudo*-letterhead inline in body: `{{firm.name}} — Patentstreitsachen`, Bearbeiter `{{user.display_name}}`, `{{user.email}}`, `{{user.office}}`, `{{today.long_de}}`. `{{firm.signature_block}}` in closing (renders empty). | **full inline Rubrum, all data-driven**: `{{parties.claimant.name}}` / `.representative`, `— Klägerin —`, `gegen`, `{{parties.defendant.name}}` / `.representative`, `— Beklagte —`, `Weitere Beteiligte: {{parties.other.name}}`, `{{project.court}}`, `Aktenzeichen: {{project.case_number}}`, `{{project.patent_number}}`. | Works — but body-banner is **labelled "DEMO — interne Vorlage (nicht freigegeben)"**, not a real letterhead. |
|
||||
| **`HLC/_firm-skeleton.docx`** (Composer base `hlc-letterhead`) | **yes** — header1/2, footer1/2 | **Real HL letterhead, fully HARDCODED**: footer firm name is a Word SDT content-control literal "Hogan Lovells"; footer2 = static HL entity boilerplate (registered office, 50+ office cities); header2 = logo image only. **Zero `{{placeholders}}` in any header/footer.** | body `document.xml` has only `{{#section:KEY}}` markers (empty). Caption content comes from the section seed (§Composer). | Letterhead present but **not data-driven & not firm-agnostic** (contradicts `branding.Name` goal). |
|
||||
| **`HLC/_skeleton.docx`** (Composer base `neutral`) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; unusable via merge.go (Path 3 bug). |
|
||||
| **`Composer/lg-duesseldorf.docx`** (base `lg-duesseldorf`, de.inf.lg) | no | none | `{{#section:KEY}}` markers only | Composer scaffold; letterhead must come from a header it doesn't have, or the body section. |
|
||||
| **`Composer/upc-formal.docx`** (base `upc-formal`, upc.inf.cfi) | no | none | `{{#section:KEY}}` markers only | same. |
|
||||
| **`HL Patents Style.dotm`** (last-ditch tier 6) | yes (same HL header/footer as firm-skeleton) | hardcoded HL letterhead | no placeholders | letterhead-only fallback. |
|
||||
| `HLC/_skeleton.en.docx` | **404 — does not exist** | — | — | EN drafts silently fall back to the DE skeleton (matches code comment at files.go:104). |
|
||||
|
||||
**Template-side takeaways**
|
||||
1. The Rubrum is template-complete on the demo per-code path and is a DB seed (not
|
||||
a template file) on the Composer path.
|
||||
2. The real letterhead exists only in the firm-skeleton/`.dotm` headers and is
|
||||
**100% hardcoded** — no placeholder, no `branding.Name`. A firm rename or a
|
||||
non-HLC deployment ships the wrong letterhead.
|
||||
3. The Composer caption/letterhead are **DB seeds (migrations 146/150)**, so
|
||||
"adjusting the template" for the Composer path means editing the
|
||||
`section_spec` seed Markdown, *not* the .docx.
|
||||
|
||||
---
|
||||
|
||||
## 3. Gap table — VAR-BAG side
|
||||
|
||||
For every placeholder a correct letterhead + Rubrum needs, is there a bag key?
|
||||
Bag built in `internal/services/submission_vars.go`.
|
||||
|
||||
| Need (letterhead + Rubrum) | Bag key | Status |
|
||||
|---|---|---|
|
||||
| Firm name | `firm.name` (← `branding.Name`) | ✅ wired |
|
||||
| Firm signature block | `firm.signature_block` | ⚠️ **key exists but emits `""`** (reserved "Phase 2", submission_vars.go:324). Template references it → renders blank. |
|
||||
| Author name / email / office | `user.display_name` / `.email` / `.office` | ✅ wired |
|
||||
| Date (today, long DE/EN, ISO) | `today` / `.long_de` / `.long_en` / `.iso` | ✅ wired |
|
||||
| Claimant name / rep (first + indexed + joined) | `parties.claimant.name`, `parties.claimant.0.name` / `.representative`, `parties.claimants` / `.representatives` | ✅ wired (3 forms, addPartyVars) |
|
||||
| Defendant name / rep | `parties.defendant.*` (same 3 forms) | ✅ wired |
|
||||
| Other parties (Streithelfer, Patentinhaberin…) | `parties.other.*` / `parties.others` | ✅ wired |
|
||||
| Case number | `project.case_number` | ✅ wired |
|
||||
| Court (name) | `project.court` | ✅ wired (free-text string) |
|
||||
| Patent number (DE + UPC forms) | `project.patent_number` / `.patent_number_upc` | ✅ wired |
|
||||
| Proceeding type / instance | `project.proceeding.name(_de/_en/.code)`, `project.instance_level` | ✅ wired |
|
||||
| Our side (DE/EN prose) | `project.our_side_de` / `_en` / raw | ✅ wired |
|
||||
| Client / matter / internal ref | `project.client_number` / `.matter_number` / `.reference` | ✅ wired |
|
||||
| **Party postal address** | — | ❌ **NO key** (needs data model) |
|
||||
| **Party legal form (Rechtsform)** | — | ❌ **NO key** |
|
||||
| **Party registered office / Sitz** | — | ❌ **NO key** (UPC r.13.1(a)/(b)) |
|
||||
| **Statutory representative (gesetzl. Vertreter, e.g. Geschäftsführer)** | — | ❌ **NO key** |
|
||||
| **Address/person for service (Zustellungsbevollmächtigter)** | — | ❌ **NO key** (UPC r.13.1(c)/(d)) |
|
||||
| **Court full address / chamber / Spruchkörper** | — | ❌ **NO key** (only the court *name* string exists) |
|
||||
| **Firm letterhead address / contact block** | — | ❌ **NO key** (hardcoded in .docx header) |
|
||||
|
||||
**Var-bag takeaways:** every placeholder the *current* templates use is wired,
|
||||
with one dud: **`firm.signature_block` always renders empty** — the single cheapest
|
||||
letterhead/closing win. Everything a *forum-correct* Rubrum additionally needs has
|
||||
**no key, because the data isn't captured** (§4).
|
||||
|
||||
---
|
||||
|
||||
## 4. Gap table — DATA-MODEL side
|
||||
|
||||
`models.Party` (models.go:539) carries **only**: `Name`, `Role`, `Representative`,
|
||||
`ContactInfo json.RawMessage`. `models.Project` carries `Court *string` (free text),
|
||||
`CaseNumber`, `PatentNumber`, dates, `OurSide`, `InstanceLevel`, client/matter.
|
||||
|
||||
- **`parties.contact_info` is a dormant jsonb column**: `PartyService.Create`
|
||||
defaults it to `{}` and **no UI ever writes it** (party form captures only
|
||||
Name / Role / Representative — `frontend/src/projects-detail.tsx:436–460`). It is
|
||||
a ready-made parking spot, but it is structurally empty today.
|
||||
- **No court registry / court-address table exists.** `project.court` is a plain
|
||||
string a user types.
|
||||
|
||||
| Forum-correct Rubrum needs | Derivable from existing fields? | Park in `contact_info` jsonb? | Needs new column + capture UI? | Cost |
|
||||
|---|---|---|---|---|
|
||||
| Party **postal address** | ❌ | ✅ feasible (`{address:{street,zip,city,country}}`) | UI: add fields to party form | **Low–Med** — jsonb, no migration; party-form + bag resolver |
|
||||
| Party **Rechtsform** (GmbH, LLP…) | ❌ (sometimes inside Name string, unreliable) | ✅ | UI field | **Low** |
|
||||
| Party **Sitz / registered office** (UPC r.13.1(a/b)) | ❌ | ✅ | UI field | **Low–Med** |
|
||||
| Party **statutory representative** (Geschäftsführer / vertreten durch …) | ⚠️ partial — `Representative` today means the *lawyer/Prozessbevollmächtigter*, not the *organ*; conflating them is wrong | ✅ (`{statutory_rep:…}`) | UI field + relabel existing `representative` | **Med** — semantic untangle |
|
||||
| **Address for service / Zustellungsbevollmächtigter** (UPC r.13.1(c/d)) | ❌ | ✅ | UI field | **Low–Med** |
|
||||
| **Court full address** | ❌ | n/a (project-level) | new `projects.court_address` col **or** a courts lookup table | **Med** (col) / **High** (registry) |
|
||||
| **Court chamber / Spruchkörper / panel** | ❌ | n/a | new `projects.court_chamber` col | **Low–Med** |
|
||||
| Firm letterhead address block | ❌ | n/a | `branding`-level config (env or table) | **Med** — touches firm-agnostic story |
|
||||
|
||||
**Recommendation on storage:** structured party attributes belong in **typed jsonb
|
||||
under `contact_info`** with a small Go struct (`models.PartyContact`) decoding it —
|
||||
not a column-per-attribute migration. It keeps the party table stable, is
|
||||
forum-shape-agnostic, and the bag resolver can emit `parties.claimant.0.address`,
|
||||
`.sitz`, `.rechtsform`, etc. Court chamber/address are project-level and small
|
||||
enough for two nullable columns; a full court **registry** is a separate, larger
|
||||
feature (nice for autofill + validation, not required for a correct caption).
|
||||
|
||||
---
|
||||
|
||||
## 5. Forum-dependence — does one parametric Rubrum cover UPC / LG / OLG / BPatG?
|
||||
|
||||
Grounded sources: **UPC RoP Rule 13** ("Contents of the Statement of claim") pulled
|
||||
verbatim from the house laws corpus (`data.laws`, `UPCRoP.013.*`). German ZPO/PatG
|
||||
caption conventions below are **standard German civil-procedure practice — these are
|
||||
NOT in the youpc corpus** (which is UPC/EPC-only), so they are flagged as
|
||||
practitioner-convention, to be confirmed by a DE-litigation reviewer (lexy) before
|
||||
wording is finalised.
|
||||
|
||||
**What UPC RoP r.13.1 demands (verified):**
|
||||
- (a) claimant name; if corporate, **location of registered office**; + claimant's representative
|
||||
- (b) defendant name; if corporate, **location of registered office**
|
||||
- (c) **postal + electronic addresses for service** on claimant + persons authorised to accept service
|
||||
- (d) postal/electronic service addresses on defendant + persons authorised, if known
|
||||
- (e) proprietor service addresses where claimant ≠ (sole) proprietor
|
||||
- (g) details of the patent including the **number**
|
||||
- (k) nature of the claim / order / remedy sought
|
||||
|
||||
→ paliad today supplies only **name** (a/b) and **patent number** (g). It captures
|
||||
**none** of: registered office/Sitz, postal/electronic service address, persons
|
||||
authorised. So a *filing-correct* UPC caption is firmly **Option B** territory.
|
||||
|
||||
**How the caption shape differs across forums (convention):**
|
||||
|
||||
| Forum | Heading | Party designations | "wegen" / subject | Court line |
|
||||
|---|---|---|---|---|
|
||||
| **DE LG** (Patentstreitkammer) | "In dem Rechtsstreit" / "In der Patentstreitsache" | Kläger(in) / Beklagte(r); parties need **Name, Anschrift, Rechtsform, ges. Vertreter** (ZPO §253 Abs. 2 Nr. 1, §130 Nr. 1 — *convention*) | "**wegen** Patentverletzung" | "an das Landgericht … , … Kammer" — court **name + chamber** |
|
||||
| **DE OLG** (Berufung) | "In dem Rechtsstreit" | **Berufungskläger / Berufungsbeklagte** (roles flip vs. first instance) | "wegen …" | "an das Oberlandesgericht …, … Senat" |
|
||||
| **BPatG** (Nichtigkeit/Beschwerde) | "In der Patentnichtigkeitssache" / "Beschwerdesache" | **Kläger/Beklagte** (nullity) or **Anmelder/Einsprechende**; patent-centric | patent + nullity ground | "an das Bundespatentgericht, … Senat" |
|
||||
| **UPC CFI** | "In the matter / In der Sache" | **Claimant / Defendant (Kläger/Beklagte)**; name + **registered office** + service address (r.13) | claim nature (r.13.1(k)) | division + "Aktenzeichen" (UPC case-number format `ACT_xxxxx/2026`) |
|
||||
|
||||
**Answer:** one *parametric* Rubrum block covers the **basic** caption across forums
|
||||
(swap designation labels + heading + court line from `our_side`/`instance_level`/
|
||||
`proceeding.code` — values the bag already has). It does **not** cover the
|
||||
forum-specific *content requirements* (UPC service addresses vs. ZPO Anschrift/
|
||||
Rechtsform vs. BPatG patent-centric framing). For Option B, the cleanest design is
|
||||
**one caption section whose seed Markdown is chosen per `proceeding_family`** (the
|
||||
Composer already keys bases by `proceeding_family` — `de.inf.lg`, `upc.inf.cfi`),
|
||||
i.e. **forum-specific caption seeds, shared resolver keys** — not a single
|
||||
universal block, and not N hand-maintained .docx files.
|
||||
|
||||
---
|
||||
|
||||
## 6. Sliced wiring proposal (tracer-bullet first)
|
||||
|
||||
Ordered so each slice ships value alone; the A/B fork only bites at Slice 3.
|
||||
|
||||
**Slice 1 — plug the empty letterhead key (pure win, no schema, no fork).**
|
||||
- Fill `firm.signature_block` in `addFirmVars` from `branding` (firm name + office /
|
||||
a configured block) instead of hardcoding `""`. Today every template that
|
||||
references it renders blank.
|
||||
- Decide letterhead source of truth: either (a) inject `{{firm.name}}` /
|
||||
`{{firm.address}}` placeholders into the firm-skeleton **header** parts (Path 1
|
||||
fills them; Composer leaves them — acceptable since chrome is firm-fixed), or
|
||||
(b) keep chrome hardcoded but make it firm-agnostic via `branding`. **Recommend
|
||||
(a)** so a firm rename / non-HLC deploy doesn't ship "Hogan Lovells".
|
||||
- Template edits: firm-skeleton `header1/footer1` get `{{firm.*}}` tokens. (mWorkRepo,
|
||||
authored as mAi — not this repo.)
|
||||
|
||||
**Slice 2 — reconcile the letterhead duplication + kill the Path-3 junk.**
|
||||
- The Composer seeds a body "letterhead" section *and* the base has a header
|
||||
letterhead → a Composer doc can show both. Decide: drop the body letterhead
|
||||
section for letterhead-bearing bases, or keep it only for `neutral`.
|
||||
- Fix Path 3: either give the universal/firm skeleton a **merge-safe** variant
|
||||
(real `{{key}}` Rubrum like the demo template) for non-Composer `/generate`, or
|
||||
gate `/generate` to codes that have a per-code template. (Verify with head which
|
||||
codes expose `/generate`.)
|
||||
|
||||
**Slice 3 — Option A "good basic Rubrum" (no new data).**
|
||||
- Promote the demo per-code Rubrum wording into a **published, forum-labelled
|
||||
caption** and align the Composer caption seeds (146/150) to the same wording.
|
||||
Parametrise designation labels + heading + "wegen" + court line off
|
||||
`our_side` / `instance_level` / `proceeding.code`. **No migration.**
|
||||
- This is the natural stopping point if m picks **A**.
|
||||
|
||||
**Slice 4 — Option B data model (the feature; needs m's go).**
|
||||
- Add `models.PartyContact` decoding typed `contact_info` jsonb:
|
||||
`{address, rechtsform, sitz, statutory_rep, service_address, service_agent}`.
|
||||
- Extend the party form (`projects-detail.tsx`) with those inputs; `PartyService`
|
||||
writes them.
|
||||
- Add `projects.court_address` + `projects.court_chamber` (nullable cols).
|
||||
- New bag keys in `addPartyVars` / `addProjectVars`:
|
||||
`parties.<role>.<i>.address|sitz|rechtsform|statutory_rep|service_address`,
|
||||
`project.court_address|court_chamber`.
|
||||
|
||||
**Slice 5 — Option B forum-correct caption seeds.**
|
||||
- Per-`proceeding_family` caption seed Markdown (UPC r.13 shape, DE-LG ZPO shape,
|
||||
OLG appeal-role shape, BPatG nullity shape), consuming the Slice-4 keys.
|
||||
- Reviewer (lexy) signs off DE conventions before publish.
|
||||
|
||||
**Slice 6 (optional) — court registry** for autofill/validation of court
|
||||
name+address+chamber. Larger; not required for a correct caption.
|
||||
|
||||
---
|
||||
|
||||
## 7. Key files (for the wiring worker)
|
||||
|
||||
- Var bag: `internal/services/submission_vars.go` (addFirmVars:319, addPartyVars:412,
|
||||
addProjectVars:349).
|
||||
- Render (Path 1, fills headers): `pkg/docforge/docx/merge.go` (isWordXMLEntry:189).
|
||||
- Compose (Path 2, headers pass-through): `pkg/docforge/docx/compose.go` (:68,:188);
|
||||
`internal/services/submission_compose.go`.
|
||||
- Template resolution: `internal/handlers/submission_drafts.go:1341`
|
||||
(`resolveSubmissionTemplate`, 6 tiers); paths in `internal/handlers/files.go`.
|
||||
- Composer base seeds (caption/letterhead Markdown): migrations
|
||||
`internal/db/migrations/146_submission_bases.up.sql`,
|
||||
`150_submission_bases_specialist.up.sql`.
|
||||
- Data model: `internal/models/models.go` (Party:539, Project:80);
|
||||
party form `frontend/src/projects-detail.tsx:436`.
|
||||
- Live templates: `m/mWorkRepo` `6 - material/Templates/Word/Paliad/{HLC,Composer}/`.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions for m / head
|
||||
|
||||
1. **A or B?** (the §TL;DR fork). A = ship a good basic caption now, no data work.
|
||||
B = capture structured party/court data for a filing-correct Rubrum.
|
||||
2. **Letterhead source of truth:** placeholderise the firm-skeleton header (firm-agnostic)
|
||||
vs. keep hardcoded HL chrome? (Slice 1 recommends placeholderise.)
|
||||
3. **Path-3 junk:** is one-click `/generate` exposed for codes lacking a per-code
|
||||
template? If yes, the literal `{{#section:…}}` output is a live bug.
|
||||
4. **`representative` semantics:** today it's the lawyer (Prozessbevollmächtigter).
|
||||
A forum Rubrum also needs the party's *statutory* representative (Geschäftsführer).
|
||||
Keep them as two distinct fields under Option B.
|
||||
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
495
docs/plans/prd-docforge-2026-05-29.md
Normal file
@@ -0,0 +1,495 @@
|
||||
# PRD — `docforge`: a modular document-generator engine
|
||||
|
||||
**Task:** t-paliad-349 (m/paliad#157) · **Author:** leibniz (inventor) · **Date:** 2026-05-29
|
||||
**Status:** DESIGN — awaiting head's go/no-go on the coder shift.
|
||||
**Supersedes nothing.** Extends and re-homes the submission generator designed in
|
||||
`docs/design-submission-generator-2026-05-19.md`, `…-v2-2026-05-26.md`, and
|
||||
`docs/design-submission-page-2026-05-22.md`.
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises
|
||||
|
||||
### 0.1 What this is
|
||||
|
||||
m wants the paliad "doc generator" pulled apart into a clean, reusable engine.
|
||||
Verbatim direction (2026-05-29):
|
||||
|
||||
> I want to be able to create and modify word documents, using variables inside
|
||||
> the documents, "editing them live" and preview the results, export in the end.
|
||||
> We should have all that modular to keep it clean. The editor is something else
|
||||
> than the importing, exporting, variable exchange, data fetching etc.
|
||||
>
|
||||
> Currently I can't upload the base document to insert variables into to create a
|
||||
> template — and then later I want to fill the template using data, modifying it
|
||||
> manually where necessary, then exporting.
|
||||
|
||||
Two distinct user surfaces fall out of that:
|
||||
|
||||
- **Authoring** — upload a base `.docx` → place variable slots into it → save as a
|
||||
reusable template. *This is the gap that does not exist today.*
|
||||
- **Generation** — pick a template → bind variables to project data → manually edit
|
||||
where needed (live editor + preview) → export `.docx`.
|
||||
|
||||
### 0.2 Today's state (audited 2026-05-29, verified against the live tree)
|
||||
|
||||
The current submission generator is ~250 KB of Go plus a 115 KB editor bundle:
|
||||
|
||||
- `internal/services/submission_vars.go` — variable resolution across **7 namespaces**
|
||||
(`firm.*`, `today.*`, `user.*`, `project.*`, `parties.*`, `procedural_event.*`
|
||||
+ `rule.*` legacy aliases, `deadline.*`). Resolution is a **push** model: each
|
||||
namespace is a hardcoded `addXxxVars(bag PlaceholderMap, …)` function mutating a
|
||||
shared `map[string]string`. There is **no interface and no registry** — adding a
|
||||
namespace means hand-editing `Build` to call a new function.
|
||||
- `internal/services/submission_merge.go` — placeholder substitution. The regex
|
||||
(line 95, verified) is `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`.
|
||||
Two-pass: single-run replace inside each `<w:t>`, then
|
||||
cross-run merge for fragmented placeholders. HTML preview wraps `(key,value)` in
|
||||
Private-Use-Area sentinels so `emitTextWithDraftVars` can reconstruct
|
||||
`<span class="draft-var" data-var="key">…</span>` for click-to-jump.
|
||||
- `internal/services/submission_md.go` — Markdown → OOXML runs. `parseInlineSpans`
|
||||
(lines 393–446) tokenises bold/italic and **preserves `{{…}}` verbatim**.
|
||||
- `internal/services/submission_compose.go` — assembles the final `.docx`: unzip base,
|
||||
render each included section's Markdown to OOXML, splice between
|
||||
`{{#section:KEY}}…{{/section:KEY}}` anchors, patch hyperlink rels, repack, then run
|
||||
the placeholder pass.
|
||||
- `internal/services/submission_{draft,section,building_block,base}_service.go` — the
|
||||
draft/section/building-block/base data model + CRUD.
|
||||
- `internal/handlers/submission_{drafts,sections,building_blocks,bases}.go` — the HTTP
|
||||
wire (the 53 KB `submission_drafts.go` is the bulk).
|
||||
- `frontend/src/client/submission-draft.ts` — the editor UI (**one `.ts` bundle; there is
|
||||
no `submission-draft.tsx`** — the brief was wrong on this point).
|
||||
|
||||
**OOXML approach (verified):** pure `archive/zip` + string manipulation of
|
||||
`word/document.xml`. **No third-party docx library** — `go.mod` has none.
|
||||
`lukasjarosch/go-docx` appears *only in a comment* (`submission_merge.go:13`)
|
||||
documenting why it was rejected (it refuses sibling placeholders in one run). The base
|
||||
stays byte-for-byte identical outside the regions we touch.
|
||||
|
||||
**Reference model:** `pkg/litigationplanner/` (t-paliad-292). The package **owns its
|
||||
types** and exposes **interfaces for stateful inputs** (`Catalog`, `HolidayCalendar`,
|
||||
`CourtRegistry`); paliad implements them against Postgres, youpc.org against an embedded
|
||||
JSON snapshot. `doc.go` is the package doc; `types_wire_test.go` locks the JSON contract.
|
||||
**docforge mirrors this packaging discipline exactly.**
|
||||
|
||||
### 0.3 Premise correction (load-bearing)
|
||||
|
||||
The brief lists **two consumers in scope: paliad + upc-commentary**. Verified against the
|
||||
live repo: **`UPCommentary/upc-kommentar` is Bun + SvelteKit + TypeScript + PLpgSQL —
|
||||
zero Go.** A SvelteKit app cannot `import` a Go `pkg/`. m's resolution (2026-05-29):
|
||||
**upc-kommentar is out of scope as a live consumer for now.** docforge is a pure Go
|
||||
package; paliad imports it in-process like `litigationplanner`. The interfaces are
|
||||
designed so an HTTP veneer (for a future TS consumer) is *addable later* without rework —
|
||||
but none is built now. See §4 D-P1 and §8.
|
||||
|
||||
### 0.4 Locked constraints (m, confirmed)
|
||||
|
||||
- One Go module: `pkg/docforge`. Same packaging model as `pkg/litigationplanner`.
|
||||
- docforge **owns no database tables** — data flows in via interfaces.
|
||||
- `.docx` first; engine designed format-pluggable for `.pdf`/`.html`/`.md` later.
|
||||
- Authoring and Generation are **distinct pages**, but share the engine + the generic
|
||||
editor plumbing.
|
||||
- Generation must support **minor manual content edits** (live editor, not just
|
||||
data-binding).
|
||||
- Editor stays per-consumer; the **generic UX plumbing** is extracted into a reusable UI
|
||||
package now.
|
||||
- The neutral model must be **lossless for our own `.docx`** (the uploaded base is an
|
||||
opaque carrier, preserved byte-for-byte outside touched regions).
|
||||
|
||||
### 0.5 Contracts that MUST survive the refactor
|
||||
|
||||
These are invariants. The migration (§6) protects each by moving it *with its file and its
|
||||
test*, unchanged:
|
||||
|
||||
1. **`placeholderRegex`** = `` `\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}` `` — underscores
|
||||
and dots legal in keys; whitespace inside braces trimmed; case-sensitive.
|
||||
2. **Last night's underscore fix** (commit `b78a984`): `parseInlineSpans` short-circuits
|
||||
the inline scanner on `{{` and copies the placeholder literally to `}}`, so
|
||||
`{{project.case_number}}` is never mangled to `{{project.casenumber}}`.
|
||||
3. **`data-var` contract** — `data-var="<key>"` on both `.draft-var` preview spans and
|
||||
`.submission-draft-var-input` sidebar inputs; the click-to-jump and focus-highlight are
|
||||
bijective across repaints.
|
||||
4. **Missing-value markers** — `[KEIN WERT: key]` (DE) / `[NO VALUE: key]` (EN) render
|
||||
inline, never an error.
|
||||
5. **Legacy aliases** — `procedural_event.X ≡ rule.X` resolve identically
|
||||
(`submission_vars_aliases_test.go`); party variables emit comma-joined, indexed, and
|
||||
flat-legacy forms (`submission_vars_parties_test.go`).
|
||||
6. **Section anchor syntax** — `{{#section:KEY}}…{{/section:KEY}}`, `KEY` matched against
|
||||
`[A-Za-z0-9_]+`.
|
||||
7. **No binary retention** — exported `.docx` is regenerable from inputs; only audit rows
|
||||
persist (`system_audit_log` `submission.exported` + `project_events`).
|
||||
8. **V1 fallback path** — pre-Composer drafts (`base_id IS NULL`, no section rows) render
|
||||
via the pure-placeholder path. No auto-upgrade.
|
||||
9. **`{{…}}` pass-through** — the Markdown walker emits placeholders verbatim; the merge
|
||||
pass substitutes them afterward. Order is load-bearing (substitution runs *inside*
|
||||
compose, after section splicing).
|
||||
|
||||
---
|
||||
|
||||
## §1 Goals
|
||||
|
||||
**G1.** Extract the format-neutral document machinery (Markdown→OOXML walker, OOXML
|
||||
merge/compose, placeholder engine, `.dotm`→`.docx`) into `pkg/docforge` with a clean
|
||||
public surface and zero behavior change at the extraction step.
|
||||
|
||||
**G2.** Introduce a **neutral document/template model** so importers produce it, the engine
|
||||
binds variables on it, and exporters render it out — with `.docx` as the first
|
||||
importer+exporter pair, not the universe. Lossless for our own `.docx`.
|
||||
|
||||
**G3.** Replace the hardcoded `addXxxVars` push with a **`VariableResolver` interface per
|
||||
namespace** + a `ResolverSet` that composes them, preserves aliases, and exposes the key
|
||||
catalogue (label + group) so the frontend variable form/palette becomes data-driven
|
||||
instead of hardcoded in TS.
|
||||
|
||||
**G4.** Build the **Authoring surface**: upload `.docx` → WYSIWYG render → click/select →
|
||||
insert `{{slot}}` → save template. Closes the gap m named.
|
||||
|
||||
**G5.** Refactor **Generation** onto docforge + uploaded templates, preserving the live
|
||||
editor, preview, manual-edit, and export — and every contract in §0.5.
|
||||
|
||||
**G6.** Extract the **generic editor UX** into `frontend/src/lib/docforge-editor/`,
|
||||
consumed by both the generation and authoring shells.
|
||||
|
||||
**Non-goals (this PRD):** implementation, migration SQL, code. Formats beyond `.docx`
|
||||
(interface only). Live upc-kommentar integration. Multi-user concurrent editing of one
|
||||
draft. An HTTP service veneer.
|
||||
|
||||
---
|
||||
|
||||
## §2 User journeys
|
||||
|
||||
### 2.1 Authoring (new)
|
||||
|
||||
1. m opens **`/admin/templates`** (or `/templates/new`) and uploads a base `.docx`
|
||||
(firm letterhead with caption layout, signature block, etc.).
|
||||
2. docforge's `.docx` importer parses the upload into a **carrier** (opaque OOXML kept
|
||||
intact) + a renderable preview. The page shows a **WYSIWYG-ish render** of the document.
|
||||
3. m highlights a piece of text — e.g. `Az. 4c O 12/23` — and a **variable palette**
|
||||
(sourced from the `ResolverSet.Keys()` catalogue, grouped DE/EN) lets him pick
|
||||
`project.case_number`. The selection is **replaced with a `{{project.case_number}}`
|
||||
slot**; a `template_slots` row records the slot key + its anchor position.
|
||||
4. He repeats for every variable region, saves, and the template becomes pickable in
|
||||
Generation. (Editing the template later creates a new **version** — see §4 D-A3.)
|
||||
|
||||
**Scope guard:** v1 authoring places **text-level slots in body paragraphs**. Slots in
|
||||
headers/footers/tables/text-boxes are a flagged follow-up (§7 note), because the
|
||||
click→OOXML-run mapping there is materially harder.
|
||||
|
||||
### 2.2 Generation (refactor of today)
|
||||
|
||||
1. Lawyer picks a template (uploaded template *or* a legacy Gitea base — both supported
|
||||
during transition) for a submission code, optionally project-scoped.
|
||||
2. A **draft** is created. Its template **structure is snapshotted** at create
|
||||
(§4 D-A3) so later template edits don't shift an in-flight draft.
|
||||
3. The sidebar shows the variable form (data-driven from `ResolverSet.Keys()`); the
|
||||
resolved bag is merged with the lawyer's overrides; the live preview renders with
|
||||
`data-var` click-to-jump; manual prose edits autosave (500 ms debounce).
|
||||
4. Export → docforge binds the model + carrier + resolved variables → `.docx` bytes
|
||||
stream as a download. Audit rows written. No binary retained.
|
||||
|
||||
### 2.3 upc-kommentar parallel journey (deferred — validates the abstractions)
|
||||
|
||||
Not built now, but the abstractions are sized for it: upc-kommentar authors work in
|
||||
**Markdown** (and want to import **foreign doc/docx** as input — m, 2026-05-29 Q4). When
|
||||
it becomes a consumer, it would: implement its own `VariableResolver`(s) over its Postgres
|
||||
(commentary metadata), feed Markdown through docforge's **markdown importer** into the
|
||||
neutral model, edit live in its own Svelte shell (reusing the *wire contract*, not Go
|
||||
code), and export. The Go engine is reached over an HTTP veneer added at that point. This
|
||||
journey is the litmus test for §3's seams: **a new consumer adds resolvers + a transport,
|
||||
touches no engine internals.**
|
||||
|
||||
---
|
||||
|
||||
## §3 Module shape
|
||||
|
||||
### 3.1 Package tree
|
||||
|
||||
```
|
||||
pkg/docforge/
|
||||
doc.go // package doc (litigationplanner-style)
|
||||
model.go // neutral model: Document, Block, InlineSpan, Slot
|
||||
template.go // Template, TemplateSlot, Carrier
|
||||
variables.go // VariableResolver interface, VariableKey, ResolverSet, alias registry
|
||||
bind.go // binding engine: walk model, resolve slots, apply missing-marker policy
|
||||
render.go // RenderHTML (preview w/ data-var spans) — format-neutral entry
|
||||
importer.go // Importer interface
|
||||
exporter.go // Exporter interface
|
||||
store.go // TemplateStore interface (carrier bytes + slot persistence contract)
|
||||
errors.go // sentinel errors (ErrUnknownTemplate, ErrUnboundSlot, …)
|
||||
placeholder.go // placeholderRegex + substitution primitives (THE locked grammar)
|
||||
types_wire_test.go // locks the JSON wire shape consumed by the TS editor
|
||||
docx/ // the .docx adapter — first importer + exporter
|
||||
importer.go // DocxImporter: parse .docx -> Carrier + detect/locate slots
|
||||
exporter.go // DocxExporter: (model + carrier + vars) -> .docx bytes [today's compose+merge]
|
||||
ooxml.go // archive/zip + document.xml manipulation [today's submission_merge/compose internals]
|
||||
md_to_ooxml.go // Markdown -> OOXML runs [today's submission_md walker + the b78a984 fix]
|
||||
dotm.go // ConvertDotmToDocx [today's pre-pass]
|
||||
markdown/ // markdown importer (input content; foreign-docx import is a later sibling)
|
||||
importer.go // parse Markdown -> neutral blocks
|
||||
```
|
||||
|
||||
**What lives in docforge vs paliad:**
|
||||
|
||||
| Concern | Home | Why |
|
||||
|---|---|---|
|
||||
| Neutral model, binding, preview-render | `docforge` | format-neutral core |
|
||||
| `VariableResolver` interface + `ResolverSet` | `docforge` | the seam m wants clean |
|
||||
| Placeholder grammar + substitution | `docforge` | shared invariant (§0.5.1) |
|
||||
| `.docx` importer + exporter, MD→OOXML walker | `docforge/docx` | first format adapter (ships *inside* the pkg, like litigationplanner's embedded snapshot) |
|
||||
| Markdown importer | `docforge/markdown` | input-format adapter |
|
||||
| Concrete resolvers (`project`, `parties`, `firm`, `user`, `today`, `deadline`, `procedural_event`) | **paliad** `internal/…` | they read paliad's DB/services |
|
||||
| `TemplateStore` impl (Postgres bytea) | **paliad** | docforge owns no tables |
|
||||
| Section / building-block model, submission codes | **paliad** | consumer-specific composition concepts |
|
||||
| HTTP handlers, editor UI, authoring page | **paliad** | wire + per-consumer UI |
|
||||
|
||||
### 3.2 The neutral model + the carrier (resolving "intermediate, but lossless docx")
|
||||
|
||||
```go
|
||||
// A Document is the format-neutral content model importers produce and exporters consume.
|
||||
type Document struct {
|
||||
Blocks []Block
|
||||
}
|
||||
type Block struct {
|
||||
Kind BlockKind // paragraph | heading | list_item | blockquote | section_marker
|
||||
Style string // logical style key (mapped to a base stylemap on export)
|
||||
Spans []InlineSpan // text runs (bold/italic/link) + Slots
|
||||
// …list level, section key, etc.
|
||||
}
|
||||
type InlineSpan struct {
|
||||
Text string
|
||||
Bold bool
|
||||
Italic bool
|
||||
Link string
|
||||
Slot *Slot // non-nil => this span is a variable slot, not literal text
|
||||
}
|
||||
type Slot struct {
|
||||
Key string // e.g. "project.case_number" — the placeholder grammar key
|
||||
}
|
||||
```
|
||||
|
||||
**The carrier keeps the lossless guarantee.** The uploaded `.docx` chrome
|
||||
(letterhead, styles, caption, signature) is **never round-tripped through `Document`**.
|
||||
It is held as an opaque `Carrier` (the original OOXML), and the exporter splices the
|
||||
rendered neutral content into the carrier's named anchors, then substitutes slots — exactly
|
||||
today's compose mechanism, now formalised:
|
||||
|
||||
```go
|
||||
type Carrier struct {
|
||||
Format string // "docx"
|
||||
Bytes []byte // original upload, preserved byte-for-byte outside anchor regions
|
||||
Anchors []Anchor // {{#section:KEY}}…{{/section:KEY}} positions + slot positions
|
||||
}
|
||||
```
|
||||
|
||||
So **two layers**: editable content = `Document` (neutral, format-pluggable); base chrome =
|
||||
`Carrier` (opaque, lossless). Foreign-docx *import as input content* (Q4) does parse into
|
||||
`Document` and **is inherently lossy** — flagged as a boundary (§8), distinct from the
|
||||
lossless export of *our* templates.
|
||||
|
||||
### 3.3 The variable resolver seam (G3)
|
||||
|
||||
```go
|
||||
// VariableResolver answers keys within one dotted namespace.
|
||||
type VariableResolver interface {
|
||||
Namespace() string // e.g. "project"
|
||||
Resolve(key string) (value string, ok bool)// ok=false => unknown key => missing marker
|
||||
Keys() []VariableKey // catalogue for the palette + sidebar form
|
||||
}
|
||||
type VariableKey struct {
|
||||
Key, LabelDE, LabelEN, Group string
|
||||
}
|
||||
|
||||
// ResolverSet composes namespaced resolvers, registers canonical<->legacy aliases,
|
||||
// and offers BOTH a pull path (Resolve, used during binding) and a push path
|
||||
// (BuildBag, preserving today's resolved_bag/merged_bag wire).
|
||||
type ResolverSet struct{ /* … */ }
|
||||
func (s *ResolverSet) Resolve(key string) (string, bool)
|
||||
func (s *ResolverSet) BuildBag() map[string]string // == today's PlaceholderMap
|
||||
func (s *ResolverSet) Catalogue() []VariableKey // drives the data-driven form/palette
|
||||
func (s *ResolverSet) RegisterAlias(canonical, legacy string)
|
||||
```
|
||||
|
||||
paliad's seven `addXxxVars` functions become seven resolver types implementing this
|
||||
interface. `BuildBag()` reproduces today's flat map exactly (alias parity tests pin it).
|
||||
`Catalogue()` kills the hardcoded `VARIABLE_GROUPS`/`VARIABLE_LABELS` in the TS bundle.
|
||||
**Resolver model = hybrid** (pull-capable interface, push-driven `BuildBag` default —
|
||||
inventor pick, §4 D-I1).
|
||||
|
||||
### 3.4 Wire contract (Go ↔ TS) — preserved, locked by test
|
||||
|
||||
The editor wire stays as-is; `types_wire_test.go` pins it:
|
||||
|
||||
- `GET draft` → `{ draft, resolved_bag, merged_bag, preview_html, rule, parties, sections }`
|
||||
- preview HTML carries `<span class="draft-var" data-var="<key>">…</span>` (built by
|
||||
docforge's `RenderHTML`, today's `emitTextWithDraftVars`).
|
||||
- `PATCH draft` ← `{ variables: PlaceholderMap, … }` (presence-tracked optional fields).
|
||||
- export/preview endpoints unchanged.
|
||||
- **New (authoring):** `POST /api/templates` (upload), `GET /api/templates/:id` (carrier
|
||||
preview + slots), `POST /api/templates/:id/slots` (place slot), `GET /api/docforge/variables`
|
||||
(the `Catalogue()`).
|
||||
|
||||
---
|
||||
|
||||
## §4 Decisions (m's picks, 2026-05-29)
|
||||
|
||||
### Prose-grill resolutions (core metaphor)
|
||||
|
||||
| # | Question | m's decision | Note |
|
||||
|---|---|---|---|
|
||||
| P1 | Cross-language sharing model | **Go pkg only; upc-kommentar out of scope for now, "reuse later somehow"** | Interfaces sized so an HTTP veneer is addable without rework. No service built. |
|
||||
| P2 | Intermediate model? | **Yes — but lossless for our .docx** | → carrier (opaque OOXML) + neutral Document (editable content). §3.2. |
|
||||
| P3 | Authoring slot mechanic | **(b) click-to-insert** | Upload → render → click/select → inject `{{…}}`. |
|
||||
| P4 | Input formats | **Markdown primary; foreign doc/docx import later** | Markdown importer first; foreign-docx import is lossy (§8). |
|
||||
| P5 | Editor sharing | **Build paliad's UI; extract generic UX into a UI package** | `frontend/src/lib/docforge-editor/`. |
|
||||
|
||||
### Structured decisions
|
||||
|
||||
| # | Decision | m's pick | Rationale / divergence |
|
||||
|---|---|---|---|
|
||||
| A1 | Authoring UX | **WYSIWYG inline** | Matches "insert variables into the document". Hardest part — render fidelity + click→run mapping — flagged §7. |
|
||||
| A2 | Template storage | **Postgres bytea (interface-backed)** | m leans (1); flagged Supabase Storage as viable. Resolved: behind a `TemplateStore` interface, bytea impl now, Supabase Storage a one-impl swap later. No schema churn either way. |
|
||||
| A3 | Versioning of existing drafts | **Snapshot at draft-create** | Lawyer's in-flight draft won't shift under them; matches today's section-seeding. |
|
||||
| A4 | Migration strategy | **Extract-in-place, then extend** | Lowest risk to the recent fixes — they move with their files + tests; behavior identical at each step. |
|
||||
| B1 | Package name | **`docforge`** | — |
|
||||
| B2 | Schema scope | **New generic tables** (`templates`, `template_slots`, `template_versions`) | Authoring is domain-neutral; submission_bases (Gitea/section_spec) stays for legacy bases with a converge path. |
|
||||
| B3 | UI package extraction | **Extract now** | Authoring reuses it this cycle — earns its keep, not speculative. |
|
||||
| B4 | Exporter pluggability | **Interface now, docx-only impl** | Cheap insurance; matches "pluggable for later". |
|
||||
|
||||
### Inventor picks (m delegated — "whatever works best")
|
||||
|
||||
| # | Pick | Reasoning |
|
||||
|---|---|---|
|
||||
| I1 | `VariableResolver` = pull-capable interface, push `BuildBag()` default | Preserves today's flat-map wire while enabling on-demand resolution + the `Catalogue()` that data-drives the form. |
|
||||
| I2 | `.docx` adapter ships **inside** `pkg/docforge/docx` | Mirrors litigationplanner shipping its embedded snapshot in-package; keeps the first adapter co-located with the engine it proves. |
|
||||
| I3 | Carrier-vs-Document split (§3.2) | Only way to satisfy "intermediate model" AND "lossless our .docx" simultaneously. |
|
||||
|
||||
---
|
||||
|
||||
## §5 Data model deltas (paliad-side — docforge owns none)
|
||||
|
||||
**New tables** (additive; SQL drafted by the coder, not here):
|
||||
|
||||
- **`paliad.templates`** — `id`, `slug`, `name_de/en`, `kind` (`'submission'` | generic),
|
||||
`source_format` (`'docx'`), `firm`, `is_active`, `created/updated_by`, timestamps,
|
||||
`current_version_id` FK.
|
||||
- **`paliad.template_versions`** — immutable snapshots: `id`, `template_id` FK,
|
||||
`version` int, `carrier_blob` bytea (the `.docx`; or storage ref via `TemplateStore`),
|
||||
`created_at`, `created_by`. Editing a template inserts a new version row.
|
||||
- **`paliad.template_slots`** — `id`, `template_version_id` FK, `slot_key` (the variable
|
||||
key, e.g. `project.case_number`), `anchor` (position encoding — see flag below),
|
||||
`label`, `order_index`. Versioned alongside the carrier.
|
||||
|
||||
**Snapshot semantics (A3):** a draft pins `template_version_id`. Template edits create a
|
||||
new version; existing drafts keep their pinned version. *(Flag for coder: pin
|
||||
`template_version_id` on the draft vs. copy a `template_snapshot` jsonb onto the draft —
|
||||
both satisfy A3; the version-table approach is preferred for auditability but the coder
|
||||
picks based on query ergonomics.)*
|
||||
|
||||
**Touched existing tables:**
|
||||
|
||||
- `submission_drafts` — add nullable `template_version_id` for uploaded-template drafts;
|
||||
**legacy `base_id` path preserved** (extract-in-place ⇒ no data migration of the 11
|
||||
existing drafts; §0.5.8 fallback intact).
|
||||
- `submission_bases`, `submission_sections`, `submission_building_blocks` — **unchanged**.
|
||||
They remain paliad consumer-specific concepts that map onto docforge's neutral model at
|
||||
render time. submission_bases (Gitea-backed) coexists with the new uploaded-template
|
||||
tables during transition; convergence is a later, separate task.
|
||||
|
||||
**Slot anchor encoding (flag for coder):** how a `template_slots.anchor` records *where*
|
||||
in the carrier OOXML the slot sits (run index + offset, vs. a stable sentinel token
|
||||
injected into the carrier at authoring time). The sentinel-token approach is likely
|
||||
simpler and reuses the existing cross-run substitution machinery — resolve in
|
||||
implementation chat.
|
||||
|
||||
---
|
||||
|
||||
## §6 Migration plan (protects working code + the recent fixes)
|
||||
|
||||
**Principle:** extract-in-place (A4). Each step **compiles, passes the moved tests, and
|
||||
leaves observable behavior identical.** The recent fixes travel *with their files*:
|
||||
|
||||
- The **b78a984 underscore fix** → `pkg/docforge/docx/md_to_ooxml.go` (was
|
||||
`submission_md.go` `parseInlineSpans`), `submission_md_test.go` moves alongside.
|
||||
- **`placeholderRegex`** → `pkg/docforge/placeholder.go`; its tests move.
|
||||
- **`data-var` / `emitTextWithDraftVars`** → `pkg/docforge/render.go` (`RenderHTML`);
|
||||
wire test moves and is pinned in `types_wire_test.go`.
|
||||
- **Cross-run merge, `.dotm`→`.docx`, anchor splicing** → `pkg/docforge/docx/`; tests move.
|
||||
- **Building-block + section model, submission codes, the 7 concrete resolvers** stay in
|
||||
`internal/` (consumer-specific) — now calling into docforge.
|
||||
|
||||
**Safety rails per step:** (1) `go build ./...` green; (2) the moved test files green; (3)
|
||||
a golden-export check — generate a known draft before and after the step, assert byte-equal
|
||||
`.docx`; (4) the live preview HTML for a fixture draft is string-equal (the `data-var`
|
||||
contract). No step ships until all four hold.
|
||||
|
||||
**What is explicitly NOT migrated:** the 11 pre-Composer drafts (`base_id IS NULL`) keep
|
||||
the v1 fallback render path; no auto-upgrade (§0.5.8).
|
||||
|
||||
---
|
||||
|
||||
## §7 Slice train
|
||||
|
||||
Tracer-bullet vertical slices, each independently shippable. Slices 1–3 are pure
|
||||
behavior-preserving refactors (the risky-to-working-code part, front-loaded under golden
|
||||
checks); 4–7 build the new capability; 8 sets up the future.
|
||||
|
||||
1. **Extract the docx engine** — move MD→OOXML walker, OOXML merge/compose, placeholder
|
||||
grammar, `.dotm`→`.docx` into `pkg/docforge/{placeholder.go, render.go, docx/}`.
|
||||
paliad's `submission_*` services become thin adapters. Golden-export + preview checks
|
||||
green. *Protects b78a984, the regex, the data-var contract.*
|
||||
2. **Neutral model + binding** — introduce `Document`/`Block`/`Slot`/`Carrier` + `bind.go`;
|
||||
refactor the docx exporter to consume the neutral model (sections → blocks → OOXML
|
||||
spliced into carrier). Behavior identical (golden checks).
|
||||
3. **`VariableResolver` interface** — refactor the 7 `addXxxVars` into resolver types +
|
||||
`ResolverSet`; `BuildBag()` reproduces today's map (alias-parity tests pin it);
|
||||
`Catalogue()` exposed. Frontend form switched to consume `Catalogue()` (kills hardcoded
|
||||
`VARIABLE_GROUPS`).
|
||||
4. **Template store + schema** — `templates`/`template_versions`/`template_slots` +
|
||||
Postgres-bytea `TemplateStore` impl. No UI yet. Additive migrations.
|
||||
5. **UI package extraction** — pull generic plumbing (debounced autosave, data-var wiring,
|
||||
preview/export round-trip, focus preservation, sticky collapse) into
|
||||
`frontend/src/lib/docforge-editor/`; submission editor consumes it. Refactor, behavior
|
||||
identical.
|
||||
6. **Authoring page** — upload `.docx` → docforge docx-importer → WYSIWYG render → select
|
||||
text → pick variable from `Catalogue()` palette → inject slot (writes
|
||||
`template_slots` + new `template_version`). Reuses the UI package + docforge importer.
|
||||
*(v1: body-paragraph text slots only.)*
|
||||
7. **Generation on uploaded templates** — generation page picks an uploaded template
|
||||
(`template_version_id` path) alongside legacy bases; snapshot-at-create; data-bind +
|
||||
manual edit + export via docforge. Legacy base path still works.
|
||||
8. **Markdown importer + exporter-interface finalisation** — `docforge/markdown` importer
|
||||
as input; `Exporter` interface locked (docx-only impl). Sets up future formats +
|
||||
eventual upc-kommentar reuse.
|
||||
|
||||
**Flagged follow-ups (post-train, separate tasks):** slots in headers/footers/tables;
|
||||
foreign-docx import fidelity; the HTTP veneer + a TS consumer; submission_bases →
|
||||
templates convergence; auto-upgrade of pre-Composer drafts.
|
||||
|
||||
---
|
||||
|
||||
## §8 Out of scope
|
||||
|
||||
- **Implementation, migration SQL, code.** PRD only.
|
||||
- **upc-kommentar as a live consumer** — deferred; abstractions sized for it, nothing built.
|
||||
- **An HTTP service veneer** — addable later without engine rework; not now.
|
||||
- **Formats beyond `.docx`** — `Exporter` interface defined (B4), only the docx impl built.
|
||||
- **Lossless import of *foreign* `.docx`** — our own templates export losslessly via the
|
||||
carrier; importing an arbitrary third-party Word doc as input content is best-effort and
|
||||
inherently lossy. Distinct guarantee.
|
||||
- **Multi-user concurrent editing** of one draft.
|
||||
- **Re-proposing the current `submission_*.go` shape** — the point is to extract + clean it.
|
||||
- **Slots outside body paragraphs** (headers/footers/tables/text-boxes) in authoring v1.
|
||||
|
||||
---
|
||||
|
||||
## Appendix — open flags for the coder (resolve in implementation chat)
|
||||
|
||||
1. **Slot anchor encoding** — run-index+offset vs. injected sentinel token (§5). Lean
|
||||
sentinel.
|
||||
2. **Snapshot mechanism** — pinned `template_version_id` vs. `template_snapshot` jsonb on
|
||||
the draft (§5). Lean version-pin.
|
||||
3. **Authoring render fidelity** — reuse the existing lossy `docXMLToHTML` preview for the
|
||||
WYSIWYG surface, or invest in higher fidelity. Lean reuse for v1, accept that
|
||||
complex layouts render approximately while slots still anchor correctly.
|
||||
4. **Storage backend** — Postgres bytea now; Supabase Storage is a clean `TemplateStore`
|
||||
swap if template volume/size grows.
|
||||
344
docs/plans/prd-docforge-ux-2026-06-01.md
Normal file
344
docs/plans/prd-docforge-ux-2026-06-01.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# PRD — docforge UX: truthful base preview + a coherent submission-drafting flow
|
||||
|
||||
**Task:** t-paliad-369 (m 2026-06-01 17:16) · **Author:** leibniz (inventor) · **Date:** 2026-06-01
|
||||
**Status:** DESIGN — awaiting head's go/no-go on the coder shift. **No code in this task.**
|
||||
**Scope:** the **UX/flow around** the generation engine. The engine itself (Rubrum styling,
|
||||
caption, letterhead from the HLC `.dotm`) was made correct today — t-paliad-364 / -365 / -367 —
|
||||
and is **explicitly out of scope to redesign.**
|
||||
|
||||
**Reading order for the reviewer:** §0 (m's decisions) → §1 (journey map + hacky inventory) →
|
||||
§2 (the base-preview feature, with wireframes) → §3 (prioritized plan) → §4 (out of scope).
|
||||
|
||||
---
|
||||
|
||||
## §0 m's decisions (2026-06-01)
|
||||
|
||||
m was grilled in prose on the UX *vision*, then answered four concrete option-pickers.
|
||||
Both rounds are folded in here; the open-question record is §5.
|
||||
|
||||
### 0.1 Prose-grill answers (UX vision)
|
||||
|
||||
| # | Question | m's answer |
|
||||
|---|---|---|
|
||||
| G1 | What reads as "hacky"? | **(a) blind base dropdown, (b) invisible Bearbeiten-vs-Generieren, (c) preview ≠ real output, (d) dense panels — ALL bad.** NOT (e) `[KEIN WERT]` walls, NOT (f) duplicate catalog. |
|
||||
| G1′ | Panel count | **m sees TWO panels, not three.** (Resolved — see §1.4: the section panel is conditional and silently absent for his drafts.) |
|
||||
| G2 | Akte-first vs free-start | **Keep free-start first-class.** No Akte gating. Teach project-less drafts to fill what they can from the proceeding (already done, t-paliad-364). |
|
||||
| G3 | Base-preview fidelity | **Truthful.** "truthful would be awesome" — a real image of the actual Word page (letterhead logo, fonts, Rubrum table). |
|
||||
| G4 | Editor live-preview fidelity | **Structural suffices** ("a, I think") — show where vars/prose land. The live editing preview stays approximate; pixel-fidelity is **only** required for the base preview. |
|
||||
| G5 | Entry points | **Keep distinct, make consistent.** "in a project it is a bit different than the free approach." Don't converge the project tab and the global picker. |
|
||||
|
||||
**The load-bearing split (G3 vs G4):** *base preview = truthful (pixel-true .docx render)*;
|
||||
*live editing preview = structural (approximate HTML)*. The truthful infra built for the base
|
||||
preview *could* later upgrade the editing preview, but m does not require that now.
|
||||
|
||||
### 0.2 Concrete option-picker answers
|
||||
|
||||
| # | Decision | m's pick | Note |
|
||||
|---|---|---|---|
|
||||
| Q1 | Base-preview render infra | **LibreOffice on-demand + cached** | Renders the *actual* base with the draft's real data (`.docx`→PDF→image), cached. Truest "what you preview is what you generate". Cost: headless LibreOffice in the container + cold-render latency — scoped in §2.3. |
|
||||
| Q2 | Where the preview surfaces | **"Vorschau" button → modal** | Not an always-on inline pane. Pairs cleanly with on-demand rendering — we only render when the lawyer asks. |
|
||||
| Q3 | Bearbeiten vs Generieren | **One primary + ⋯ menu** | Primary "Entwurf öffnen"; a kebab offers the fast "Direkt exportieren" path. Kills the two-equal-buttons ambiguity, keeps the capability. |
|
||||
| Q4 | Editor sidebar density | **Meta → header toolbar** | Draft-meta (name, keyword, base+Vorschau, language) moves to a top header strip; the sidebar keeps only the fill-in work (parties + variables). |
|
||||
|
||||
---
|
||||
|
||||
## §1 Current journey map + the hacky inventory
|
||||
|
||||
Three entry points, one editor, one authoring page. All gated behind auth; knowledge-platform
|
||||
pages are separate. Grounded in the live tree (verified 2026-06-01).
|
||||
|
||||
### 1.1 Entry A — the project "Schriftsätze" tab
|
||||
`frontend/src/client/submissions.ts` · per-project, opened from a project detail page.
|
||||
|
||||
- Shows the **full cross-proceeding catalog** grouped by proceeding, with the project's own
|
||||
proceeding pinned at the top (lime border, " (dieses Projekt)" suffix) — m's 2026-05-23 decree.
|
||||
- **Each row carries two buttons:** `Bearbeiten` (→ editor at `/projects/{id}/submissions/{code}/draft`)
|
||||
and `Generieren` (POST `…/generate` → immediate `.docx` download, **skipping the editor**).
|
||||
- A `universell` badge marks rows without a dedicated per-code template.
|
||||
|
||||
**Hacky (G1-b):** the two buttons read as equals, but their behaviour is wildly different — one
|
||||
opens an editor, the other silently downloads a file. And the one-click `Generieren` runs the
|
||||
**merge path** (`onGenerateClick`, `submissions.ts:212`), which historically produced an *unfilled*
|
||||
doc (the t-paliad-363 P3 finding); even after today's fixes, "download a finished doc without ever
|
||||
seeing it" is a foot-gun next to "open the editor".
|
||||
|
||||
### 1.2 Entry B — the global `/submissions/new` picker
|
||||
`frontend/src/submissions-new.tsx` + `client/submissions-new.ts` · cross-proceeding catalog.
|
||||
|
||||
- Search box + proceeding chips + a grouped, read-only catalog table (just got the filter +
|
||||
group-header contrast fixes, t-paliad-365).
|
||||
- Each row: **"Entwurf starten"** → choose **"Ohne Projekt"** (jump straight to the draft) or
|
||||
**"Mit Projekt verknüpfen"** (a project-picker modal → project-scoped draft).
|
||||
|
||||
**Consistent-but-distinct (G5):** this serves a different moment than Entry A — *browsing the whole
|
||||
catalog and starting fresh* vs *working inside a known case*. m wants these kept distinct. The job
|
||||
is **consistency of affordances** (same row buttons, same naming, same kebab), not convergence.
|
||||
The duplicate-catalog concern (G1-f) m explicitly waved off.
|
||||
|
||||
### 1.3 Entry C — the draft editor
|
||||
`frontend/src/submission-draft.tsx` (shell) + `client/submission-draft.ts` (2873-line bundle).
|
||||
|
||||
Layout today (`.submission-draft-grid`):
|
||||
- **Sidebar** (`aside.submission-draft-sidebar`): draft switcher + "+ Neuer Entwurf" → name → keyword
|
||||
(filename) → **base picker** → language toggle → save status → "Aus Projekt importieren" → party
|
||||
picker → **~20 variable fields**. Everything stacked vertically.
|
||||
- **Section list** (`section.submission-draft-sections-wrap`, **`display:none` by default**) — the
|
||||
Composer prose-section editor. Painted *only* when the draft has seeded section rows.
|
||||
- **Preview pane** (`section.submission-draft-preview-wrap`): a read-only, lossy HTML render
|
||||
(`paintPreview`, `submission-draft.ts:1209`; `preview_html` from the server).
|
||||
|
||||
**Hacky (G1-a):** the base picker is a bare `<select>` (`#submission-draft-base`,
|
||||
`paintBasePicker`, `submission-draft.ts:1257`). It mixes legacy Gitea bases and uploaded templates
|
||||
in one dropdown (an optgroup "Hochgeladene Vorlagen"), and the lawyer picks **blind** — no idea
|
||||
what letterhead/structure each base yields. *This is m's headline ask.*
|
||||
|
||||
**Hacky (G1-c):** the preview pane is plain HTML — no letterhead logo, no HLpat fonts, no Rubrum
|
||||
table layout. It looks nothing like the exported Word document. "What you preview" ≠ "what you
|
||||
generate."
|
||||
|
||||
**Hacky (G1-d):** the sidebar is a long vertical stack of unlike things — draft management, template
|
||||
choice, language, parties, and every variable field — all competing for the same column.
|
||||
|
||||
### 1.4 The 2-vs-3-panel discrepancy — RESOLVED
|
||||
m reported seeing **two** panels; the brief described three. **m is right.** The middle
|
||||
"Abschnitte" panel is `wrap.style.display = "none"` whenever `state.view.sections` is empty
|
||||
(`paintSectionList`, `submission-draft.ts:1362-1364`). Section rows are seeded only for **Composer
|
||||
drafts** (a `base_id` whose `submission_bases.section_spec` seeds them) or when the lawyer manually
|
||||
adds a section. m's drafts (project-less / pre-Composer, `base_id IS NULL`) have **zero** sections,
|
||||
so the panel never appears and he sees `sidebar ‖ preview`.
|
||||
|
||||
This is itself a **silent UX inconsistency**: the editor is 2 panels for some drafts and 3 for
|
||||
others, with no signpost that a section editor exists. The layout design below is grounded in the
|
||||
**2-panel reality** and makes the third panel's presence/absence *explicit* (§3 Slice 5).
|
||||
|
||||
### 1.5 Entry D — `/admin/templates` authoring
|
||||
`frontend/src/templates-authoring.tsx` + `client/templates-authoring.ts` (docforge slice 6).
|
||||
|
||||
Admin-only. Upload `.docx` → render run-addressable text → select a span + pick a variable from the
|
||||
palette → drop a `{{slot}}` → save as a reusable template. Three columns: palette ‖ preview ‖ slots.
|
||||
|
||||
**Touch only for consistency:** uploaded templates surface in the *same* editor base picker as the
|
||||
Gitea bases, so the **base-preview feature (§2) must cover uploaded templates too** — an authored
|
||||
template should be previewable exactly like a Gitea base. No authoring-page redesign in this PRD.
|
||||
|
||||
### 1.6 Inventory summary (what we fix vs what m waved off)
|
||||
|
||||
| Smell | m's verdict | Addressed in |
|
||||
|---|---|---|
|
||||
| (a) blind base dropdown | **fix** | §2 base preview + §3 S2/S3/S4 |
|
||||
| (b) invisible Bearbeiten/Generieren | **fix** | §3 S1 |
|
||||
| (c) preview ≠ real output | **fix** (truthful base preview; live preview stays structural) | §2 + §3 S3/S4 |
|
||||
| (d) dense panels | **fix** | §3 S2 |
|
||||
| (e) `[KEIN WERT]` walls | not a priority (engine fix t-paliad-364 already softens) | §3 S5 (light polish only) |
|
||||
| (f) duplicate catalog | keep distinct | §3 S1 (consistency, not convergence) |
|
||||
|
||||
---
|
||||
|
||||
## §2 The template-base preview (m's headline ask)
|
||||
|
||||
> "Can we have a preview of the template base we are using?"
|
||||
|
||||
**Shape:** a **"Vorschau" button** (Q2) opens a **modal** that renders the selected base as a
|
||||
**truthful image of the actual Word page** (Q3) — same engine, same bytes that an export would
|
||||
produce — via **headless LibreOffice on-demand, cached** (Q1). The modal carries a base-switcher so
|
||||
the lawyer can flip bases and compare them truthfully → *"what you preview is what you generate."*
|
||||
|
||||
### 2.1 Wireframe — the Vorschau modal
|
||||
|
||||
```
|
||||
┌─ Vorschau — Vorlagenbasis ───────────────────────────────────────────────┐
|
||||
│ Basis: [ HLC Briefkopf ▾ ] Sprache: (•DE) ( EN ) [ ✕ ] │
|
||||
│ ├ HLC Briefkopf │
|
||||
│ ├ Universelles Skelett │
|
||||
│ ├ LG Düsseldorf │
|
||||
│ ├ UPC Formblatt │
|
||||
│ └ ── Hochgeladene Vorlagen ── │
|
||||
│ └ HLC Patents Style v0.26… │
|
||||
├───────────────────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ ┌──────────────────────────────────────────────────┐ │
|
||||
│ │ [HLC logo] Hogan Lovells … │ ← real page │
|
||||
│ │ ───────────────────────────────────────────────── │ image, page │
|
||||
│ │ Landgericht Düsseldorf │ 1 of N │
|
||||
│ │ │ (LibreOffice │
|
||||
│ │ In dem Rechtsstreit │ .docx→PDF→PNG │
|
||||
│ │ Mustermandant GmbH – Klägerin – │ of the SAME │
|
||||
│ │ ./. │ bytes export │
|
||||
│ │ Musterbeklagte AG – Beklagte – │ would emit) │
|
||||
│ │ wegen Patentverletzung │ │
|
||||
│ │ Az. 4c O 12/23 │ │
|
||||
│ │ … │ │
|
||||
│ └──────────────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ ‹ Seite 1 / 3 › Daten: (• meine Daten) ( Beispiel ) │
|
||||
│ [ Diese Basis verwenden ] │
|
||||
└───────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
- **Base-switcher** (top): flips the previewed base. "Diese Basis verwenden" commits it to the draft
|
||||
(the existing `base_id` / `template_version_id` PATCH). So the modal is *both* a preview and the
|
||||
base-chooser — replacing the blind `<select>` as the primary base-selection surface.
|
||||
- **Daten toggle:** *meine Daten* renders the draft's resolved bag (truthful to the export);
|
||||
*Beispiel* substitutes canned sample data (Mustermandant ./. Musterbeklagte, Az. 4c O 12/23) so a
|
||||
fresh/project-less draft previews a *full* page instead of `[KEIN WERT]` markers. Default: *meine
|
||||
Daten* when the draft has resolved values, *Beispiel* when it is essentially empty.
|
||||
- **Paging:** multi-page docs render page-by-page (`‹ Seite n / N ›`).
|
||||
|
||||
### 2.2 Where the button lives
|
||||
|
||||
Per Q4, the base picker moves into the **editor header toolbar**, and the "Vorschau" button sits
|
||||
right next to it (§3 S2 wireframe). The same modal is also reachable from a **catalog-row kebab**
|
||||
("Vorschau Vorlagenbasis", §3 S1) so a lawyer can eyeball a base *before* even opening the editor.
|
||||
|
||||
### 2.3 The truthful-render infra (Q1 = LibreOffice on-demand + cached)
|
||||
|
||||
**Pipeline (server-side, new `GET` preview endpoint):**
|
||||
|
||||
1. Resolve the request: `(draft_id | base identity, lang, data-mode)`.
|
||||
2. Run the **existing export pipeline** to produce `.docx` bytes — *the same compose/merge code an
|
||||
export uses*, so the preview is byte-faithful by construction. (Project-less / sample-data mode
|
||||
feeds a canned bag; "meine Daten" feeds the draft's resolved bag.)
|
||||
3. `soffice --headless --convert-to pdf` (headless LibreOffice) → PDF.
|
||||
4. PDF → PNG per page (poppler `pdftoppm`, or equivalent) at a sensible DPI.
|
||||
5. Return the PNG(s); the modal renders them.
|
||||
|
||||
**Caching** (this is what makes "on-demand" affordable):
|
||||
- Key on `(template identity, lang, data-mode, hash(resolved-bag))`. Identical inputs → cached PNG,
|
||||
no re-render. The bag-hash means editing a variable invalidates only that draft's preview.
|
||||
- Sample-data mode caches per `(base, lang)` only (data is constant) — so base *browsing/compare* is
|
||||
effectively free after the first render of each base.
|
||||
- Cache store: on-disk under the response/temp dir, or a small table — coder's call. No binary is
|
||||
retained as a *document* (the §0.5.7 no-retention invariant is about exported documents; preview
|
||||
PNGs are a regenerable cache, not a stored artifact — flag for the coder to confirm framing).
|
||||
|
||||
**Cost / risk to flag for m + coder:**
|
||||
- **Container dependency:** headless LibreOffice (~hundreds of MB) must be added to the Dokploy
|
||||
image (or a sidecar). This is the single biggest cost of the truthful path. *Recommend a sidecar/
|
||||
separate stage so the main Go image stays lean — coder evaluates.*
|
||||
- **Cold-render latency:** first render of a given `(base, lang, data)` is seconds (LibreOffice
|
||||
spin-up + convert). The modal shows a spinner ("⟳ wird gerendert…"); the cache makes repeats
|
||||
instant. A warm-cache pass over the 4–5 known bases in *sample-data* mode can pre-render the common
|
||||
cases at deploy time.
|
||||
- **Concurrency:** LibreOffice headless is single-instance-touchy; serialise conversions through a
|
||||
small worker/queue (one `soffice` at a time, or a pool). Flag for the coder.
|
||||
|
||||
**Tracer-bullet sequencing (important):** the modal **UX ships before the LibreOffice infra** — see
|
||||
§3 S3 (modal scaffold rendering the *existing structural HTML* first) → S4 (swap the modal body to
|
||||
the real PNG once LibreOffice lands). This de-risks the heavy infra: the base-compare UX is usable
|
||||
and reviewable immediately, and the truthful render drops in behind the same modal.
|
||||
|
||||
---
|
||||
|
||||
## §3 Prioritized UX-improvement plan (tracer-bullet first)
|
||||
|
||||
Cheap → meaty. Each slice independently shippable and independently reviewable. **S1/S2 are the
|
||||
quick wins that fix three of m's four "hacky" complaints with no new infra.**
|
||||
|
||||
### Slice 1 — Catalog row: one primary + ⋯ menu, consistent across both entry points *(quick win — kills G1-b, delivers G5-consistency)*
|
||||
TS/CSS only, no backend.
|
||||
- Replace the two equal buttons with a **primary `Entwurf öffnen`** + a **`⋯` kebab**:
|
||||
`Direkt exportieren (.docx)` and `Vorschau Vorlagenbasis`.
|
||||
- Apply the **same row component / vocabulary** to the project Schriftsätze tab
|
||||
(`client/submissions.ts`) *and* the global picker (`client/submissions-new.ts`) so they read
|
||||
consistently — while keeping each surface's distinct context (own-proceeding pin on the tab;
|
||||
search + chips + project-link modal on the global picker).
|
||||
- Drop the bare `universell` jargon badge for a clearer tooltip/label.
|
||||
|
||||
```
|
||||
Klageerwiderung Beklagte § 277 ZPO [ Entwurf öffnen ] [ ⋯ ]
|
||||
de.inf.lg.erwidg └─┐
|
||||
• Direkt exportieren (.docx)
|
||||
• Vorschau Vorlagenbasis
|
||||
```
|
||||
|
||||
### Slice 2 — Editor header toolbar: meta out of the sidebar *(quick win — kills G1-d)*
|
||||
TS/CSS layout, no backend.
|
||||
- Lift draft-meta (switcher, name, keyword, **base picker + 👁 Vorschau button**, language) into a
|
||||
**header strip** above the working area. Sidebar keeps only **parties + variables** (the fill-in
|
||||
work). Export button stays top-right.
|
||||
|
||||
```
|
||||
┌─ Klageerwiderung · de.inf.lg.erwidg ───────────────────────────────────────────┐
|
||||
│ Entwurf:[ Entwurf v2 ▾ ][+ Neu] Name:[__________] Stichwort:[__________] │
|
||||
│ Vorlagenbasis:[ HLC Briefkopf ▾ ][👁 Vorschau] Sprache:(•DE)(EN) [Als .docx ⤓]│
|
||||
├──────────────────────────────────┬──────────────────────────────────────────────┤
|
||||
│ PARTEIEN │ VORSCHAU (Struktur — wo Daten/Text landen) │
|
||||
│ ☑ Mustermandant (Klägerin) │ [letterhead] │
|
||||
│ ☑ Musterbeklagte (Beklagte) │ In dem Rechtsstreit … │
|
||||
│ VARIABLEN │ Az. «project.case_number» │
|
||||
│ project.case_number [________] │ … │
|
||||
│ … │ │
|
||||
└──────────────────────────────────┴──────────────────────────────────────────────┘
|
||||
```
|
||||
(When the draft has sections, the Composer "Abschnitte" panel sits between sidebar and preview —
|
||||
see S5 for making that presence explicit.)
|
||||
|
||||
### Slice 3 — Vorschau modal scaffold (structural render first) *(tracer bullet for the base preview)*
|
||||
- Build the modal shell (§2.1): base-switcher, Sprache toggle, Daten toggle, paging frame,
|
||||
"Diese Basis verwenden", spinner state.
|
||||
- Wire the `👁 Vorschau` button (S2 header) and the row kebab (S1) to open it.
|
||||
- **Body initially renders the existing structural `preview_html`** for the selected base — so the
|
||||
whole base-compare + choose UX is live and reviewable *before* any LibreOffice work.
|
||||
|
||||
### Slice 4 — Truthful render: LibreOffice on-demand + cached *(the meaty infra — delivers G3)*
|
||||
- Add the preview endpoint + the `.docx`→PDF→PNG pipeline + cache (§2.3).
|
||||
- Add headless LibreOffice to the deploy (sidecar recommended) + a single-flight conversion worker.
|
||||
- **Swap the modal body** from structural HTML (S3) to the real page PNG(s). Same modal, same UX.
|
||||
- Warm-cache the known bases in sample-data mode at deploy.
|
||||
- *This is the slice that carries the container-dependency + latency cost — gate it on m's explicit
|
||||
OK for the LibreOffice dependency.*
|
||||
|
||||
### Slice 5 — Section-panel discoverability + small honesty polish *(polish)*
|
||||
- Make the conditional "Abschnitte" panel **explicit**: when a draft *could* have sections but has
|
||||
none, show a slim empty-state ("Dieser Entwurf hat keine Abschnitte — [+ Abschnitt hinzufügen]")
|
||||
instead of silently rendering nothing — so the section editor is discoverable (fixes the §1.4
|
||||
inconsistency). When sections genuinely don't apply (pure merge-path draft), say so once.
|
||||
- Light `[KEIN WERT]` softening in the *live* preview (e.g. a muted "‹noch leer: …›" treatment)
|
||||
so honest gaps read as gentle prompts, not errors. (G1-e is low priority; keep it light.)
|
||||
|
||||
**Ordering rationale:** S1+S2 ship the visible "less hacky" wins immediately (rows + editor layout),
|
||||
no infra. S3 lands the base-preview *experience* on cheap rails. S4 makes it *truthful* — the one
|
||||
slice with real infra cost, isolated and gated. S5 is discoverability polish.
|
||||
|
||||
---
|
||||
|
||||
## §4 Out of scope
|
||||
|
||||
- **Implementation / code / migration SQL.** Design only.
|
||||
- **Redesigning the generation engine** — Rubrum/caption/letterhead/HLpat styling are correct as of
|
||||
t-paliad-364/-365/-367. This PRD touches only the UX *around* it.
|
||||
- **Upgrading the LIVE editing preview to pixel-true** — m: structural suffices (G4). The truthful
|
||||
infra (post-S4) *could* later power a "truthful full-document" view in the editor, but that is a
|
||||
future opt-in, not this work.
|
||||
- **Converging the two entry points** — m: keep distinct (G5). We make them *consistent*, not one.
|
||||
- **Akte-first gating** — m: free-start stays first-class (G2).
|
||||
- **`/admin/templates` authoring redesign** — only consistency touch is that uploaded templates must
|
||||
be previewable via §2; no workflow rework.
|
||||
- **Multi-user concurrent editing** of one draft.
|
||||
- **The `[KEIN WERT]` product question** (require-Akte vs fill-what-we-can) — already resolved by
|
||||
t-paliad-364 (fill-what-we-can); not reopened here.
|
||||
|
||||
---
|
||||
|
||||
## §5 Open-question record (historical)
|
||||
|
||||
These were the open questions before m ratified them; kept for the record. All are now closed in §0.
|
||||
|
||||
- **OQ-G1** What specifically feels hacky? → closed (G1: a/b/c/d, not e/f).
|
||||
- **OQ-G2** Akte-first vs free-start? → closed (free-start first-class).
|
||||
- **OQ-G3** Base-preview fidelity? → closed (truthful).
|
||||
- **OQ-G4** Editor live-preview fidelity? → closed (structural suffices).
|
||||
- **OQ-G5** Converge or keep entry points distinct? → closed (distinct + consistent).
|
||||
- **OQ-Q1** Base-preview render infra? → closed (LibreOffice on-demand + cached).
|
||||
- **OQ-Q2** Where does the preview surface? → closed ("Vorschau" button → modal).
|
||||
- **OQ-Q3** Bearbeiten vs Generieren? → closed (one primary + ⋯ menu).
|
||||
- **OQ-Q4** Sidebar density? → closed (meta → header toolbar).
|
||||
|
||||
### Flags for the eventual coder (resolve in implementation chat)
|
||||
1. **LibreOffice deployment shape** — sidecar vs. in-image; the single-flight conversion worker.
|
||||
2. **Preview cache** — on-disk vs. a small table; eviction policy; confirm preview PNGs are framed as
|
||||
a regenerable cache, not a retained "document" (vs the §0.5.7 no-retention invariant).
|
||||
3. **DPI / page-image size** — fidelity vs. payload weight for the modal.
|
||||
4. **Sample-data source** — a single canned bag, or per-jurisdiction sample bags for nicer previews.
|
||||
5. **Base-picker vocabulary** — with the modal as the chooser, whether to keep "Vorlagenbasis" vs a
|
||||
plainer "Vorlage" label, and how to present Gitea bases vs uploaded templates as one list.
|
||||
354
docs/plans/prd-filename-generator-2026-06-01.md
Normal file
354
docs/plans/prd-filename-generator-2026-06-01.md
Normal file
@@ -0,0 +1,354 @@
|
||||
# PRD — Composable Name/Filename Generator Engine
|
||||
|
||||
**Task:** t-paliad-355 · **Author:** leibniz (inventor) · **Date:** 2026-06-01
|
||||
**Status:** DESIGN — awaiting head go/no-go on coder shift
|
||||
**Builds on:** t-paliad-352 / m/paliad#155 (draft title), t-paliad-354 (export filename, merged `94adeeb`)
|
||||
**Related:** `docs/plans/prd-docforge-2026-05-29.md` (doc-generation engine — a future naming consumer)
|
||||
|
||||
---
|
||||
|
||||
## § m's decisions (2026-06-01)
|
||||
|
||||
All eight grilling questions answered; every pick matched the inventor recommendation.
|
||||
|
||||
**Batch 1 — model:**
|
||||
- Q1 (Composition model): **Structured segments + string shorthand.** Canonical model is an ordered segment list with per-segment missing-rules; a token-template string is an optional authoring shorthand that compiles to segments.
|
||||
- Q2 (v1 precedence): **System → Firm → User → per-document.** Mirrors the existing dashboard-layout chain exactly. Project-level deferred to v1.1.
|
||||
- Q3 (Engine depth): **Reusable engine, wire 3 known consumers.** Real engine now; only draft-title, submission-.docx, and the non-project fix are wired. Other surfaces register as known artifacts but keep current code.
|
||||
- Q4 (Non-project name): **`<date> <keyword>`**, falling back to `Entwurf N` only when no type context exists.
|
||||
|
||||
**Batch 2 — concrete:**
|
||||
- Q5 (Missing-rule set): **omit + placeholder + literal**, per segment.
|
||||
- Q6 (Date semantics): **Render-time "today", Europe/Berlin, `YYYY-MM-DD`.**
|
||||
- Q7 (Settings UX): **Live-preview string field on `/settings`** + clickable `{token}` palette. Missing-rules use defaults (not user-editable in v1).
|
||||
- Q8 (Artifact scope): **2 submission artifacts (`submission_draft_title`, `submission_docx_filename`) + extensible registry.** docforge-export, data-zip, projection-slug registered as known artifacts but unwired in v1.
|
||||
|
||||
These are necessary for a coder shift, **not** sufficient — the head still gates whether/who/when to implement (inventor→coder rule).
|
||||
|
||||
---
|
||||
|
||||
## §0 Premises (verified against the live system, 2026-06-01)
|
||||
|
||||
| # | Premise | How verified |
|
||||
|---|---------|--------------|
|
||||
| P1 | Draft title = `<date> <client> ./. <forum> ./. <opponent>`, project-bound only, missing segments dropped-with-separator. | Read `internal/services/submission_autoname.go` (`AutoSubmissionTitle`). |
|
||||
| P2 | Non-project drafts fall back to `Entwurf N` / `Draft N` counter. | Read `submission_draft_service.go` `newDraftName`/`Create`. |
|
||||
| P3 | Export filename = `<date> <keyword> (<case | "Az. folgt">).docx`; keyword overridable per-draft via `composer_meta.filename_keyword`. | Read `internal/handlers/submissions.go` `submissionFileName` + `submissionFilenameKeyword`. |
|
||||
| P4 | Sanitiser `SanitiseSubmissionFileName` folds umlauts, maps `/\:*?<>|`→`_`, strips `"'`, **preserves spaces/parens**. Lives in `pkg/docforge/docx/dotm.go`, re-exported via `services.SanitiseSubmissionFileName` (`docforge_shims.go`). | Read `pkg/docforge/docx/dotm.go`. |
|
||||
| P5 | `DashboardLayoutSpec` is a production precedent for a validated jsonb spec: code `FactoryDefaultLayout` → admin `firm_dashboard_default` (db row id=1) → per-user `user_dashboard_layouts`, with `Validate()` (write) + `SanitizeForRead()` (read). | Read `dashboard_layout_spec.go`, `firm_dashboard_default_service.go`. |
|
||||
| P6 | `users.email_preferences jsonb` (per-user bag) and `projects.metadata jsonb` exist live. No dedicated `user_preferences` table — migration 017 only added the `email_preferences` column. | `information_schema.columns` query on live `paliad` schema. |
|
||||
| P7 | Draft titles are de-duplicated at create time via `uniqueDraftName` (appends a counter on collision). | Read `newDraftName`. |
|
||||
|
||||
**Doc-is-the-bug flags raised:** none. The two shipped behaviours are exactly as the task described; `projects.metadata` exists so a project-level override needs no new column when v1.1 arrives (only a documented sub-key).
|
||||
|
||||
---
|
||||
|
||||
## §1 The problem
|
||||
|
||||
Two one-off naming functions shipped in successive tasks (#155, 354). Each hardcodes: a date format, an ordered set of segments, a separator, and a missing-value policy. m wants to stop re-deriving this per feature — "we will need a filename generator more often later on" — and to expose **defaults / compositions** as a **user (and maybe project) setting**. Plus one immediate gap: non-project drafts get no date-led name.
|
||||
|
||||
The design must:
|
||||
1. Extract a **reusable composition engine** that renders a name from (template, variable-bag, render-target).
|
||||
2. Reproduce **both shipped schemes byte-for-byte** as seed defaults (no behaviour regression).
|
||||
3. Add **settings** with a clean precedence chain, built **on** the dashboard-spec pattern (P5), not beside it.
|
||||
4. Fix the **non-project** gap inside the engine, not as another special case.
|
||||
|
||||
---
|
||||
|
||||
## §2 The engine
|
||||
|
||||
A new package **`pkg/nomen`** (Latin *nomen* = "name"; firm-agnostic, sits beside `pkg/docforge`). Pure, dependency-light, table-testable. No DB, no HTTP — consumers resolve variables and hand them in, exactly as `AutoSubmissionTitle` is pure today.
|
||||
|
||||
> **FLAG (coder + m):** package name `nomen` is the inventor pick. Alternatives: `pkg/naming`, `internal/services/namegen`. Pick at implementation; nothing downstream depends on the name.
|
||||
|
||||
### 2.1 Core types (interface sketch — not final Go)
|
||||
|
||||
```go
|
||||
package nomen
|
||||
|
||||
// Segment is one piece of a composition: a variable reference, the
|
||||
// separator that precedes it, and what to do when the variable resolves
|
||||
// empty.
|
||||
type Segment struct {
|
||||
Var string // key into the variable catalog, e.g. "date", "keyword"
|
||||
Sep string // TRAILING separator: emitted AFTER this segment iff a
|
||||
// later segment also emits. The last emitted segment's
|
||||
// Sep is never used. (See Slice-1 note below.)
|
||||
Wrap [2]string // optional surrounding literals, e.g. {"(", ")"} for case-no.
|
||||
Missing MissingRule // omit | placeholder | literal
|
||||
}
|
||||
|
||||
type MissingRule struct {
|
||||
Kind MissingKind // KindOmit | KindPlaceholder | KindLiteral
|
||||
Value string // placeholder/literal text (e.g. "Az. folgt"); ignored for omit
|
||||
}
|
||||
|
||||
// Composition is the canonical, validated model.
|
||||
type Composition struct {
|
||||
Version int // schema version (start at 1)
|
||||
Segments []Segment
|
||||
}
|
||||
|
||||
// VarResolver yields a variable's value for one render. Returns ("", false)
|
||||
// when the variable is unavailable in this context (→ Missing rule applies).
|
||||
type VarResolver func(key string) (string, bool)
|
||||
|
||||
// RenderTarget post-processes the assembled string (sanitisation, suffix).
|
||||
type RenderTarget interface {
|
||||
Name() string // "title" | "filename"
|
||||
Transform(assembled string) string
|
||||
}
|
||||
|
||||
func (c Composition) Render(resolve VarResolver, target RenderTarget) string
|
||||
func (c Composition) Validate(catalog VarCatalog) error
|
||||
```
|
||||
|
||||
> **Implementation note (Slice 1, 2026-06-01 — `Sep` is trailing, not leading).**
|
||||
> This PRD originally sketched `Sep` as the separator emitted *before* a
|
||||
> segment. During Slice 1 that model proved unable to reproduce #155
|
||||
> byte-for-byte: the existing test `"no client — client segment omitted"`
|
||||
> requires `2026-05-31 UPC ./. Novartis Pharma` — the date must join the
|
||||
> *forum* with a single space when the client is absent, while the
|
||||
> forum-to-opponent join stays ` ./. `. A separator owned by the right-hand
|
||||
> segment would need two different values for the same segment depending on
|
||||
> what was omitted before it. Making the separator **trailing** (owned by
|
||||
> the left-hand segment) is the minimal faithful fix: the date's trailing
|
||||
> ` ` is used whenever any identity segment follows, and each party's
|
||||
> trailing ` ./. ` is used whenever another party follows. All shipped
|
||||
> #155/354 tests pass unchanged. Implemented in `pkg/nomen/nomen.go`; the
|
||||
> realised `RenderTarget` also splits `Transform` into `SanitiseValue`
|
||||
> (per-variable) + `Finalise` (whole-string + suffix) per §2.3.
|
||||
|
||||
### 2.2 Render algorithm (reproduces both shipped schemes)
|
||||
|
||||
For each segment, in order:
|
||||
1. `val, ok := resolve(seg.Var)`.
|
||||
2. If `!ok || strings.TrimSpace(val) == ""`, apply `seg.Missing`:
|
||||
- `KindOmit` → segment contributes nothing (and its `Sep` is suppressed).
|
||||
- `KindPlaceholder` → `val = seg.Missing.Value` (treated as present).
|
||||
- `KindLiteral` → `val = seg.Missing.Value` (same as placeholder; distinct *intent* in the model — "this is a fixed label", not "this is a stand-in for missing data" — so the settings UI can word them differently and future policy can diverge).
|
||||
3. If the segment emits, prepend `seg.Sep` **iff at least one segment already emitted** (kills the leading-separator problem the #155 code solves by hand), then wrap with `seg.Wrap`.
|
||||
4. Concatenate.
|
||||
5. `target.Transform(assembled)` runs once on the whole string.
|
||||
|
||||
**Separator suppression** is the generalisation of #155's "drop segment + its leading separator". **Placeholder** is the generalisation of 354's `(Az. folgt)`.
|
||||
|
||||
### 2.3 Render targets
|
||||
|
||||
The **same** `Composition` renders to different targets:
|
||||
|
||||
| Target | `Transform` | Used by |
|
||||
|--------|-------------|---------|
|
||||
| `TitleTarget` | identity (spaces, umlauts, ` ./. ` all valid in a human title) | `submission_draft_title` |
|
||||
| `FilenameTarget{ext: ".docx"}` | per-segment-aware: applies `services.SanitiseSubmissionFileName` to **variable values** (not the frame — preserve the spaces/parens/wrap), then appends `ext`. | `submission_docx_filename` |
|
||||
|
||||
> **Design note — where sanitisation runs.** 354 sanitises *each variable value* but keeps the assembled frame (`<date> <kw> (<case>)`) intact. To preserve that exactly, the `FilenameTarget` is **not** a dumb whole-string transform — the engine sanitises each resolved variable value *before* assembly when the target requests it, and the target only appends the extension at the end. So `RenderTarget` gains one more hook:
|
||||
|
||||
```go
|
||||
type RenderTarget interface {
|
||||
Name() string
|
||||
SanitiseValue(v string) string // per-variable; identity for TitleTarget
|
||||
Finalise(assembled string) string // whole-string; appends ".docx" for filename
|
||||
}
|
||||
```
|
||||
|
||||
This is the one subtlety that makes the engine faithful to 354. Both shipped schemes drop out of `(Composition, VarResolver, RenderTarget)` with no special-casing.
|
||||
|
||||
### 2.4 Variable catalog
|
||||
|
||||
A `VarCatalog` is an extensible registry: `key → VarDef{ Label, LabelEN, Description, Group }`. The catalog is **metadata only** (for validation + the settings palette); **values** come from the per-render `VarResolver` the consumer supplies. This keeps the engine pure — a consumer registers which keys it can resolve, the engine validates a composition only references known keys.
|
||||
|
||||
v1 catalog (the union of what the two schemes need + obvious near-neighbours):
|
||||
|
||||
| key | meaning | resolver source (submission consumer) |
|
||||
|-----|---------|----------------------------------------|
|
||||
| `date` | render-time today, Europe/Berlin, `YYYY-MM-DD` | engine-provided default resolver (see §2.5) |
|
||||
| `keyword` | document/submission type; user-overridable | `composer_meta.filename_keyword` → rule name (lang-aware) → "submission" |
|
||||
| `case_number` | project Aktenzeichen | `project.CaseNumber` |
|
||||
| `client` | root-ancestor client name | project-tree walk (existing `autoNameForProject`) |
|
||||
| `forum` | short forum label (UPC/EPA/LG/…) | `submissionForumShort(pt)` (existing) |
|
||||
| `opponent` | primary opposing party name | `submissionOpponentName(parties, ourSide)` (existing) |
|
||||
|
||||
Registered-but-deferred keys (declared so compositions can reference them, resolvers added when a consumer needs them): `proceeding`, `lang`, `client_matter`, `project_name`, `draft_counter`.
|
||||
|
||||
**Extensibility contract:** a new consumer (e.g. docforge export) builds its own `VarCatalog` subset + `VarResolver` and registers an artifact (§4). It never edits the engine.
|
||||
|
||||
### 2.5 The `date` resolver
|
||||
|
||||
The engine ships a default `date` resolver: `time.Now()` → `Europe/Berlin` → `Format("2006-01-02")`. This is the **one** variable the engine resolves itself (both shipped schemes compute it identically), so a consumer that only wants the standard date doesn't re-implement it. A consumer may override `date` in its resolver (e.g. a created-at date) — but v1 does not.
|
||||
|
||||
---
|
||||
|
||||
## §3 Settings & precedence
|
||||
|
||||
### 3.1 Precedence chain (v1)
|
||||
|
||||
Resolution order for a given artifact, **first hit wins**:
|
||||
|
||||
```
|
||||
per-document override → user override → firm default → system default
|
||||
(highest priority) (always present)
|
||||
```
|
||||
|
||||
- **System default** — code-resident, per artifact. The seed `Composition` literals (§5). Always exists; nothing can delete it.
|
||||
- **Firm default** — optional admin-set row, mirrors `firm_dashboard_default` (P5). A firm can mandate a house naming convention. Cleared → reverts to system.
|
||||
- **User override** — per-user, stored in a jsonb bag keyed by artifact id. Absent key → fall through.
|
||||
- **Per-document override** — the **already-shipped** `composer_meta.filename_keyword`, generalised to a `composer_meta.name_overrides` map of `{var → value}` (back-compat: `filename_keyword` reads as `name_overrides.keyword` for the filename artifact). This is a *variable-value* override, not a *composition* override — the user is replacing one token's value for one document, not redefining the template.
|
||||
|
||||
> **Why per-document is a value override, not a template override:** the shipped "Stichwort" editor lets a lawyer change *what the keyword is* for one draft, not *the shape of the name*. Keeping per-document as value-only avoids giving every draft its own editable template (scope creep) while preserving the shipped UX exactly.
|
||||
|
||||
### 3.2 Storage
|
||||
|
||||
| Level | Where | Shape |
|
||||
|-------|-------|-------|
|
||||
| System | Go code (`nomen` consumer package) | `Composition` literals |
|
||||
| Firm | **new** `paliad.firm_name_compositions` (id=1 singleton, mirrors `firm_dashboard_default`) | `jsonb`: `{ artifact_id: Composition }` map, validated |
|
||||
| User | **new column** `paliad.users.name_compositions jsonb NOT NULL DEFAULT '{}'` (mirrors `email_preferences`) | `{ artifact_id: Composition }` map |
|
||||
| Per-document | **existing** `submission_drafts.composer_meta` | `{ name_overrides: { var: value } }` (supersedes flat `filename_keyword`) |
|
||||
|
||||
A `NameCompositionSpec` type gets `Validate()` (write — references-known-vars, known-artifact, ≤ N segments) and `SanitizeForRead()` (read — drop segments referencing dropped vars, clamp version), exactly like `DashboardLayoutSpec`. This is the closest existing analog and the pattern is copy-shaped.
|
||||
|
||||
> **Project-level (v1.1, deferred):** when it lands, it slots between user and firm (`per-document → user → project → firm → system`) and stores under a documented `projects.metadata.name_compositions` sub-key — **no migration needed** (P6: column exists). The "project vs user, who wins?" call (Q2) is deferred with it; the v1.1 default is **user wins** (a lawyer's personal convention beats a matter's), but that's a v1.1 decision, flagged here so v1 storage doesn't preclude it.
|
||||
|
||||
---
|
||||
|
||||
## §4 Artifact registry
|
||||
|
||||
An **artifact** is a named thing that gets a name: it binds a default composition, an allowed-variable subset, and a render target.
|
||||
|
||||
```go
|
||||
type Artifact struct {
|
||||
ID string // "submission_draft_title", "submission_docx_filename"
|
||||
Label string // for the settings UI
|
||||
Catalog VarCatalog // which variables are available here
|
||||
Target RenderTarget // title vs filename
|
||||
SystemDefault Composition // the seed (§5)
|
||||
}
|
||||
```
|
||||
|
||||
v1 registry (`internal/services/namegen` — the paliad-side wiring; `pkg/nomen` stays pure):
|
||||
|
||||
| Artifact ID | Target | Wired in v1? |
|
||||
|-------------|--------|--------------|
|
||||
| `submission_draft_title` | title | **yes** |
|
||||
| `submission_docx_filename` | filename `.docx` | **yes** |
|
||||
| `docforge_export` | filename | registered, **unwired** (opts in when docforge ships) |
|
||||
| `data_zip_export` | filename `.zip` | registered, **unwired** (keeps `ExportFilename` shape) |
|
||||
| `projection_slug` | slug | registered, **unwired** |
|
||||
|
||||
Registering-but-not-wiring means: the artifact ID exists in the catalog so the settings UI *could* list it and a composition *could* be stored, but the consumer still calls its current code path until a follow-up task flips it. No dead behaviour, no speculative resolver code.
|
||||
|
||||
> **`data_zip_export` note:** `ExportFilename` (`paliad-export-project-<slug>-<short>-<ts>.zip`) is deliberately machine-shaped (UTC timestamp, uuid disambiguator) — it is **not** a legal title and should **not** inherit the legal-composition defaults. It is registered for *discoverability*, but its eventual opt-in would use a distinct catalog (slug/timestamp/uuid vars), confirming the engine generalises beyond the legal-title model without forcing that model on it.
|
||||
|
||||
---
|
||||
|
||||
## §5 Seed defaults (the two shipped schemes, as data)
|
||||
|
||||
### 5.1 `submission_draft_title` (reproduces `AutoSubmissionTitle`, #155)
|
||||
|
||||
```
|
||||
Segments:
|
||||
{ Var: "date", Sep: "", Missing: omit }
|
||||
{ Var: "client", Sep: " ", Missing: omit }
|
||||
{ Var: "forum", Sep: " ./. ", Missing: omit }
|
||||
{ Var: "opponent", Sep: " ./. ", Missing: omit }
|
||||
Target: TitleTarget
|
||||
```
|
||||
|
||||
- All-omit + separator-suppression reproduces "drop empty segment with its leading separator".
|
||||
- `date` with `Sep: ""` and the others' first-emitted-suppresses-Sep rule yields `2026-05-31 Bayer AG ./. UPC` when opponent is empty — identical to today.
|
||||
- Non-project draft: `client`/`forum`/`opponent` resolve `("", false)` → all omitted → renders bare `<date>`. **This is the non-project fix** (§6).
|
||||
|
||||
### 5.2 `submission_docx_filename` (reproduces `submissionFileName`, 354)
|
||||
|
||||
```
|
||||
Segments:
|
||||
{ Var: "date", Sep: "", Missing: omit }
|
||||
{ Var: "keyword", Sep: " ", Missing: literal("submission") }
|
||||
{ Var: "case_number", Sep: " ", Wrap: {"(", ")"},
|
||||
Missing: placeholder("Az. folgt") }
|
||||
Target: FilenameTarget{ext: ".docx"}
|
||||
```
|
||||
|
||||
- `keyword` missing → `literal("submission")` reproduces the `kw == "" → "submission"` fallback.
|
||||
- `case_number` missing → `placeholder("Az. folgt")`, wrapped in parens → `(Az. folgt)`.
|
||||
- `FilenameTarget` sanitises each value via `SanitiseSubmissionFileName`, preserves the frame, appends `.docx`. Output identical to 354.
|
||||
|
||||
**Faithfulness test (acceptance gate):** golden-file table tests assert the engine's output is byte-equal to the current `AutoSubmissionTitle` / `submissionFileName` across the existing test matrix (with/without opponent, with/without case-number, en/de, umlaut folding). The shipped funcs become thin wrappers over the engine, or are deleted once call-sites move.
|
||||
|
||||
---
|
||||
|
||||
## §6 The non-project fix
|
||||
|
||||
Currently `newDraftName` only calls `autoNameForProject` when `project != nil`; otherwise `nextDraftName` → `Entwurf N`. Under the engine:
|
||||
|
||||
- A non-project draft renders `submission_draft_title` with a resolver where `client/forum/opponent` are all `("", false)` → composition degrades to `<date>`.
|
||||
- Per Q4, the default gains a `keyword` segment so non-project drafts read **`<date> <keyword>`** where `keyword` = submission/document type if the draft has a `submission_code` that maps to a rule, else falls back.
|
||||
- **Fallback when no keyword context:** if `keyword` also resolves empty (project-less draft with no `submission_code`/rule), the title degrades to `<date> Entwurf N` — `Entwurf N` enters as the `keyword` segment's `literal` fallback **with** the existing counter, so uniqueness is preserved via `uniqueDraftName` (P7).
|
||||
|
||||
> **FLAG (coder):** confirm whether project-less drafts (t-paliad-243) carry a `submission_code`. If yes, `keyword` derives from the rule like the project path. If no, the `literal("Entwurf N")` fallback is the norm and non-project names read `<date> Entwurf N` — still satisfies "date first there" (m's ask). Resolve in implementation; both paths are handled by the same composition.
|
||||
|
||||
The non-project title is the **same** `submission_draft_title` artifact — not a separate composition. Degradation is data-driven, not a code branch. This is the payoff of the engine: the gap closes by *removing* the `project != nil` special-case, not adding another.
|
||||
|
||||
---
|
||||
|
||||
## §7 Settings UX (v1)
|
||||
|
||||
A section on the existing `/settings` page (017 surface):
|
||||
|
||||
- **Per artifact** (v1 lists the 2 wired ones): a single-line **token-template string** field, e.g. `{date} {keyword} ({case_number})`.
|
||||
- A **token palette**: clickable chips (`{date}` `{client}` `{forum}` `{opponent}` `{keyword}` `{case_number}`) insert at cursor. Chips show the localised label (DE primary / EN secondary).
|
||||
- A **live preview** rendered against a **sample project** (fixed fixture: client "Bayer AG", forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", today's date) so the user sees the result instantly — and a second preview line with empties so they see the missing-rule behaviour.
|
||||
- **Reset to firm/system default** button (mirrors the dashboard "reset layout").
|
||||
|
||||
**String ⇄ segments:** the field is the *shorthand* (Q1). A small parser compiles `{var}` tokens + surrounding literals into `Segments` (separators = the literal runs between tokens; `(…)` around a token → `Wrap`). Missing-rules are **not** in the string (Q7) — they come from the system default for that var and are not user-editable in v1. So a user can reorder/drop/re-add tokens and change literals, but can't (yet) flip case-number from placeholder to omit. That's a deliberate v1 boundary; the structured model already supports it, the UI just doesn't expose it.
|
||||
|
||||
> Parser edge: a `{token}` the catalog doesn't know → inline validation error ("Unknown variable {foo}"), preview shows nothing, save disabled. Mirrors `DashboardLayoutSpec.Validate` rejecting unknown widget keys.
|
||||
|
||||
---
|
||||
|
||||
## §8 Slice train
|
||||
|
||||
Sliced so a **tracer bullet** ships value before the settings UI exists.
|
||||
|
||||
- **Slice 1 — Engine + faithful refactor (no behaviour change).**
|
||||
`pkg/nomen` (types, render, targets, catalog) + `internal/services/namegen` (artifact registry + the 2 seed compositions + resolvers built from existing `submission_autoname.go` helpers). Re-point `AutoSubmissionTitle` and `submissionFileName` call-sites at the engine. **Acceptance:** §5 golden-file byte-equality; all existing #155/354 tests green unchanged. *No user-visible change — this is the safety net.*
|
||||
- **Slice 2 — Non-project date-first (§6).**
|
||||
Remove the `project != nil` special-case in `newDraftName`; non-project drafts render `submission_draft_title`. **Acceptance:** project-less draft gets `<date> <keyword>` (or `<date> Entwurf N` fallback); existing project drafts unchanged. *First user-visible win, m's immediate ask.*
|
||||
- **Slice 3 — Precedence: system → user (per-document already shipped).**
|
||||
`users.name_compositions jsonb` column + `NameCompositionSpec` (`Validate`/`SanitizeForRead`) + resolution that prefers a user override over the system default. Generalise `composer_meta.filename_keyword` → `name_overrides.keyword` (back-compat read). *No UI yet — overrides settable via API/test.*
|
||||
- **Slice 4 — Settings UX (§7).**
|
||||
`/settings` token-template field + palette + live preview for the 2 wired artifacts. *User can now customise.*
|
||||
- **Slice 5 — Firm default.**
|
||||
`firm_name_compositions` singleton + admin surface, mirroring `firm_dashboard_default`. Slots into precedence below user. *Firm-wide convention.*
|
||||
|
||||
Slices 1–2 are the tracer bullet (engine proven on shipped behaviour + the gap closed). 3–5 layer settings without re-touching the engine.
|
||||
|
||||
---
|
||||
|
||||
## §9 Out of scope (this PRD)
|
||||
|
||||
- Implementation, migration SQL drafting, Go code.
|
||||
- Re-litigating #155 / 354 behaviour — they are the seed defaults, reproduced not redesigned.
|
||||
- **Project-level** compositions (v1.1; storage path reserved in §3.2, precedence call deferred).
|
||||
- Wiring `docforge_export`, `data_zip_export`, `projection_slug` — registered, not migrated (each is its own follow-up when the surface needs it).
|
||||
- Naming for non-doc-generation strings across the app.
|
||||
- User-editable **missing-rules** in the settings UI (model supports it; UI deferred past v1).
|
||||
|
||||
---
|
||||
|
||||
## §10 Open questions (historical record — resolved in § m's decisions)
|
||||
|
||||
1. Composition representation — token-string vs structured-segments vs both. → **Q1: structured + string shorthand.**
|
||||
2. v1 precedence levels. → **Q2: system → firm → user → per-document.**
|
||||
3. Generalisation depth (YAGNI vs engine-now). → **Q3: reusable engine, 3 consumers wired.**
|
||||
4. Non-project default name. → **Q4: `<date> <keyword>`.**
|
||||
5. Missing-rule policy set. → **Q5: omit + placeholder + literal.**
|
||||
6. Date semantics. → **Q6: render-time today, Europe/Berlin, `YYYY-MM-DD`.**
|
||||
7. Settings UX shape. → **Q7: live-preview string field + palette.**
|
||||
8. Artifact registry scope. → **Q8: 2 submission artifacts + extensible registry.**
|
||||
|
||||
**Remaining FLAGs for the coder (not blocking design approval):**
|
||||
- Package name `pkg/nomen` (vs `naming`/`namegen`) — implementation pick.
|
||||
- Whether project-less drafts carry a `submission_code` (decides `keyword` source in §6).
|
||||
- `name_overrides` back-compat read of the existing `filename_keyword` key — confirm the one shipped draft-keyword row migrates cleanly (live round-trip test, like t-paliad-354's).
|
||||
@@ -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**:
|
||||
|
||||
280
exports/gen-deadline-list.py
Executable file
280
exports/gen-deadline-list.py
Executable file
@@ -0,0 +1,280 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Generate a markdown deadline-list export for UPC PA training (work/head delegation #2572).
|
||||
|
||||
Sorts by proceeding-type display_order then sequence_order. Sections by proceeding.
|
||||
|
||||
t-paliad-348 / yoUPC#178 update: matches the engine's `IncludeOptional=false`
|
||||
default (`pkg/litigationplanner/engine.go`). Optional rules (priority='optional')
|
||||
are SUPPRESSED by default so the manuscript shows the same "naked proceeding
|
||||
backbone" the UI now renders. Pass `--include-optional` to opt back in for an
|
||||
exhaustive catalog dump.
|
||||
|
||||
Usage:
|
||||
uv run exports/gen-deadline-list.py [--include-optional] [-o OUT]
|
||||
"""
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = ["psycopg2-binary"]
|
||||
# ///
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
import psycopg2
|
||||
import psycopg2.extras
|
||||
|
||||
DSN = os.environ.get(
|
||||
"PALIAD_DEADLINE_EXPORT_DSN",
|
||||
"postgres://postgres:rpsak3yf4lu1izgefx9p9xweg3qroojw@100.99.98.201:11833/postgres?sslmode=disable",
|
||||
)
|
||||
|
||||
# `priority` filter is wired at the SQL level (not post-filter in Python) so
|
||||
# the row counter in the markdown header reflects what's actually in the
|
||||
# manuscript — matching what the lawyer sees on /tools/procedures.
|
||||
SQL_TEMPLATE = """
|
||||
SELECT
|
||||
pt.code AS pt_code,
|
||||
pt.display_order,
|
||||
COALESCE(pt.name_en, pt.name) AS pt_label_en,
|
||||
pt.name AS pt_label_de,
|
||||
COALESCE(pe.name_en, pe.name) AS event_en,
|
||||
pe.name AS event_de,
|
||||
sr.duration_value,
|
||||
sr.duration_unit,
|
||||
sr.timing,
|
||||
sr.alt_duration_value,
|
||||
sr.alt_duration_unit,
|
||||
sr.combine_op,
|
||||
sr.rule_code,
|
||||
COALESCE(te.name, te.name_de) AS trigger_label,
|
||||
te.code AS trigger_code,
|
||||
sr.primary_party,
|
||||
sr.is_court_set,
|
||||
sr.is_spawn,
|
||||
sr.priority,
|
||||
sr.deadline_notes_en,
|
||||
sr.deadline_notes,
|
||||
sr.condition_expr,
|
||||
sr.sequence_order
|
||||
FROM paliad.sequencing_rules sr
|
||||
JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
LEFT JOIN paliad.proceeding_types pt ON pt.id = sr.proceeding_type_id
|
||||
LEFT JOIN paliad.trigger_events te ON te.id = sr.trigger_event_id
|
||||
WHERE sr.lifecycle_state = 'published'
|
||||
AND sr.is_active = true
|
||||
AND pt.id IS NOT NULL
|
||||
{priority_filter}
|
||||
ORDER BY pt.display_order NULLS LAST, pt.code, sr.sequence_order NULLS LAST, sr.rule_code, pe.name;
|
||||
"""
|
||||
|
||||
|
||||
def format_frist(duration_value, duration_unit, timing, alt_value, alt_unit, combine_op):
|
||||
"""Format the deadline duration cleanly."""
|
||||
if duration_value is None or duration_unit is None:
|
||||
return ""
|
||||
unit_map = {
|
||||
"days": "d",
|
||||
"weeks": "w",
|
||||
"months": "M",
|
||||
"years": "y",
|
||||
"calendar_days": "CD",
|
||||
"working_days": "WD",
|
||||
}
|
||||
unit = unit_map.get(duration_unit, duration_unit)
|
||||
main = f"{duration_value} {unit}"
|
||||
if alt_value is not None and alt_unit is not None:
|
||||
alt_unit_short = unit_map.get(alt_unit, alt_unit)
|
||||
op = combine_op or "or"
|
||||
main = f"{main} {op} {alt_value} {alt_unit_short}"
|
||||
if timing == "before":
|
||||
main = f"{main} before"
|
||||
elif timing == "after":
|
||||
main = f"{main} after"
|
||||
return main
|
||||
|
||||
|
||||
def format_party(primary_party, is_court_set):
|
||||
if is_court_set:
|
||||
return "court-set"
|
||||
if primary_party == "claimant":
|
||||
return "claimant"
|
||||
if primary_party == "defendant":
|
||||
return "defendant"
|
||||
if primary_party == "both":
|
||||
return "either"
|
||||
if primary_party == "court":
|
||||
return "court"
|
||||
return primary_party or "—"
|
||||
|
||||
|
||||
def detect_r94(notes_en, notes_de):
|
||||
"""Flag R.9.4 non-extendable from notes text (heuristic — no DB field)."""
|
||||
blobs = " ".join(filter(None, [notes_en or "", notes_de or ""])).lower()
|
||||
if "r.9.4" in blobs or "r 9.4" in blobs or "r9.4" in blobs:
|
||||
return "✗"
|
||||
if "non-extendable" in blobs or "nicht verlängerbar" in blobs or "nicht verlaengerbar" in blobs:
|
||||
return "✗"
|
||||
return ""
|
||||
|
||||
|
||||
def conditional_marker(condition_expr):
|
||||
if condition_expr in (None, "", {}):
|
||||
return ""
|
||||
# condition_expr is JSONB → returns dict
|
||||
if isinstance(condition_expr, dict):
|
||||
if "flag" in condition_expr:
|
||||
return f"if `{condition_expr['flag']}`"
|
||||
if condition_expr.get("op") == "and" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " & ".join(f"`{f}`" for f in flags)
|
||||
if condition_expr.get("op") == "or" and "args" in condition_expr:
|
||||
flags = [a.get("flag", "?") for a in condition_expr["args"]]
|
||||
return "if " + " | ".join(f"`{f}`" for f in flags)
|
||||
return "cond"
|
||||
|
||||
|
||||
def md_escape(s):
|
||||
if s is None:
|
||||
return ""
|
||||
return str(s).replace("|", "\\|").replace("\n", " ")
|
||||
|
||||
|
||||
def render(rows, *, include_optional: bool, generated_for: str) -> str:
|
||||
by_pt = {}
|
||||
for r in rows:
|
||||
key = (r["display_order"] or 9999, r["pt_code"], r["pt_label_de"], r["pt_label_en"])
|
||||
by_pt.setdefault(key, []).append(r)
|
||||
|
||||
out = []
|
||||
today = date.today().isoformat()
|
||||
out.append(f"# UPC + DE/EP Deadline Catalog — Stand {today}")
|
||||
out.append("")
|
||||
out.append(f"Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).")
|
||||
out.append(f"Generated for {generated_for}. {len(rows)} rules across {len(by_pt)} proceedings.")
|
||||
if include_optional:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** `--include-optional` — every published rule, including "
|
||||
"`priority='optional'` rules suppressed by the engine's default "
|
||||
"(`IncludeOptional=false`). This is the exhaustive catalog dump."
|
||||
)
|
||||
else:
|
||||
out.append("")
|
||||
out.append(
|
||||
"**Mode:** default — matches the engine's `IncludeOptional=false` "
|
||||
"behaviour (pkg/litigationplanner/engine.go). `priority='optional'` "
|
||||
"rules are suppressed; the manuscript shows only the mandatory "
|
||||
"backbone the lawyer sees by default on /tools/procedures. "
|
||||
"Re-run with `--include-optional` for the full catalog. "
|
||||
"(t-paliad-348 / yoUPC#178)"
|
||||
)
|
||||
out.append("")
|
||||
out.append("**Spalten:**")
|
||||
out.append("- **Phase/Event** = procedural event (German primary)")
|
||||
out.append("- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)")
|
||||
out.append("- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)")
|
||||
out.append("- **Anchor** = trigger event the deadline runs from")
|
||||
out.append("- **Seite** = filing party (claimant / defendant / either / court-set)")
|
||||
out.append("- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)")
|
||||
out.append("- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)")
|
||||
out.append("- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)")
|
||||
out.append("")
|
||||
out.append("---")
|
||||
out.append("")
|
||||
|
||||
for (order, pt_code, pt_de, pt_en) in sorted(by_pt.keys()):
|
||||
prules = by_pt[(order, pt_code, pt_de, pt_en)]
|
||||
out.append(f"## {pt_de} · `{pt_code}`")
|
||||
out.append("")
|
||||
if pt_en and pt_en != pt_de:
|
||||
out.append(f"*{pt_en}*")
|
||||
out.append("")
|
||||
if include_optional:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | Priorität | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|---|:---:|---|")
|
||||
else:
|
||||
out.append("| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |")
|
||||
out.append("|---:|---|---|---|---|---|:---:|---|")
|
||||
for i, r in enumerate(prules, 1):
|
||||
event = md_escape(r["event_de"] or r["event_en"] or "")
|
||||
frist = md_escape(
|
||||
format_frist(
|
||||
r["duration_value"], r["duration_unit"], r["timing"],
|
||||
r["alt_duration_value"], r["alt_duration_unit"], r["combine_op"],
|
||||
)
|
||||
)
|
||||
rule = md_escape(r["rule_code"] or "")
|
||||
anchor = md_escape(r["trigger_label"] or "")
|
||||
party = format_party(r["primary_party"], r["is_court_set"])
|
||||
r94 = detect_r94(r["deadline_notes_en"], r["deadline_notes"])
|
||||
cond = md_escape(conditional_marker(r["condition_expr"]))
|
||||
spawn_marker = " ⤴" if r["is_spawn"] else ""
|
||||
if include_optional:
|
||||
priority = md_escape(r["priority"] or "")
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {priority} | {r94} | {cond} |"
|
||||
)
|
||||
else:
|
||||
out.append(
|
||||
f"| {i} | {event}{spawn_marker} | {frist} | {rule} | {anchor} | {party} | {r94} | {cond} |"
|
||||
)
|
||||
out.append("")
|
||||
|
||||
out.append("---")
|
||||
out.append("")
|
||||
out.append("**Lesehilfe:**")
|
||||
out.append("- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)")
|
||||
out.append("- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)")
|
||||
out.append("- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).")
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument(
|
||||
"--include-optional",
|
||||
action="store_true",
|
||||
help="Include priority='optional' rules. Default false matches the engine's IncludeOptional=false default.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o",
|
||||
"--out",
|
||||
default="exports/upc-deadlines-2026-05-28.md",
|
||||
help="Output path (relative to repo root).",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--generated-for",
|
||||
default="PA-Schulung 2026-05-28",
|
||||
help="Free-text label rendered in the markdown header.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
priority_filter = "" if args.include_optional else "AND sr.priority != 'optional'"
|
||||
sql = SQL_TEMPLATE.format(priority_filter=priority_filter)
|
||||
|
||||
conn = psycopg2.connect(DSN)
|
||||
try:
|
||||
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
|
||||
cur.execute(sql)
|
||||
rows = cur.fetchall()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
md = render(rows, include_optional=args.include_optional, generated_for=args.generated_for)
|
||||
# Resolve out path relative to the repo root (= the script's grandparent).
|
||||
out_path = Path(args.out)
|
||||
if not out_path.is_absolute():
|
||||
out_path = Path(__file__).resolve().parent.parent / out_path
|
||||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
out_path.write_text(md)
|
||||
n_pt = len({(r["display_order"] or 9999, r["pt_code"]) for r in rows})
|
||||
print(
|
||||
f"WROTE {out_path} ({len(rows)} rules, {n_pt} proceedings, "
|
||||
f"include_optional={args.include_optional})"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-after-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 156 KiB |
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
BIN
exports/screenshots/paliad-348-before-upc-inf-cfi.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 490 KiB |
378
exports/upc-deadlines-2026-05-28.md
Normal file
378
exports/upc-deadlines-2026-05-28.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# UPC + DE/EP Deadline Catalog — Stand 2026-05-28
|
||||
|
||||
Source: `paliad.sequencing_rules` (lifecycle_state=published, is_active=true).
|
||||
Generated for PA-Schulung 2026-05-28. 178 rules across 25 proceedings.
|
||||
|
||||
**Mode:** default — matches the engine's `IncludeOptional=false` behaviour (pkg/litigationplanner/engine.go). `priority='optional'` rules are suppressed; the manuscript shows only the mandatory backbone the lawyer sees by default on /tools/procedures. Re-run with `--include-optional` for the full catalog. (t-paliad-348 / yoUPC#178)
|
||||
|
||||
**Spalten:**
|
||||
- **Phase/Event** = procedural event (German primary)
|
||||
- **Frist** = duration + timing (`d` days, `w` weeks, `M` months, `CD` calendar days, `WD` working days; `before` = relative to anchor)
|
||||
- **Rule** = legal source (RoP / § ZPO / § PatG / Art. EPÜ)
|
||||
- **Anchor** = trigger event the deadline runs from
|
||||
- **Seite** = filing party (claimant / defendant / either / court-set)
|
||||
- **Priorität** = mandatory / recommended / optional / informational (only when `--include-optional`)
|
||||
- **R.9.4** = ✗ marked non-extendable in notes (heuristic — confirm against rule text)
|
||||
- **Bedingung** = scenario flag(s) that must be set for the rule to fire (blank = always)
|
||||
|
||||
---
|
||||
|
||||
## Verletzungsverfahren · `upc.inf.cfi`
|
||||
|
||||
*Infringement Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | RoP.013.1 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 3 M after | RoP.023 | | defendant | | |
|
||||
| 3 | Replik | 2 M or 2 M after | RoP.029.b | | claimant | | if `with_ccr` |
|
||||
| 4 | Duplik | 1 M or 2 M after | RoP.029.c | | defendant | | if `with_ccr` |
|
||||
| 5 | Erwiderung auf Nichtigkeitswiderklage | 2 M after | RoP.029.a | | claimant | | if `with_ccr` |
|
||||
| 6 | Replik auf Erwiderung zur Nichtigkeitswiderklage | 2 M after | RoP.029.d | | defendant | | if `with_ccr` |
|
||||
| 7 | Duplik auf Replik zur Erwiderung Nichtigkeitswiderklage | 1 M after | RoP.029.e | | claimant | | if `with_ccr` |
|
||||
| 8 | Antrag auf Patentänderung | 2 M after | RoP.030.1 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 9 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.032.1 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_ccr` & `with_amend` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 12 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 13 | Mitteilung Dolmetscherkosten | 2 w before | RoP.109.4 | Oral hearing | court | | |
|
||||
| 14 | Übersetzungen einreichen | 2 w after | RoP.109.5 | | either | | |
|
||||
| 15 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 16 | Entscheidung | 0 M after | RoP.118.1 | | court-set | | |
|
||||
| 17 | Duplik zur Replik auf die Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | Reply to the Defence to an Application to amend the patent | defendant | | if `with_ccr` & `with_amend` |
|
||||
| 18 | Einreichung von Übersetzungen von Schriftstücken | 1 M after | RoP.007.4 | Order of the judge-rapporteur to lodge translations | either | | |
|
||||
| 19 | Antrag auf Simultanübersetzung | 1 M before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 20 | Antrag auf Folgemaßnahmen aus einer rechtskräftigen Validitätsentscheidung | 2 M after | RoP.118.4 | Final decision of the central division, Court of Appeal or EPO on the validity of the patent | either | | if `with_ccr` |
|
||||
| 21 | Antrag auf Überprüfung einer verfahrensleitenden Anordnung | 15 d after | RoP.333 | Case management order (Service) | either | | |
|
||||
| 22 | Mängelbeseitigung / Einreichung schriftlicher Stellungnahme | 14 d after | RoP.019 | Preliminary Objection | either | | |
|
||||
| 23 | Mängelbeseitigung / Zahlung | 14 d after | RoP.016 | Notification by the Registry to correct deficiencies | either | | |
|
||||
| 24 | Antrag auf Verweisung an die Zentralkammer | 10 d after | RoP.323 | Information by the Court not to approve Application to use the patent's language as language of the proceedings | either | | |
|
||||
| 25 | Mitteilung über Beauftragung eines Dolmetschers auf Kosten der Partei | 2 w before | RoP.109.5 | Oral hearing | either | | |
|
||||
| 26 | Klärung von Übersetzungsfragen | 2 w after | RoP.109 | Summons to Oral Hearing | court | | |
|
||||
| 27 | Antrag auf Vertraulichkeit gegenüber der Öffentlichkeit | 14 d after | RoP.262.2 | Opponent Submission | either | | |
|
||||
| 28 | Wiedereinsetzungsantrag (UPC R.320) | 2 M after | RoP.320 | Removal of obstacle (UPC R.320) | either | | |
|
||||
|
||||
## Verletzungsverfahren (LG) · `de.inf.lg`
|
||||
|
||||
*Infringement (Regional Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klageerhebung | 0 M after | § 253 ZPO | | claimant | | |
|
||||
| 2 | Anzeige der Verteidigungsbereitschaft | 2 w after | § 276 ZPO | | defendant | | |
|
||||
| 3 | Klageerwiderung | 6 w after | § 276 ZPO | | court-set | | |
|
||||
| 4 | Replik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 5 | Duplik | 4 w after | § 282 ZPO | | court-set | | |
|
||||
| 6 | Haupttermin | 0 M after | § 279 ZPO | | court-set | | |
|
||||
| 7 | Urteil | 0 M after | § 300 ZPO | | court-set | | |
|
||||
| 8 | Berufungsfrist | 1 M after | § 517 ZPO | | either | | |
|
||||
| 9 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 10 | Wiedereinsetzungsantrag (§ 233 ZPO) | 2 w after | § 233 ZPO | Removal of obstacle (ZPO §233) | — | | |
|
||||
| 11 | Einspruch gegen Versäumnisurteil (§ 339 ZPO) | 2 w after | § 339 ZPO | Service of default judgment | — | | |
|
||||
| 12 | Schriftsatznachreichung (§ 296a ZPO) | 3 w after | § 296a ZPO | End of oral hearing | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren · `upc.rev.cfi`
|
||||
|
||||
*Revocation Action*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | RoP.044 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.049.1 | | defendant | | |
|
||||
| 3 | Antrag auf Patentänderung | 0 M after | RoP.049.2.a | | defendant | | if `with_amend` |
|
||||
| 4 | Verletzungswiderklage | 0 M after | RoP.049.2.b | | defendant | | if `with_cci` |
|
||||
| 5 | Replik | 2 M after | RoP.051 | | claimant | | |
|
||||
| 6 | Erwiderung auf Patentänderungsantrag | 2 M after | RoP.043.3 | | claimant | | if `with_amend` |
|
||||
| 7 | Erwiderung auf Verletzungswiderklage | 2 M after | RoP.056.1 | | claimant | | if `with_cci` |
|
||||
| 8 | Duplik | 1 M after | RoP.052 | | defendant | | |
|
||||
| 9 | Replik auf Erwiderung zum Patentänderungsantrag | 1 M after | RoP.032.3 | | defendant | | if `with_amend` |
|
||||
| 10 | Replik auf Erwiderung zur Verletzungswiderklage | 1 M after | RoP.056.3 | | defendant | | if `with_cci` |
|
||||
| 11 | Duplik auf Replik zum Patentänderungsantrag | 1 M after | RoP.032.3 | | claimant | | if `with_amend` |
|
||||
| 12 | Duplik auf Replik zur Erwiderung Verletzungswiderklage | 1 M after | RoP.056.4 | | claimant | | if `with_cci` |
|
||||
| 13 | Zwischenanhörung | 0 M after | RoP.105 | | court-set | | |
|
||||
| 14 | Mündliche Verhandlung | 0 M after | RoP.112 | | court-set | | |
|
||||
| 15 | Entscheidung | 0 M after | RoP.118.3 | | court-set | | |
|
||||
| 16 | Duplik zur Replik auf die Erwiderung zur Nichtigkeitsklage | 1 M after | RoP.052 | Reply to the Defence to revocation | — | | |
|
||||
| 17 | Verletzungswiderklage | 2 M after | RoP.053 | Statement for Revocation | — | | |
|
||||
| 18 | Antrag auf Patentänderung | 2 M after | RoP.050 | Statement for Revocation | — | | |
|
||||
|
||||
## Nichtigkeitsverfahren (BPatG) · `de.null.bpatg`
|
||||
|
||||
*Nullity (Federal Patent Court)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Nichtigkeitsklage | 0 M after | § 81 PatG | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | § 82 Abs. 3 PatG | | defendant | | |
|
||||
| 3 | Replik | 2 M after | § 83 PatG | | claimant | | |
|
||||
| 4 | Hinweisbeschluss | 0 M after | § 83 PatG | | court-set | | |
|
||||
| 5 | Stellungnahme zum Hinweisbeschluss | 0 M after | § 83 PatG | | either | | |
|
||||
| 6 | Duplik | 1 M after | § 83 PatG | | defendant | | |
|
||||
| 7 | Mündliche Verhandlung | 0 M after | § 80 PatG | | court-set | | |
|
||||
| 8 | Urteil | 0 M after | § 84 PatG | | court-set | | |
|
||||
| 9 | Berufungsfrist | 1 M after | § 110 PatG | | either | | |
|
||||
| 10 | Berufungsbegründung | 3 M after | § 111 PatG | | either | | |
|
||||
| 11 | Wiedereinsetzungsantrag (§ 123 PatG) | 2 M after | § 123 PatG | Removal of obstacle (PatG §123) | — | | |
|
||||
|
||||
## Einspruchsverfahren · `epa.opp.opd`
|
||||
|
||||
*Opposition Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | Art. 97 EPÜ | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | Art. 99 EPÜ | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | R. 79(1) EPÜ | | court-set | | |
|
||||
| 4 | Entscheidung | 0 M after | Art. 102 EPÜ | | court-set | | |
|
||||
| 5 | Beschwerdefrist | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 6 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 7 | Stellungnahme weiterer Beteiligter | 0 M after | R. 79 EPÜ | | either | | |
|
||||
| 8 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 9 | Wiedereinsetzungsantrag (Art. 122 EPÜ) | 2 M after | Art. 122 EPÜ | Removal of obstacle (EPC Art.122) | — | | |
|
||||
|
||||
## Beschwerdeverfahren · `epa.opp.boa`
|
||||
|
||||
*Appeal Proceedings*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung der Beschwerdeentscheidung | 0 M after | R. 124 EPÜ | | either | | |
|
||||
| 2 | Beschwerdeeinlegung | 2 M after | Art. 108 EPÜ | | either | | |
|
||||
| 3 | Beschwerdebegründung | 4 M after | Art. 108 EPÜ | | either | | |
|
||||
| 4 | Beschwerdeerwiderung | 4 M after | RPBA Art. 12 | | either | | |
|
||||
| 5 | Mündliche Verhandlung | 0 M after | Art. 116 EPÜ | | court-set | | |
|
||||
| 6 | Entscheidung | 0 M after | Art. 111 EPÜ | | court-set | | |
|
||||
| 7 | Eingaben vor mündl. Verhandlung | 0 M after | R. 116 EPÜ | | either | | |
|
||||
| 8 | Antrag auf Überprüfung | 2 M after | Art. 112a EPÜ | | either | | |
|
||||
|
||||
## Einspruchsverfahren DPMA · `dpma.opp.dpma`
|
||||
|
||||
*Opposition DPMA*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Veröffentlichung der Erteilung | 0 M after | § 58 PatG | | either | | |
|
||||
| 2 | Einspruchsfrist | 9 M after | § 59 PatG | | either | | |
|
||||
| 3 | Erwiderung des Patentinhabers | 4 M after | § 59(2) PatG | | court-set | | |
|
||||
| 4 | DPMA-Entscheidung | 0 M after | § 61 PatG | | court-set | | |
|
||||
| 5 | Wiedereinsetzungsantrag (DPMA) | 2 M after | § 123 PatG | Removal of obstacle (DPMA, PatG §123) | — | | |
|
||||
|
||||
## Berufungsverfahren · `upc.apl.merits`
|
||||
|
||||
*Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Berufungseinlegung | 2 M after | RoP.224.1.a | | either | | |
|
||||
| 2 | Berufungsbegründung | 4 M after | RoP.224.2.a | | either | | |
|
||||
| 3 | Berufungserwiderung | 3 M after | RoP.235.1 | | either | | |
|
||||
| 4 | Mündliche Verhandlung | 0 M after | RoP.240 | | court-set | | |
|
||||
| 5 | Entscheidung | 0 M after | RoP.235.4 | | court-set | | |
|
||||
| 6 | Anschlussberufung | 3 M after | RoP.237 | | either | | |
|
||||
| 7 | Erwiderung Anschlussberufung | 2 M after | RoP.238.1 | | either | | |
|
||||
| 8 | Berufungsschrift gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 2 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 9 | Berufungsbegründung gegen eine in Regel 220.1(a) und (b) genannte Entscheidung | 4 M after | RoP.224.1(a) | Decision referred to in Rule 220.1(a) and (b) | — | | |
|
||||
| 10 | Anfechtung einer Entscheidung über die Verwerfung der Berufung als unzulässig | 1 M after | RoP.245 | Decision to reject an appeal as inadmissible | — | | |
|
||||
| 11 | Antrag auf Wiederaufnahme (schwerwiegender Verfahrensmangel) | 2 M after | RoP.247.2 | Final decision (Service) / Discovery of the fundamental defect (whichever is later) | — | | |
|
||||
| 12 | Antrag auf Wiederaufnahme (Straftat) | 2 M after | RoP.247.1 | Final decision (Service) / Court decision on criminal offence (whichever is later) | — | | |
|
||||
|
||||
## Berufungsverfahren OLG (Verletzung) · `de.inf.olg`
|
||||
|
||||
*Appeal OLG (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung LG-Urteil | 0 M after | § 540 ZPO | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 517 ZPO | | either | | |
|
||||
| 3 | Berufungsbegründung | 2 M after | § 520 ZPO | | either | | |
|
||||
| 4 | Berufungserwiderung | 1 M after | § 521 ZPO | | either | | |
|
||||
| 5 | Anschlussberufung | 0 M after | § 524 ZPO | | either | | |
|
||||
| 6 | Mündliche Verhandlung | 0 M after | § 540 ZPO | | court-set | | |
|
||||
| 7 | OLG-Urteil | 0 M after | § 540 ZPO | | court-set | | |
|
||||
|
||||
## Revisions-/NZB-Verfahren BGH (Verletzung) · `de.inf.bgh`
|
||||
|
||||
*Revision / Non-admission Appeal BGH (Infringement)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung OLG-Urteil | 0 M after | § 555 ZPO | | either | | |
|
||||
| 2 | Nichtzulassungsbeschwerde | 1 M after | § 544 ZPO | | either | | |
|
||||
| 3 | Nichtzulassungsbeschwerde-Begründung | 2 M after | § 544 ZPO | | either | | |
|
||||
| 4 | Revisionsfrist | 1 M after | § 548 ZPO | | either | | |
|
||||
| 5 | Revisionsbegründung | 2 M after | § 551 ZPO | | either | | |
|
||||
| 6 | Revisionserwiderung | 1 M after | § 554 ZPO | | either | | |
|
||||
| 7 | Mündliche Verhandlung BGH | 0 M after | § 555 ZPO | | court-set | | |
|
||||
| 8 | BGH-Urteil | 0 M after | § 555 ZPO | | court-set | | |
|
||||
|
||||
## Berufungsverfahren BGH (Nichtigkeit) · `de.null.bgh`
|
||||
|
||||
*Appeal BGH (Nullity)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Urteil | 0 M after | § 110 PatG | | either | | |
|
||||
| 2 | Berufungsschrift | 1 M after | § 110 PatG | | either | | |
|
||||
| 3 | Berufungsbegründung | 3 M after | § 520 Abs. 2 ZPO i.V.m. § 117 PatG | | either | | |
|
||||
| 4 | Berufungserwiderung | 2 M after | § 521 Abs. 2 ZPO i.V.m. § 117 PatG | | court-set | | |
|
||||
| 5 | Mündliche Verhandlung BGH | 0 M after | § 121 PatG | | court-set | | |
|
||||
| 6 | BGH-Urteil | 0 M after | § 122 PatG | | court-set | | |
|
||||
|
||||
## EP-Erteilungsverfahren · `epa.grant.exa`
|
||||
|
||||
*EP Grant Procedure*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anmeldung | 0 M after | Art. 75 EPÜ | | claimant | | |
|
||||
| 2 | Recherchenbericht | 6 M after | Art. 92 EPÜ | | court-set | | |
|
||||
| 3 | Veröffentlichung (A1) | 18 M after | Art. 93 EPÜ | | court-set | | |
|
||||
| 4 | Prüfungsantrag | 6 M after | R. 70(1) EPÜ | | claimant | | |
|
||||
| 5 | Mitteilung nach R. 71(3) | 0 M after | R. 71(3) EPÜ | | court-set | | |
|
||||
| 6 | Zustimmung + Übersetzung | 4 M after | R. 71(3) EPÜ | | claimant | | |
|
||||
| 7 | Erteilung (B1) | 0 M after | Art. 97 EPÜ | | court-set | | |
|
||||
|
||||
## Beschwerdeverfahren BPatG (DPMA) · `dpma.appeal.bpatg`
|
||||
|
||||
*Appeal BPatG (against DPMA Decision)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung DPMA-Entscheidung | 0 M after | § 65 PatG | | either | | |
|
||||
| 2 | Beschwerde | 1 M after | § 73 PatG | | either | | |
|
||||
| 3 | Beschwerdebegründung | 1 M after | § 75 PatG | | court-set | | |
|
||||
| 4 | Mündliche Verhandlung BPatG | 0 M after | § 78 PatG | | court-set | | |
|
||||
| 5 | BPatG-Entscheidung | 0 M after | § 78 PatG | | court-set | | |
|
||||
|
||||
## Rechtsbeschwerdeverfahren BGH · `dpma.appeal.bgh`
|
||||
|
||||
*Legal Appeal BGH*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Zustellung BPatG-Entscheidung | 0 M after | § 100 PatG | | either | | |
|
||||
| 2 | Rechtsbeschwerde | 1 M after | § 100 PatG | | either | | |
|
||||
| 3 | Rechtsbeschwerdebegründung | 1 M after | § 102 PatG | | either | | |
|
||||
| 4 | BGH-Entscheidung | 0 M after | § 100 PatG | | court-set | | |
|
||||
|
||||
## Berufungsverfahren Anordnungen · `upc.apl.order`
|
||||
|
||||
*Order Appeal (15-day track)*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Anordnung / angegriffene Entscheidung | 0 M after | RoP.220 | | court-set | | |
|
||||
| 2 | Berufung mit Zulassung | 15 d after | RoP.220.2 | | either | | |
|
||||
| 3 | Antrag auf Ermessensüberprüfung | 15 d after | RoP.220.3 | | either | | |
|
||||
| 4 | Berufungsbegründung (Orders Track) | 15 d after | RoP.224.2.b | | either | | |
|
||||
| 5 | Anschlussberufung | 15 d after | RoP.237 | | either | | |
|
||||
| 6 | Erwiderung Anschlussberufung | 15 d after | RoP.238.2 | | either | | |
|
||||
| 7 | Berufungsschrift gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
| 8 | Berufungsbegründung gegen eine in Regel 220.1(c) genannte Anordnung oder eine in Regel 220.2 oder 221.3 genannte Entscheidung | 15 d after | RoP.224.1(b) | Order referred to in Rule 220.1(c) or a decision referred to in Rule 220.2 or 221.3 | — | | |
|
||||
|
||||
## Schadensbemessungsverfahren · `upc.dmgs.cfi`
|
||||
|
||||
*Damages Determination*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Schadensbemessung | 0 M after | RoP.125 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.137.2 | | defendant | | |
|
||||
| 3 | Replik | 1 M after | RoP.139 | | claimant | | |
|
||||
| 4 | Duplik | 1 M after | RoP.139 | | defendant | | |
|
||||
|
||||
## Bucheinsichtsverfahren · `upc.disc.cfi`
|
||||
|
||||
*Lay-open Books / Discovery*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Bucheinsicht | 0 M after | RoP.190 | | claimant | | |
|
||||
| 2 | Klageerwiderung | 2 M after | RoP.142.2 | | defendant | | |
|
||||
| 3 | Replik | 14 d after | RoP.142.3 | | claimant | | |
|
||||
| 4 | Duplik | 14 d after | RoP.142.3 | | defendant | | |
|
||||
|
||||
## Einstweilige Maßnahmen · `upc.pi.cfi`
|
||||
|
||||
*Provisional Measures*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag | 0 M after | RoP.206 | | claimant | | |
|
||||
| 2 | Erwiderung | 0 M after | RoP.211.2 | | court-set | | |
|
||||
| 3 | Mündliche Verhandlung | 0 M after | RoP.195 | | court-set | | |
|
||||
| 4 | Mängelbeseitigung Antrag | 14 d after | RoP.207.6.a | | claimant | | |
|
||||
| 5 | Beschluss | 0 M after | RoP.211 | | court-set | | |
|
||||
| 6 | Klage in der Hauptsache erheben | 31 d max 20 WD after | RoP.213 | | claimant | | |
|
||||
|
||||
## Schutzschrift · `upc.pl.cfi`
|
||||
|
||||
*Protective Letter*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Einreichung der Schutzschrift | 0 M after | RoP.207 | | defendant | | |
|
||||
| 2 | Erneuerung der Schutzschrift | 6 M after | RoP.207.9 | Protective Letter | — | | |
|
||||
|
||||
## Berufungsverfahren Kosten · `upc.apl.cost`
|
||||
|
||||
*Cost-Decision Appeal*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Kostenfestsetzungsbeschluss | 0 M after | RoP.221.4 | | court-set | | |
|
||||
| 2 | Antrag auf Berufungszulassung | 15 d after | RoP.221.1 | | either | | |
|
||||
| 3 | Antrag auf Berufungszulassung gegen Kostenentscheidungen | 15 d after | RoP.220.2 | Decision on fixation of costs (Rule 157) | — | | |
|
||||
|
||||
## Negative Feststellungsklage · `upc.dni.cfi`
|
||||
|
||||
*Declaration of Non-Infringement*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Klage auf negative Feststellung der Nichtverletzung | 0 M after | RoP.063 | | claimant | | |
|
||||
| 2 | Erwiderung auf die negative Feststellungsklage | 2 M after | RoP.066 | Statement for a declaration of non-infringement | — | | |
|
||||
| 3 | Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.067 | Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
| 4 | Duplik zur Replik auf die Erwiderung zur negativen Feststellungsklage | 1 M after | RoP.068 | Reply to the Defence to the Statement for a declaration of non-infringement | — | | |
|
||||
|
||||
## Überprüfung von EPA-Entscheidungen · `upc.epo.review`
|
||||
|
||||
*Review of EPO decisions*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Überprüfung der EPA-Entscheidung | 0 M after | RoP.088 | | — | | |
|
||||
| 2 | Antrag auf Aufhebung einer Entscheidung des EPA, mit der ein Antrag auf einheitliche Wirkung zurückgewiesen wurde | 3 w after | RoP.097 | Decision of the EPO not to grant unitary effect | — | | |
|
||||
|
||||
## Separate Kostenentscheidung · `upc.costs.cfi`
|
||||
|
||||
*Separate Cost Decision*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Kostenfestsetzung | 1 M after | RoP.151 | | claimant | | |
|
||||
|
||||
## Beweissicherung / saisie · `upc.bsv.cfi`
|
||||
|
||||
*Evidence Preservation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Antrag auf Beweissicherung | 0 M after | RoP.192 | | court-set | | |
|
||||
| 2 | Antrag auf Überprüfung der Beweissicherungsanordnung | 30 d after | RoP.197.3 | Execution of measures to preserve evidence | — | | |
|
||||
| 3 | Beginn des Hauptsacheverfahrens | 31 d max 20 WD after | RoP.198 | Date specified in the Court's order to preserve evidence | — | | |
|
||||
|
||||
## Widerklage auf Nichtigkeit · `upc.ccr.cfi`
|
||||
|
||||
*Counterclaim for Revocation*
|
||||
|
||||
| # | Phase / Event | Frist | Rule | Anchor | Seite | R.9.4 | Bedingung |
|
||||
|---:|---|---|---|---|---|:---:|---|
|
||||
| 1 | Widerklage auf Nichtigkeit | 3 M after | RoP.025 | | defendant | | |
|
||||
|
||||
---
|
||||
|
||||
**Lesehilfe:**
|
||||
- ⤴ Spawn-Marker: event opens a sub-proceeding (e.g. CCR forks revocation track)
|
||||
- `with_ccr` = Widerklage auf Nichtigkeit gefilt | `with_amend` = Patentänderungsantrag | `with_cci` = Widerklage auf Verletzung (in rev.cfi)
|
||||
- Catalog ist work-in-progress: 7 compound-name rules + Patentänderung-Duplikation noch in m's split-review backlog (m/paliad#149).
|
||||
@@ -18,6 +18,7 @@ import { renderProjectsNew } from "./src/projects-new";
|
||||
import { renderProjectsDetail } from "./src/projects-detail";
|
||||
import { renderProjectsChart } from "./src/projects-chart";
|
||||
import { renderSubmissionDraft } from "./src/submission-draft";
|
||||
import { renderTemplatesAuthoring } from "./src/templates-authoring";
|
||||
import { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
@@ -255,6 +256,7 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-detail.ts"),
|
||||
join(import.meta.dir, "src/client/projects-chart.ts"),
|
||||
join(import.meta.dir, "src/client/submission-draft.ts"),
|
||||
join(import.meta.dir, "src/client/templates-authoring.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
@@ -382,6 +384,7 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
|
||||
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
|
||||
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
|
||||
await Bun.write(join(DIST, "templates-authoring.html"), renderTemplatesAuthoring());
|
||||
await Bun.write(join(DIST, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
|
||||
Binary file not shown.
BIN
frontend/public/patentsstyle/HLC-Patents-Style.dotm
Normal file
BIN
frontend/public/patentsstyle/HLC-Patents-Style.dotm
Normal file
Binary file not shown.
@@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>HL Patents Style</title>
|
||||
<title>HLC Patents Style</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #002236;
|
||||
@@ -81,31 +81,34 @@
|
||||
<body>
|
||||
<main>
|
||||
|
||||
<h1>HL <span class="accent">Patents Style</span></h1>
|
||||
<p class="lead">Das Word-Template fuer Patentschriftsaetze bei Hogan Lovells.</p>
|
||||
<h1>HLC <span class="accent">Patents Style</span></h1>
|
||||
<!-- Lead line confirmed final by m (work/head #2685): HLC-only, no Hogan Lovells mention. -->
|
||||
<p class="lead">The Word template for patent submissions at HLC.</p>
|
||||
|
||||
<h2>Was es kann</h2>
|
||||
<h2>What it does</h2>
|
||||
<ul>
|
||||
<li>Vorlagen-Stile fuer alle gaengigen Schriftsatz-Bausteine (Headings, Randnummern, Antraege, Exhibits)</li>
|
||||
<li>BuildingBlocks: ueber das Ribbon vorgefertigte Abschnitte einfuegen</li>
|
||||
<li>Sprachumschaltung DE / EN per Ribbon-Toggle</li>
|
||||
<li>Scaffolding: kompletter Schriftsatz-Aufbau mit einem Klick</li>
|
||||
<li>Margin Numbers, Exhibit-Nummerierung, SEQ-Felder</li>
|
||||
<li>Auto-Update ueber das Ribbon (siehe unten)</li>
|
||||
<li>Document styles for every common submission building block (headings, margin numbers, motions, exhibits)</li>
|
||||
<li>BuildingBlocks: insert ready-made sections straight from the ribbon</li>
|
||||
<li>DE / EN language switch via a ribbon toggle</li>
|
||||
<li>Scaffolding: build a complete submission with one click</li>
|
||||
<li>Margin numbers, exhibit numbering, SEQ fields</li>
|
||||
<li>Auto-update from the ribbon (see below)</li>
|
||||
</ul>
|
||||
|
||||
<h2>Aktualisierungen</h2>
|
||||
<p>Im Ribbon-Tab <em>HL Patent</em> → Gruppe <em>Manage</em> → <kbd>Check for Updates</kbd>. Holt das aktuelle Manifest von diesem Server, prueft die Version, laedt die neue <code>.dotm</code> nur bei Bedarf, verifiziert per SHA256, installiert. Nach dem Update Word neu starten.</p>
|
||||
<h2>Updates</h2>
|
||||
<p>In the ribbon tab <em>HLC Patent</em> → group <em>Manage</em> → <kbd>Check for Updates</kbd>. It fetches the current manifest from this server, checks the version, downloads the new <code>.dotm</code> only when needed, verifies it via SHA256, and installs it. Restart Word after updating.</p>
|
||||
|
||||
<h2>Frische Installation</h2>
|
||||
<p>Wer das Template noch nicht installiert hat, laedt einmal manuell die aktuelle Version und kopiert sie in den Word-Startup-Ordner. Den Rest macht die <code>InstallTemplate</code>-Routine im Template selbst.</p>
|
||||
<p><a class="download" href="HL-Patents-Style.dotm" download>HL Patents Style.dotm herunterladen</a></p>
|
||||
<h2>Fresh install</h2>
|
||||
<p>If you haven’t installed the template yet, download the current version once manually and copy it into the Word startup folder. The <code>InstallTemplate</code> routine inside the template handles the rest.</p>
|
||||
<!-- HLC-Patents-Style.dotm published + verified live (work/head #2697/#2698); href flipped.
|
||||
HL-Patents-Style.dotm kept on disk for now, dropped on a later publish. -->
|
||||
<p><a class="download" href="HLC-Patents-Style.dotm" download>Download HLC Patents Style</a></p>
|
||||
|
||||
<h2>Hilfe & Feedback</h2>
|
||||
<p>Fehler, Wuensche, Stilfragen, Build-Probleme: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HL%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
<h2>Help & feedback</h2>
|
||||
<p>Bugs, requests, style questions, build problems: <a href="mailto:matthias.siebels@hoganlovells.com?subject=HLC%20Patents%20Style">matthias.siebels@hoganlovells.com</a></p>
|
||||
|
||||
<footer>
|
||||
<p>Update-Endpoint: <code>paliad.msbls.de/patentstyle/</code> · Mirror: <code>hihlc.msbls.de/patentstyle/</code></p>
|
||||
<p>Update endpoint: <code>paliad.msbls.de/patentsstyle/</code> · Mirror: <code>hihlc.msbls.de/patentsstyle/</code></p>
|
||||
<p id="ver"></p>
|
||||
</footer>
|
||||
|
||||
@@ -115,7 +118,7 @@
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(j => {
|
||||
if (j && j.version) {
|
||||
document.getElementById('ver').textContent = 'Aktuell ausgeliefert: ' + j.version;
|
||||
document.getElementById('ver').textContent = 'Currently served: ' + j.version;
|
||||
}
|
||||
})
|
||||
.catch(() => {});
|
||||
5
frontend/public/patentsstyle/version.json
Normal file
5
frontend/public/patentsstyle/version.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"version": "v0.260601-1614",
|
||||
"dotm_url": "https://paliad.msbls.de/patentsstyle/HLC-Patents-Style.dotm",
|
||||
"sha256": "A5B3F4FA2DA97242D0BA8B63B29F4F637600E8EF7217CCF446942B7C6DB3C9A4"
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
{
|
||||
"version": "v0.260518",
|
||||
"dotm_url": "https://paliad.msbls.de/patentstyle/HL-Patents-Style.dotm",
|
||||
"sha256": "5CEA98A29D2FD6D9970B9A2499054DF52685A1116459E07F9290B0D0ADD521F4"
|
||||
}
|
||||
322
frontend/src/client/base-preview-modal.ts
Normal file
322
frontend/src/client/base-preview-modal.ts
Normal file
@@ -0,0 +1,322 @@
|
||||
// Shared base-preview modal (t-paliad-370 S3, PRD §2.1).
|
||||
//
|
||||
// Opened from the editor's 👁 Vorschau button and from the catalog row
|
||||
// kebabs ("Vorschau Vorlagenbasis"). Lets the lawyer browse template bases,
|
||||
// flip the output language + data-mode, and — in the editor — commit a base
|
||||
// with "Diese Basis verwenden", replacing the blind <select> as the chooser.
|
||||
//
|
||||
// S3 renders the EXISTING structural preview HTML (GET /api/submission-preview)
|
||||
// in a swappable body region. S4 swaps that region for a truthful .docx→image
|
||||
// render behind the same modal + endpoint shape; the pager + spinner are the
|
||||
// shell that work already expects.
|
||||
//
|
||||
// Built once and body-attached so a single modal serves all three host pages
|
||||
// (editor, project Schriftsätze tab, global picker) without triplicated SSR.
|
||||
|
||||
interface BaseRow { id: string; label_de: string; label_en: string }
|
||||
interface TemplateRow { version_id?: string; name_de: string; name_en: string }
|
||||
|
||||
export interface BasePreviewOpts {
|
||||
/** submission_code — drives caption + the fallback template. */
|
||||
code: string;
|
||||
/** Initial output language. */
|
||||
lang: string;
|
||||
/** Editor context: preview against this draft's resolved data. */
|
||||
draftId?: string;
|
||||
/** Catalog (project tab) context: scope sample/own data to this project. */
|
||||
projectId?: string | null;
|
||||
/** Current selection (base_id | "tpl:"+version_id | ""), preselected. */
|
||||
currentBase?: string;
|
||||
/** Default data-mode; falls back to "mine" with a draft, else "sample". */
|
||||
defaultData?: "mine" | "sample";
|
||||
/** Editor: commit the chosen base. Omitted ⇒ catalog (preview-only). */
|
||||
onApply?: (baseValue: string) => void;
|
||||
}
|
||||
|
||||
interface ModalState {
|
||||
opts: BasePreviewOpts;
|
||||
selectedBase: string;
|
||||
lang: string;
|
||||
data: "mine" | "sample";
|
||||
}
|
||||
|
||||
let modal: HTMLElement | null = null;
|
||||
let bodyRegion: HTMLElement | null = null;
|
||||
let baseSelect: HTMLSelectElement | null = null;
|
||||
let applyBtn: HTMLButtonElement | null = null;
|
||||
let pager: HTMLElement | null = null;
|
||||
let state: ModalState | null = null;
|
||||
|
||||
// Base/template catalog, fetched once and reused across opens.
|
||||
let bases: BaseRow[] = [];
|
||||
let templates: TemplateRow[] = [];
|
||||
let catalogLoaded = false;
|
||||
let reqSeq = 0; // guards against out-of-order preview responses
|
||||
|
||||
// Truthful-render page images for the current preview (data URIs); empty when
|
||||
// the body is showing the structural HTML fallback.
|
||||
let truthfulPages: string[] = [];
|
||||
let currentPage = 0;
|
||||
|
||||
function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function ensureBuilt(): void {
|
||||
if (modal) return;
|
||||
|
||||
modal = document.createElement("div");
|
||||
modal.className = "base-preview-overlay";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.style.display = "none";
|
||||
|
||||
const card = document.createElement("div");
|
||||
card.className = "base-preview-card";
|
||||
modal.appendChild(card);
|
||||
|
||||
// Header: base switcher + language toggle + data toggle + close.
|
||||
const header = document.createElement("header");
|
||||
header.className = "base-preview-header";
|
||||
header.innerHTML = `
|
||||
<div class="base-preview-controls">
|
||||
<label class="base-preview-field">
|
||||
<span>${esc(isEN() ? "Template base" : "Vorlagenbasis")}</span>
|
||||
<select class="base-preview-base"></select>
|
||||
</label>
|
||||
<div class="base-preview-toggle base-preview-lang" role="group" aria-label="${esc(isEN() ? "Language" : "Sprache")}">
|
||||
<button type="button" data-lang="de">DE</button>
|
||||
<button type="button" data-lang="en">EN</button>
|
||||
</div>
|
||||
<div class="base-preview-toggle base-preview-data" role="group" aria-label="${esc(isEN() ? "Data" : "Daten")}">
|
||||
<button type="button" data-data="mine">${esc(isEN() ? "My data" : "Meine Daten")}</button>
|
||||
<button type="button" data-data="sample">${esc(isEN() ? "Sample" : "Beispiel")}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="base-preview-close" aria-label="${esc(isEN() ? "Close" : "Schließen")}">×</button>
|
||||
`;
|
||||
card.appendChild(header);
|
||||
|
||||
// Swappable body region — S3 structural HTML, S4 truthful page image.
|
||||
bodyRegion = document.createElement("div");
|
||||
bodyRegion.className = "base-preview-body";
|
||||
card.appendChild(bodyRegion);
|
||||
|
||||
// Footer: pager (S4) + apply.
|
||||
const footer = document.createElement("footer");
|
||||
footer.className = "base-preview-footer";
|
||||
pager = document.createElement("div");
|
||||
pager.className = "base-preview-pager";
|
||||
footer.appendChild(pager);
|
||||
applyBtn = document.createElement("button");
|
||||
applyBtn.type = "button";
|
||||
applyBtn.className = "btn-primary btn-cta-lime btn-small base-preview-apply";
|
||||
applyBtn.textContent = isEN() ? "Use this base" : "Diese Basis verwenden";
|
||||
footer.appendChild(applyBtn);
|
||||
card.appendChild(footer);
|
||||
|
||||
document.body.appendChild(modal);
|
||||
|
||||
baseSelect = header.querySelector<HTMLSelectElement>(".base-preview-base");
|
||||
|
||||
// Wiring.
|
||||
baseSelect?.addEventListener("change", () => {
|
||||
if (!state || !baseSelect) return;
|
||||
state.selectedBase = baseSelect.value;
|
||||
renderPreview();
|
||||
});
|
||||
header.querySelectorAll<HTMLButtonElement>(".base-preview-lang button").forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
if (!state) return;
|
||||
state.lang = b.dataset.lang === "en" ? "en" : "de";
|
||||
paintToggles();
|
||||
renderPreview();
|
||||
});
|
||||
});
|
||||
header.querySelectorAll<HTMLButtonElement>(".base-preview-data button").forEach((b) => {
|
||||
b.addEventListener("click", () => {
|
||||
if (!state) return;
|
||||
state.data = b.dataset.data === "sample" ? "sample" : "mine";
|
||||
paintToggles();
|
||||
renderPreview();
|
||||
});
|
||||
});
|
||||
header.querySelector<HTMLButtonElement>(".base-preview-close")?.addEventListener("click", close);
|
||||
applyBtn.addEventListener("click", () => {
|
||||
if (!state || !state.opts.onApply) return;
|
||||
state.opts.onApply(state.selectedBase);
|
||||
close();
|
||||
});
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) close();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal && modal.style.display !== "none") close();
|
||||
});
|
||||
}
|
||||
|
||||
function paintToggles(): void {
|
||||
if (!modal || !state) return;
|
||||
modal.querySelectorAll<HTMLButtonElement>(".base-preview-lang button").forEach((b) => {
|
||||
b.classList.toggle("is-active", b.dataset.lang === state!.lang);
|
||||
});
|
||||
modal.querySelectorAll<HTMLButtonElement>(".base-preview-data button").forEach((b) => {
|
||||
b.classList.toggle("is-active", b.dataset.data === state!.data);
|
||||
});
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
if (catalogLoaded) return;
|
||||
try {
|
||||
const [bRes, tRes] = await Promise.all([
|
||||
fetch("/api/submission-bases", { credentials: "include" }),
|
||||
fetch("/api/templates", { credentials: "include" }),
|
||||
]);
|
||||
if (bRes.ok) bases = ((await bRes.json()) as { bases?: BaseRow[] }).bases ?? [];
|
||||
if (tRes.ok) {
|
||||
templates = (((await tRes.json()) as { templates?: TemplateRow[] }).templates ?? [])
|
||||
.filter((t) => !!t.version_id);
|
||||
}
|
||||
} catch {
|
||||
/* leave catalog empty — the modal still previews the current/fallback base */
|
||||
}
|
||||
catalogLoaded = true;
|
||||
}
|
||||
|
||||
function paintBaseSelect(): void {
|
||||
if (!baseSelect || !state) return;
|
||||
baseSelect.innerHTML = "";
|
||||
|
||||
const fallback = document.createElement("option");
|
||||
fallback.value = "";
|
||||
fallback.textContent = isEN() ? "— Standard template —" : "— Standardvorlage —";
|
||||
baseSelect.appendChild(fallback);
|
||||
|
||||
for (const b of bases) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = b.id;
|
||||
opt.textContent = isEN() ? b.label_en : b.label_de;
|
||||
baseSelect.appendChild(opt);
|
||||
}
|
||||
if (templates.length > 0) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
|
||||
for (const t of templates) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "tpl:" + t.version_id;
|
||||
opt.textContent = isEN() ? t.name_en : t.name_de;
|
||||
group.appendChild(opt);
|
||||
}
|
||||
baseSelect.appendChild(group);
|
||||
}
|
||||
baseSelect.value = state.selectedBase;
|
||||
}
|
||||
|
||||
async function renderPreview(): Promise<void> {
|
||||
if (!bodyRegion || !state) return;
|
||||
const seq = ++reqSeq;
|
||||
truthfulPages = [];
|
||||
currentPage = 0;
|
||||
bodyRegion.innerHTML = `<div class="base-preview-spinner">${esc(isEN() ? "Rendering…" : "Wird gerendert…")}</div>`;
|
||||
if (pager) pager.textContent = "";
|
||||
|
||||
const params = new URLSearchParams({
|
||||
base: state.selectedBase,
|
||||
code: state.opts.code,
|
||||
lang: state.lang,
|
||||
data: state.data,
|
||||
// Ask for the truthful page image; the backend falls back to structural
|
||||
// HTML when the render sidecar isn't provisioned (graceful pre-infra).
|
||||
fidelity: "truthful",
|
||||
});
|
||||
if (state.opts.draftId) {
|
||||
params.set("draft", state.opts.draftId);
|
||||
} else if (state.opts.projectId) {
|
||||
params.set("project", state.opts.projectId);
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/submission-preview?${params.toString()}`, { credentials: "include" });
|
||||
if (seq !== reqSeq) return; // a newer request superseded this one
|
||||
if (!res.ok) {
|
||||
bodyRegion.innerHTML = `<div class="base-preview-error">${esc(isEN() ? "Preview unavailable." : "Vorschau nicht verfügbar.")}</div>`;
|
||||
return;
|
||||
}
|
||||
const data = (await res.json()) as { truthful?: boolean; pages?: string[]; preview_html?: string };
|
||||
if (seq !== reqSeq) return;
|
||||
if (data.truthful && data.pages && data.pages.length > 0) {
|
||||
truthfulPages = data.pages;
|
||||
currentPage = 0;
|
||||
paintTruthfulPage();
|
||||
} else {
|
||||
// Structural HTML fallback (S3) on a paper-like sheet.
|
||||
bodyRegion.innerHTML = `<div class="base-preview-page">${data.preview_html ?? ""}</div>`;
|
||||
if (pager) pager.textContent = isEN() ? "Structural preview" : "Struktur-Vorschau";
|
||||
}
|
||||
} catch {
|
||||
if (seq !== reqSeq) return;
|
||||
bodyRegion.innerHTML = `<div class="base-preview-error">${esc(isEN() ? "Preview failed." : "Vorschau fehlgeschlagen.")}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// paintTruthfulPage shows the current page image + a ‹ n / N › pager with
|
||||
// prev/next. Pages are already in memory (data URIs), so paging is instant.
|
||||
function paintTruthfulPage(): void {
|
||||
if (!bodyRegion || truthfulPages.length === 0) return;
|
||||
bodyRegion.innerHTML = `<img class="base-preview-img" alt="${esc(isEN() ? "Page preview" : "Seitenvorschau")}" src="${truthfulPages[currentPage]}" />`;
|
||||
if (!pager) return;
|
||||
if (truthfulPages.length === 1) {
|
||||
pager.textContent = "";
|
||||
return;
|
||||
}
|
||||
pager.innerHTML = "";
|
||||
const prev = document.createElement("button");
|
||||
prev.type = "button";
|
||||
prev.className = "base-preview-page-nav";
|
||||
prev.textContent = "‹";
|
||||
prev.disabled = currentPage === 0;
|
||||
prev.addEventListener("click", () => { if (currentPage > 0) { currentPage--; paintTruthfulPage(); } });
|
||||
const label = document.createElement("span");
|
||||
label.textContent = `${isEN() ? "Page" : "Seite"} ${currentPage + 1} / ${truthfulPages.length}`;
|
||||
const next = document.createElement("button");
|
||||
next.type = "button";
|
||||
next.className = "base-preview-page-nav";
|
||||
next.textContent = "›";
|
||||
next.disabled = currentPage >= truthfulPages.length - 1;
|
||||
next.addEventListener("click", () => { if (currentPage < truthfulPages.length - 1) { currentPage++; paintTruthfulPage(); } });
|
||||
pager.append(prev, label, next);
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
if (modal) modal.style.display = "none";
|
||||
state = null;
|
||||
}
|
||||
|
||||
/** Open the base-preview modal. See BasePreviewOpts. */
|
||||
export async function openBasePreview(opts: BasePreviewOpts): Promise<void> {
|
||||
ensureBuilt();
|
||||
if (!modal || !applyBtn) return;
|
||||
|
||||
state = {
|
||||
opts,
|
||||
selectedBase: opts.currentBase ?? "",
|
||||
lang: opts.lang === "en" ? "en" : "de",
|
||||
data: opts.defaultData ?? (opts.draftId ? "mine" : "sample"),
|
||||
};
|
||||
|
||||
// Apply button only when the caller can commit (editor); catalog = look-only.
|
||||
applyBtn.style.display = opts.onApply ? "" : "none";
|
||||
|
||||
modal.style.display = "";
|
||||
paintToggles();
|
||||
await loadCatalog();
|
||||
if (!state) return; // closed while loading
|
||||
paintBaseSelect();
|
||||
void renderPreview();
|
||||
}
|
||||
262
frontend/src/client/builder-akte.ts
Normal file
262
frontend/src/client/builder-akte.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
// Akte-mode wiring for the Litigation Builder (m/paliad#153 B4,
|
||||
// t-paliad-347).
|
||||
//
|
||||
// PRD §2.3 + §3.1 + §3.2: the page-header Akte picker lists every
|
||||
// project (`type='case'`) the user can see. Picking one POSTs to
|
||||
// /api/builder/scenarios/from-project, which mints a project-backed
|
||||
// scenario (origin_project_id pinned) seeded with the project's
|
||||
// proceeding + scenario_flags + completed deadlines. Subsequent
|
||||
// builder edits dual-write through to paliad.deadlines + projects.
|
||||
// scenario_flags via the server-side dual-write hooks.
|
||||
//
|
||||
// The picker is its own module so the builder.ts orchestrator only
|
||||
// has to expose two hooks:
|
||||
//
|
||||
// - `onProjectChosen(projectId)` — called when the user picks a
|
||||
// project. Builder calls the from-project endpoint and loads the
|
||||
// returned scenario.
|
||||
// - `setSelectedProject(scenario)` — called after a scenario loads
|
||||
// so the picker reflects the current Akte (or "— ohne —" for
|
||||
// kontextfrei scenarios).
|
||||
//
|
||||
// Cross-surface scenario-flag-changed (mig 154 ssoT, m/paliad#149):
|
||||
// the builder listens to the existing CustomEvent so any peer surface
|
||||
// that PATCHes /api/projects/{id}/scenario-flags triggers a re-fetch
|
||||
// on the builder's active proceeding when the projectId matches the
|
||||
// scenario's origin_project_id. The dispatch direction is already
|
||||
// covered by patchScenarioFlags inside scenario-flags.ts — the
|
||||
// builder's own PATCH /api/projects/.../scenario-flags goes through
|
||||
// that helper so peer surfaces stay in sync without a separate dispatch.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface AkteProjectMeta {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
}
|
||||
|
||||
export type OnProjectChosen = (projectId: string) => void | Promise<void>;
|
||||
|
||||
interface State {
|
||||
projects: AkteProjectMeta[];
|
||||
loaded: boolean;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
projects: [],
|
||||
loaded: false,
|
||||
};
|
||||
|
||||
// fetchAkteProjects pulls every type=case project the caller can see.
|
||||
// Visibility is enforced by /api/projects via the project_teams /
|
||||
// can_see_project predicate. We filter client-side to projects with a
|
||||
// proceeding_type_id — those are the ones the builder can render. We
|
||||
// don't filter server-side because /api/projects' filter param doesn't
|
||||
// accept proceeding_type_id_not_null and round-tripping for that one
|
||||
// reason isn't worth a new endpoint.
|
||||
export async function fetchAkteProjects(): Promise<AkteProjectMeta[]> {
|
||||
try {
|
||||
const resp = await fetch("/api/projects?type=case", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: /api/projects", resp.status);
|
||||
return [];
|
||||
}
|
||||
const rows = (await resp.json()) as Array<{
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
proceeding_type_id?: number | null;
|
||||
our_side?: string | null;
|
||||
status?: string;
|
||||
}>;
|
||||
return rows
|
||||
.filter((r) => r.proceeding_type_id != null && (r.status ?? "active") === "active")
|
||||
.map((r) => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
reference: r.reference ?? null,
|
||||
case_number: r.case_number ?? null,
|
||||
proceeding_type_id: r.proceeding_type_id ?? null,
|
||||
our_side: r.our_side ?? null,
|
||||
}));
|
||||
} catch (e) {
|
||||
console.error("builder-akte: fetch projects failed", e);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// formatProjectLabel renders the dropdown row for a project. Reference
|
||||
// + title are the primary anchors; the case_number tail disambiguates
|
||||
// when two cases share a reference family.
|
||||
function formatProjectLabel(p: AkteProjectMeta): string {
|
||||
const parts: string[] = [];
|
||||
if (p.reference) parts.push(p.reference);
|
||||
parts.push(p.title);
|
||||
if (p.case_number) parts.push("(" + p.case_number + ")");
|
||||
return parts.join(" · ");
|
||||
}
|
||||
|
||||
// renderAktePicker fills the existing <select id="builder-akte-picker">
|
||||
// with the project list + a "— ohne —" sentinel. Idempotent.
|
||||
function renderAktePicker(selectedId: string | null): void {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) return;
|
||||
const none = t("builder.akte.none");
|
||||
const opts: string[] = [`<option value="" data-i18n="builder.akte.none">${escHtml(none)}</option>`];
|
||||
for (const p of state.projects) {
|
||||
const selected = p.id === selectedId ? " selected" : "";
|
||||
opts.push(
|
||||
`<option value="${escAttr(p.id)}"${selected}>${escHtml(formatProjectLabel(p))}</option>`,
|
||||
);
|
||||
}
|
||||
sel.innerHTML = opts.join("");
|
||||
}
|
||||
|
||||
// mountAktePicker is the entry point. It fetches the project list once,
|
||||
// wires the dropdown change event to the supplied callback, and
|
||||
// returns a controller exposing setSelectedProject so the builder can
|
||||
// keep the picker reflective of the active scenario's Akte.
|
||||
//
|
||||
// The picker re-enables itself the moment projects load. While
|
||||
// loading, the existing `disabled` attribute (set in procedures.tsx)
|
||||
// stays so users don't pick during the fetch — but if the user lands
|
||||
// on the page after the catalog is cached this is essentially
|
||||
// instantaneous.
|
||||
export interface AktePickerHandle {
|
||||
setSelectedProject: (projectId: string | null) => void;
|
||||
isAkteMode: () => boolean;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
|
||||
export async function mountAktePicker(onChosen: OnProjectChosen): Promise<AktePickerHandle> {
|
||||
const sel = document.getElementById("builder-akte-picker") as HTMLSelectElement | null;
|
||||
if (!sel) {
|
||||
return {
|
||||
setSelectedProject: () => {},
|
||||
isAkteMode: () => false,
|
||||
reload: async () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// First load — fill the dropdown, enable it, wire change.
|
||||
state.projects = await fetchAkteProjects();
|
||||
state.loaded = true;
|
||||
renderAktePicker(null);
|
||||
sel.disabled = false;
|
||||
|
||||
sel.addEventListener("change", () => {
|
||||
const id = sel.value;
|
||||
if (!id) {
|
||||
// "— ohne —" reset is intentional; the builder treats this as
|
||||
// "leave the current scenario alone, just clear the picker".
|
||||
// Switching the active scenario to a non-Akte one happens via
|
||||
// the scenario picker, not by clicking the empty Akte option.
|
||||
return;
|
||||
}
|
||||
void onChosen(id);
|
||||
});
|
||||
|
||||
return {
|
||||
setSelectedProject: (projectId: string | null) => {
|
||||
const next = projectId ?? "";
|
||||
// Renderless quick-sync when the option is present; otherwise
|
||||
// re-render so the option appears (covers freshly created
|
||||
// projects since this picker last loaded).
|
||||
const optEl = sel.querySelector<HTMLOptionElement>(`option[value="${cssEscape(next)}"]`);
|
||||
if (next && !optEl) {
|
||||
renderAktePicker(next);
|
||||
} else {
|
||||
sel.value = next;
|
||||
}
|
||||
},
|
||||
isAkteMode: () => sel.value !== "",
|
||||
reload: async () => {
|
||||
state.projects = await fetchAkteProjects();
|
||||
renderAktePicker(sel.value || null);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// createScenarioFromProject posts to the B4 entry point. Returns the
|
||||
// new scenario's deep payload on success (id + proceedings + events),
|
||||
// null on failure. Caller is expected to load the returned scenario
|
||||
// via the builder's existing fetchScenarioDeep / state.active path.
|
||||
export async function createScenarioFromProject(projectId: string): Promise<{ id: string } | null> {
|
||||
try {
|
||||
const resp = await fetch("/api/builder/scenarios/from-project", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Accept: "application/json" },
|
||||
body: JSON.stringify({ project_id: projectId }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
console.warn("builder-akte: from-project", resp.status, await resp.text().catch(() => ""));
|
||||
return null;
|
||||
}
|
||||
const out = await resp.json();
|
||||
return out && typeof out.id === "string" ? { id: out.id } : null;
|
||||
} catch (e) {
|
||||
console.error("builder-akte: from-project failed", e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// renderAkteBanner toggles the "Aus Akte: <code>" badge next to the
|
||||
// scenario picker. The badge is a <span class="builder-akte-banner">
|
||||
// inserted/removed by this helper; CSS gives it a lime tint to match
|
||||
// the Akte affordance throughout the app. Pass `null` (or omit
|
||||
// projectId) to hide.
|
||||
export function renderAkteBanner(projectId: string | null): void {
|
||||
const host = document.querySelector(".builder-pageheader") as HTMLElement | null;
|
||||
if (!host) return;
|
||||
let badge = document.getElementById("builder-akte-banner");
|
||||
if (!projectId) {
|
||||
if (badge) badge.remove();
|
||||
return;
|
||||
}
|
||||
const meta = state.projects.find((p) => p.id === projectId);
|
||||
const label = meta ? formatProjectLabel(meta) : projectId.slice(0, 8);
|
||||
const text =
|
||||
t("builder.akte.banner.prefix") + " " + label;
|
||||
if (!badge) {
|
||||
badge = document.createElement("span");
|
||||
badge.id = "builder-akte-banner";
|
||||
badge.className = "builder-akte-banner";
|
||||
badge.setAttribute("role", "note");
|
||||
host.appendChild(badge);
|
||||
}
|
||||
badge.textContent = text;
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
// helpers
|
||||
// ────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function escAttr(s: string): string {
|
||||
return s.replace(/&/g, "&").replace(/"/g, """);
|
||||
}
|
||||
|
||||
function escHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape is a small fallback for browsers that don't yet expose
|
||||
// CSS.escape. UUIDs only contain [0-9a-f-] so even the naïve replacer
|
||||
// keeps us safe; the function exists to make intent obvious.
|
||||
function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof (CSS as { escape?: (s: string) => string }).escape === "function") {
|
||||
return (CSS as { escape: (s: string) => string }).escape(s);
|
||||
}
|
||||
return s.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
|
||||
}
|
||||
147
frontend/src/client/builder-picker.ts
Normal file
147
frontend/src/client/builder-picker.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Add-proceeding inline picker for the Litigation Builder.
|
||||
//
|
||||
// PRD §3 + §3.1: "+ Verfahren hinzufügen" button at the bottom of the
|
||||
// triplet stack opens an inline picker. Forum chip row (UPC for v1)
|
||||
// gates the Verfahren chip row, click → callback. Designed for B1's
|
||||
// single-triplet flow and B2's multi-triplet stacking with no shape
|
||||
// change between slices.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface ProceedingTypeMeta {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
nameEN: string;
|
||||
// group / jurisdiction. The proceeding-types API returns "UPC" /
|
||||
// "DE" / etc. as the canonical jurisdiction; for v1 the picker
|
||||
// only renders UPC.
|
||||
group?: string;
|
||||
jurisdiction?: string;
|
||||
}
|
||||
|
||||
type OnPick = (meta: ProceedingTypeMeta) => void | Promise<void>;
|
||||
|
||||
let activePopover: HTMLElement | null = null;
|
||||
|
||||
export function mountAddProceedingPicker(
|
||||
anchor: HTMLElement,
|
||||
types: ProceedingTypeMeta[],
|
||||
onPick: OnPick,
|
||||
): void {
|
||||
closeActive();
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "builder-picker-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("builder.picker.aria"));
|
||||
|
||||
const header = document.createElement("div");
|
||||
header.className = "builder-picker-header";
|
||||
header.innerHTML = `
|
||||
<strong class="builder-picker-title">${escHtml(t("builder.picker.title"))}</strong>
|
||||
<button type="button" class="builder-picker-close" aria-label="${escAttr(t("builder.picker.close"))}">×</button>
|
||||
`;
|
||||
pop.appendChild(header);
|
||||
|
||||
// Forum row — UPC only for v1. Disabled chips render greyed.
|
||||
const forumRow = document.createElement("div");
|
||||
forumRow.className = "builder-picker-row";
|
||||
forumRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.forum"))}</span>
|
||||
<div class="builder-picker-chips">
|
||||
<button type="button" class="builder-picker-chip is-active" data-forum="UPC">UPC</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DE" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DE</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="EPA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">EPA</button>
|
||||
<button type="button" class="builder-picker-chip" data-forum="DPMA" disabled title="${escAttr(t("builder.picker.future_jurisdiction"))}">DPMA</button>
|
||||
</div>
|
||||
`;
|
||||
pop.appendChild(forumRow);
|
||||
|
||||
const procRow = document.createElement("div");
|
||||
procRow.className = "builder-picker-row";
|
||||
procRow.innerHTML = `
|
||||
<span class="builder-picker-axis-label">${escHtml(t("builder.picker.axis.proc"))}</span>
|
||||
<div class="builder-picker-chips builder-picker-chips--wrap" id="builder-picker-proc-chips"></div>
|
||||
`;
|
||||
pop.appendChild(procRow);
|
||||
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "builder-picker-empty";
|
||||
empty.hidden = true;
|
||||
empty.textContent = t("builder.picker.empty");
|
||||
pop.appendChild(empty);
|
||||
|
||||
const procHost = pop.querySelector("#builder-picker-proc-chips") as HTMLElement;
|
||||
const lang = document.documentElement.lang === "en" ? "en" : "de";
|
||||
for (const meta of types) {
|
||||
const label = lang === "en" ? (meta.nameEN || meta.name) : meta.name;
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-picker-chip builder-picker-chip--proc";
|
||||
chip.setAttribute("data-code", meta.code);
|
||||
chip.innerHTML = `<span class="builder-picker-chip-code">${escHtml(meta.code)}</span>
|
||||
<span class="builder-picker-chip-name">${escHtml(label)}</span>`;
|
||||
chip.addEventListener("click", () => {
|
||||
closeActive();
|
||||
void onPick(meta);
|
||||
});
|
||||
procHost.appendChild(chip);
|
||||
}
|
||||
if (types.length === 0) empty.hidden = false;
|
||||
|
||||
header.querySelector(".builder-picker-close")?.addEventListener("click", () => {
|
||||
closeActive();
|
||||
});
|
||||
|
||||
// Position the popover under the anchor button.
|
||||
positionUnder(pop, anchor);
|
||||
document.body.appendChild(pop);
|
||||
activePopover = pop;
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
document.addEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
function positionUnder(pop: HTMLElement, anchor: HTMLElement): void {
|
||||
const rect = anchor.getBoundingClientRect();
|
||||
pop.style.position = "absolute";
|
||||
const top = rect.bottom + window.scrollY + 6;
|
||||
// Default left = anchor's left; clamp so popover stays in viewport.
|
||||
const left = Math.max(8, rect.left + window.scrollX);
|
||||
pop.style.top = `${top}px`;
|
||||
pop.style.left = `${left}px`;
|
||||
pop.style.maxWidth = "min(640px, calc(100vw - 24px))";
|
||||
pop.style.zIndex = "60";
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!activePopover) return;
|
||||
const target = ev.target as Node;
|
||||
if (activePopover.contains(target)) return;
|
||||
closeActive();
|
||||
}
|
||||
|
||||
function onEscape(ev: KeyboardEvent): void {
|
||||
if (ev.key === "Escape") closeActive();
|
||||
}
|
||||
|
||||
function closeActive(): void {
|
||||
if (activePopover) {
|
||||
activePopover.remove();
|
||||
activePopover = null;
|
||||
}
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
document.removeEventListener("keydown", onEscape, true);
|
||||
}
|
||||
|
||||
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, """);
|
||||
}
|
||||
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, """);
|
||||
}
|
||||
412
frontend/src/client/builder-search.ts
Normal file
412
frontend/src/client/builder-search.ts
Normal file
@@ -0,0 +1,412 @@
|
||||
// Universal search dropdown for the Litigation Builder (m/paliad#153 B3).
|
||||
//
|
||||
// PRD §2.2 + §3.1 + §6.3: the page-header search box ("Suche") drives
|
||||
// a typed dropdown returning grouped event / scenario / project hits.
|
||||
// Picking an event lands the user on a scratch scenario with one
|
||||
// triplet anchored on that event's proceeding type. Picking a scenario
|
||||
// loads it; picking a project (Akte) is deferred to B4 (the dropdown
|
||||
// row renders but pick falls through to a console hint until B4 wires
|
||||
// project-backed scenarios).
|
||||
//
|
||||
// The controller is owned by builder.ts; this module exports
|
||||
// `mountBuilderSearch` which wires the input + dropdown lifecycle and
|
||||
// invokes the supplied callbacks. No module-level state — re-mounting
|
||||
// is safe.
|
||||
|
||||
import { t } from "./i18n";
|
||||
|
||||
export interface EventSearchHit {
|
||||
id: string;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
event_kind?: string | null;
|
||||
primary_party?: string | null;
|
||||
anchor_rule_id: string;
|
||||
follow_up_count: number;
|
||||
proceeding_type: {
|
||||
id: number;
|
||||
code: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
jurisdiction?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ScenarioSearchHit {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ProjectSearchHit {
|
||||
id: string;
|
||||
type: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
case_number?: string | null;
|
||||
matter_number?: string | null;
|
||||
client_number?: string | null;
|
||||
}
|
||||
|
||||
export interface UniversalSearchResponse {
|
||||
query: string;
|
||||
events: EventSearchHit[];
|
||||
scenarios: ScenarioSearchHit[];
|
||||
projects: ProjectSearchHit[];
|
||||
counts: { events: number; scenarios: number; projects: number };
|
||||
}
|
||||
|
||||
export interface BuilderSearchCallbacks {
|
||||
onPickEvent: (hit: EventSearchHit) => void | Promise<void>;
|
||||
onPickScenario: (hit: ScenarioSearchHit) => void | Promise<void>;
|
||||
onPickProject?: (hit: ProjectSearchHit) => void | Promise<void>;
|
||||
}
|
||||
|
||||
interface Controller {
|
||||
input: HTMLInputElement;
|
||||
dropdown: HTMLElement;
|
||||
open: boolean;
|
||||
abort: AbortController | null;
|
||||
debounceTimer: number | null;
|
||||
lang: "de" | "en";
|
||||
}
|
||||
|
||||
let active: Controller | null = null;
|
||||
|
||||
// mountBuilderSearch wires the universal search behavior onto an
|
||||
// existing <input>. Idempotent — re-calling tears down the previous
|
||||
// dropdown and rebinds. Returns a controller exposing focus() so the
|
||||
// entry-mode toggle in builder.ts can land on the search input.
|
||||
export function mountBuilderSearch(
|
||||
input: HTMLInputElement,
|
||||
cb: BuilderSearchCallbacks,
|
||||
): { focus: () => void; close: () => void } {
|
||||
teardown();
|
||||
|
||||
const lang: "de" | "en" = document.documentElement.lang === "en" ? "en" : "de";
|
||||
|
||||
// Single dropdown container, anchored under the input. Positioned
|
||||
// absolutely so it floats above the canvas without reflowing layout.
|
||||
const dropdown = document.createElement("div");
|
||||
dropdown.className = "builder-search-dropdown";
|
||||
dropdown.setAttribute("role", "listbox");
|
||||
dropdown.hidden = true;
|
||||
document.body.appendChild(dropdown);
|
||||
|
||||
active = {
|
||||
input,
|
||||
dropdown,
|
||||
open: false,
|
||||
abort: null,
|
||||
debounceTimer: null,
|
||||
lang,
|
||||
};
|
||||
|
||||
input.addEventListener("input", onInput);
|
||||
input.addEventListener("focus", onFocus);
|
||||
input.addEventListener("keydown", onKeydown);
|
||||
document.addEventListener("click", onOutsideClick, true);
|
||||
window.addEventListener("resize", reposition);
|
||||
window.addEventListener("scroll", reposition, true);
|
||||
|
||||
// Click handler is wired once on the dropdown root via event
|
||||
// delegation; per-row data attributes identify the hit type.
|
||||
dropdown.addEventListener("click", (ev) => {
|
||||
const row = (ev.target as HTMLElement).closest<HTMLElement>(".builder-search-row");
|
||||
if (!row) return;
|
||||
const kind = row.getAttribute("data-hit-kind");
|
||||
const payload = row.getAttribute("data-hit-payload");
|
||||
if (!kind || !payload) return;
|
||||
try {
|
||||
const hit = JSON.parse(payload);
|
||||
ev.stopPropagation();
|
||||
closeDropdown();
|
||||
if (kind === "event") void cb.onPickEvent(hit);
|
||||
else if (kind === "scenario") void cb.onPickScenario(hit);
|
||||
else if (kind === "project" && cb.onPickProject) void cb.onPickProject(hit);
|
||||
} catch (err) {
|
||||
console.error("builder-search: bad payload", err);
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
focus: () => {
|
||||
input.focus();
|
||||
// Open the dropdown on focus even when input is empty — show the
|
||||
// "start typing" hint per PRD §2.2 (search box auto-focuses).
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
},
|
||||
close: closeDropdown,
|
||||
};
|
||||
}
|
||||
|
||||
function teardown(): void {
|
||||
if (!active) return;
|
||||
if (active.abort) active.abort.abort();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
active.dropdown.remove();
|
||||
active.input.removeEventListener("input", onInput);
|
||||
active.input.removeEventListener("focus", onFocus);
|
||||
active.input.removeEventListener("keydown", onKeydown);
|
||||
document.removeEventListener("click", onOutsideClick, true);
|
||||
window.removeEventListener("resize", reposition);
|
||||
window.removeEventListener("scroll", reposition, true);
|
||||
active = null;
|
||||
}
|
||||
|
||||
function onInput(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (active.debounceTimer !== null) window.clearTimeout(active.debounceTimer);
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
return;
|
||||
}
|
||||
if (q.length < 2) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.short"));
|
||||
return;
|
||||
}
|
||||
active.debounceTimer = window.setTimeout(() => {
|
||||
void runSearch(q);
|
||||
}, 180);
|
||||
}
|
||||
|
||||
function onFocus(): void {
|
||||
if (!active) return;
|
||||
const q = active.input.value.trim();
|
||||
if (q.length === 0) {
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.start"));
|
||||
} else if (q.length >= 2) {
|
||||
void runSearch(q);
|
||||
}
|
||||
}
|
||||
|
||||
function onKeydown(ev: KeyboardEvent): void {
|
||||
if (!active) return;
|
||||
if (ev.key === "Escape") {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
if (ev.key === "ArrowDown" || ev.key === "ArrowUp") {
|
||||
const rows = Array.from(active.dropdown.querySelectorAll<HTMLElement>(".builder-search-row"));
|
||||
if (rows.length === 0) return;
|
||||
ev.preventDefault();
|
||||
const current = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
let idx = current ? rows.indexOf(current) : -1;
|
||||
idx = ev.key === "ArrowDown"
|
||||
? Math.min(rows.length - 1, idx + 1)
|
||||
: Math.max(0, idx - 1);
|
||||
rows.forEach((r) => r.classList.remove("is-focus"));
|
||||
rows[idx].classList.add("is-focus");
|
||||
rows[idx].scrollIntoView({ block: "nearest" });
|
||||
return;
|
||||
}
|
||||
if (ev.key === "Enter") {
|
||||
const focused = active.dropdown.querySelector<HTMLElement>(".builder-search-row.is-focus");
|
||||
if (focused) {
|
||||
ev.preventDefault();
|
||||
focused.click();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: Event): void {
|
||||
if (!active) return;
|
||||
const target = ev.target as Node;
|
||||
if (active.input.contains(target)) return;
|
||||
if (active.dropdown.contains(target)) return;
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
async function runSearch(q: string): Promise<void> {
|
||||
if (!active) return;
|
||||
// Cancel any in-flight request so a slow earlier query can't clobber
|
||||
// a faster newer one.
|
||||
if (active.abort) active.abort.abort();
|
||||
const ctl = new AbortController();
|
||||
active.abort = ctl;
|
||||
|
||||
openDropdown();
|
||||
renderHint(t("builder.search.hint.loading"));
|
||||
|
||||
try {
|
||||
const url = "/api/builder/search?q=" + encodeURIComponent(q);
|
||||
const resp = await fetch(url, { signal: ctl.signal });
|
||||
if (!resp.ok) {
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as UniversalSearchResponse;
|
||||
if (active.abort !== ctl) return;
|
||||
renderResults(data);
|
||||
} catch (err) {
|
||||
if ((err as { name?: string })?.name === "AbortError") return;
|
||||
console.error("builder-search error:", err);
|
||||
renderHint(t("builder.search.hint.error"));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHint(message: string): void {
|
||||
if (!active) return;
|
||||
active.dropdown.innerHTML = `<div class="builder-search-hint">${escHtml(message)}</div>`;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderResults(data: UniversalSearchResponse): void {
|
||||
if (!active) return;
|
||||
const lang = active.lang;
|
||||
|
||||
const total = data.events.length + data.scenarios.length + data.projects.length;
|
||||
if (total === 0) {
|
||||
renderHint(t("builder.search.hint.empty"));
|
||||
return;
|
||||
}
|
||||
|
||||
// Result-count summary per PRD §2.2: "N Ereignisse · M Szenarios · K Akten"
|
||||
const counts = `<div class="builder-search-summary">` +
|
||||
escHtml(tCount("builder.search.summary.events", data.events.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.scenarios", data.scenarios.length)) +
|
||||
` · ` +
|
||||
escHtml(tCount("builder.search.summary.projects", data.projects.length)) +
|
||||
`</div>`;
|
||||
|
||||
const sections: string[] = [counts];
|
||||
|
||||
if (data.events.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.events"),
|
||||
data.events.map((e) => renderEventRow(e, lang)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.scenarios.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.scenarios"),
|
||||
data.scenarios.map((s) => renderScenarioRow(s)).join(""),
|
||||
));
|
||||
}
|
||||
if (data.projects.length > 0) {
|
||||
sections.push(renderGroup(
|
||||
t("builder.search.group.projects"),
|
||||
data.projects.map((p) => renderProjectRow(p, lang)).join(""),
|
||||
));
|
||||
}
|
||||
|
||||
active.dropdown.innerHTML = sections.join("");
|
||||
reposition();
|
||||
}
|
||||
|
||||
function renderGroup(label: string, rowsHtml: string): string {
|
||||
return `<section class="builder-search-group">` +
|
||||
`<header class="builder-search-group-label">${escHtml(label)}</header>` +
|
||||
rowsHtml +
|
||||
`</section>`;
|
||||
}
|
||||
|
||||
function renderEventRow(hit: EventSearchHit, lang: "de" | "en"): string {
|
||||
const name = lang === "en" ? (hit.name_en || hit.name_de) : (hit.name_de || hit.name_en);
|
||||
const ptName = lang === "en"
|
||||
? (hit.proceeding_type.name_en || hit.proceeding_type.name_de)
|
||||
: (hit.proceeding_type.name_de || hit.proceeding_type.name_en);
|
||||
const party = hit.primary_party ? `<span class="builder-search-party">${escHtml(hit.primary_party)}</span>` : "";
|
||||
const kind = hit.event_kind ? `<span class="builder-search-kind">${escHtml(hit.event_kind)}</span>` : "";
|
||||
// Payload for the click handler — we embed the full hit so builder.ts
|
||||
// doesn't need a second lookup. JSON-encoded into a data attribute,
|
||||
// attr-escaped on the way in.
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="event" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-pt-code">${escHtml(hit.proceeding_type.code)}</span>` +
|
||||
`<span class="builder-search-event-name">${escHtml(name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-pt-name">${escHtml(ptName)}</span>` +
|
||||
kind + party +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderScenarioRow(hit: ScenarioSearchHit): string {
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="scenario" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-scenario-name">${escHtml(hit.name)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">` +
|
||||
`<span class="builder-search-status">${escHtml(hit.status)}</span>` +
|
||||
`</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function renderProjectRow(hit: ProjectSearchHit, _lang: "de" | "en"): string {
|
||||
const meta: string[] = [];
|
||||
if (hit.case_number) meta.push(hit.case_number);
|
||||
if (hit.matter_number) meta.push(hit.matter_number);
|
||||
if (hit.client_number) meta.push(hit.client_number);
|
||||
if (hit.reference) meta.push(hit.reference);
|
||||
const metaText = meta.length > 0 ? meta.join(" · ") : "";
|
||||
const payload = escAttr(JSON.stringify(hit));
|
||||
return `<div class="builder-search-row" data-hit-kind="project" data-hit-payload="${payload}" tabindex="-1" role="option">` +
|
||||
`<div class="builder-search-row-main">` +
|
||||
`<span class="builder-search-project-type">${escHtml(hit.type)}</span>` +
|
||||
`<span class="builder-search-project-title">${escHtml(hit.title)}</span>` +
|
||||
`</div>` +
|
||||
`<div class="builder-search-row-meta">${escHtml(metaText)}</div>` +
|
||||
`</div>`;
|
||||
}
|
||||
|
||||
function openDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = false;
|
||||
active.open = true;
|
||||
reposition();
|
||||
}
|
||||
|
||||
function closeDropdown(): void {
|
||||
if (!active) return;
|
||||
active.dropdown.hidden = true;
|
||||
active.open = false;
|
||||
if (active.abort) {
|
||||
active.abort.abort();
|
||||
active.abort = null;
|
||||
}
|
||||
}
|
||||
|
||||
function reposition(): void {
|
||||
if (!active || !active.open) return;
|
||||
const rect = active.input.getBoundingClientRect();
|
||||
const top = rect.bottom + window.scrollY + 4;
|
||||
const left = rect.left + window.scrollX;
|
||||
const width = Math.max(rect.width, 380);
|
||||
active.dropdown.style.position = "absolute";
|
||||
active.dropdown.style.top = `${top}px`;
|
||||
active.dropdown.style.left = `${left}px`;
|
||||
active.dropdown.style.width = `${width}px`;
|
||||
active.dropdown.style.zIndex = "60";
|
||||
}
|
||||
|
||||
// tCount applies a simple plural pick: keys ".one" / ".other" carry
|
||||
// the singular/plural variants; the caller's key is the bare stem.
|
||||
function tCount(key: string, n: number): string {
|
||||
const variant = n === 1 ? `${key}.one` : `${key}.other`;
|
||||
return t(variant).replace("{n}", String(n));
|
||||
}
|
||||
|
||||
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, """);
|
||||
}
|
||||
271
frontend/src/client/builder-triplet.ts
Normal file
271
frontend/src/client/builder-triplet.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// ProceedingTriplet renderer for the Litigation Builder.
|
||||
//
|
||||
// PRD §3.3 + §3.4 + §6.1: one triplet = jurisdiction badge + name +
|
||||
// perspective + Detailgrad + columnar `proaktiv | court | reaktiv`
|
||||
// body.
|
||||
//
|
||||
// B2 wires the live controls — perspective radio, scenario-flag strip,
|
||||
// remove button, collapse — and the per-event-card overlays (3-state
|
||||
// machine, action buttons, optional-horizon chip). The 3-column body
|
||||
// itself is still produced by verfahrensablauf-core.renderColumnsBody;
|
||||
// per-card overlays are layered on top after innerHTML write via the
|
||||
// data-rule-id hooks added in the same slice.
|
||||
|
||||
import { t, tDyn, getLang } from "./i18n";
|
||||
import type { DeadlineResponse, Side } from "./views/verfahrensablauf-core";
|
||||
import type { BuilderProceeding, BuilderEvent } from "./builder";
|
||||
import type { ProceedingTypeMeta } from "./builder-picker";
|
||||
|
||||
export interface ScenarioFlagCatalogEntry {
|
||||
flag_key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
description?: string;
|
||||
hidden_unless_set: boolean;
|
||||
}
|
||||
|
||||
export interface TripletViewInput {
|
||||
proceeding: BuilderProceeding;
|
||||
meta: ProceedingTypeMeta;
|
||||
data: DeadlineResponse | null;
|
||||
side: Side;
|
||||
// Flag catalog filtered to the keys the active proceeding actually
|
||||
// references via its rules' condition_expr. B2 passes the global
|
||||
// catalog and lets the user toggle any — flags that don't gate any
|
||||
// rule are simply no-ops on this triplet.
|
||||
flagCatalog: ScenarioFlagCatalogEntry[];
|
||||
// Map keyed by sequencing_rule_id (lowercased UUID) → BuilderEvent
|
||||
// for the per-card state machine. Cards whose rule is absent default
|
||||
// to "planned".
|
||||
eventsByRule: Map<string, BuilderEvent>;
|
||||
// Per-card optional-horizon registry. Each rule with optional
|
||||
// children carries a `+N Optionen` chip; the chip's count comes from
|
||||
// here (defaults to scenario_events.horizon_optional, falls back to
|
||||
// proceeding-level when not stored per-card).
|
||||
columnsHtml: string;
|
||||
isChild: boolean;
|
||||
}
|
||||
|
||||
// Triplet header + controls + columns body. Pure-string render; the
|
||||
// caller (builder.ts) wires click handlers on top.
|
||||
export function renderTriplet(input: TripletViewInput): string {
|
||||
const lang = getLang();
|
||||
const procLabel = lang === "en"
|
||||
? (input.meta.nameEN || input.meta.name)
|
||||
: (input.meta.name || input.meta.nameEN);
|
||||
const flagsBadge = activeFlagsBadge(input.proceeding.scenario_flags);
|
||||
|
||||
const body = input.data
|
||||
? input.columnsHtml
|
||||
: `<div class="builder-triplet-loading">${escHtml(t("builder.triplet.loading"))}</div>`;
|
||||
|
||||
const controls = renderControls(input);
|
||||
const flagStrip = renderFlagStrip(input);
|
||||
|
||||
return `
|
||||
<header class="builder-triplet-header">
|
||||
<span class="builder-triplet-jurisdiction">${escHtml(jurisdictionFor(input.meta))}</span>
|
||||
<span class="builder-triplet-code">${escHtml(input.meta.code)}</span>
|
||||
<span class="builder-triplet-name">${escHtml(procLabel)}</span>
|
||||
${flagsBadge}
|
||||
</header>
|
||||
${controls}
|
||||
${flagStrip}
|
||||
<div class="builder-triplet-body">
|
||||
${body}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderControls(input: TripletViewInput): string {
|
||||
const perspective = input.side ?? "";
|
||||
const detailgrad = input.proceeding.detailgrad || "selected";
|
||||
|
||||
const radio = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-perspective-btn${active}"
|
||||
data-action="perspective" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
const detailBtn = (value: string, key: string, current: string): string => {
|
||||
const active = value === current ? " is-active" : "";
|
||||
return `<button type="button" class="builder-triplet-detailgrad-btn${active}"
|
||||
data-action="detailgrad" data-value="${escAttr(value)}">${escHtml(tDyn(key))}</button>`;
|
||||
};
|
||||
|
||||
return `<div class="builder-triplet-controls">
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.perspective.label"))}</span>
|
||||
<div class="builder-triplet-perspective">
|
||||
${radio("", "builder.triplet.perspective.none", perspective)}
|
||||
${radio("claimant", "builder.triplet.perspective.claimant", perspective)}
|
||||
${radio("defendant", "builder.triplet.perspective.defendant", perspective)}
|
||||
</div>
|
||||
<span class="builder-triplet-controls-label">${escHtml(t("builder.triplet.detailgrad.label"))}</span>
|
||||
<div class="builder-triplet-detailgrad">
|
||||
${detailBtn("selected", "builder.triplet.detailgrad.selected", detailgrad)}
|
||||
${detailBtn("all_options", "builder.triplet.detailgrad.all_options", detailgrad)}
|
||||
</div>
|
||||
<button type="button" class="builder-triplet-remove" data-action="remove">
|
||||
${escHtml(t("builder.triplet.remove"))}
|
||||
</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderFlagStrip(input: TripletViewInput): string {
|
||||
// B2 ships the full global catalog. Flags that don't gate any of the
|
||||
// active proceeding's rules are still toggle-able but have no effect
|
||||
// on the calc result (the engine simply doesn't read them).
|
||||
const lang = getLang();
|
||||
const flags = input.proceeding.scenario_flags || {};
|
||||
if (input.flagCatalog.length === 0) {
|
||||
return `<div class="builder-triplet-flagstrip">
|
||||
<span class="builder-triplet-flag-empty">${escHtml(t("builder.triplet.no_flags"))}</span>
|
||||
</div>`;
|
||||
}
|
||||
const toggles = input.flagCatalog.map((entry) => {
|
||||
const label = lang === "en" ? entry.label_en : entry.label_de;
|
||||
const isOn = flags[entry.flag_key] === true;
|
||||
return `<label class="builder-triplet-flag-toggle">
|
||||
<input type="checkbox"
|
||||
data-action="flag"
|
||||
data-flag-key="${escAttr(entry.flag_key)}"
|
||||
${isOn ? "checked" : ""} />
|
||||
<span>${escHtml(label)}</span>
|
||||
</label>`;
|
||||
}).join("");
|
||||
return `<div class="builder-triplet-flagstrip">${toggles}</div>`;
|
||||
}
|
||||
|
||||
function jurisdictionFor(meta: ProceedingTypeMeta): string {
|
||||
if (meta.jurisdiction) return meta.jurisdiction;
|
||||
if (meta.group) return meta.group;
|
||||
const dot = meta.code.indexOf(".");
|
||||
if (dot > 0) return meta.code.slice(0, dot).toUpperCase();
|
||||
return meta.code.toUpperCase();
|
||||
}
|
||||
|
||||
function activeFlagsBadge(flags: Record<string, unknown>): string {
|
||||
const active = Object.entries(flags).filter(([, v]) => v === true).map(([k]) => k);
|
||||
if (active.length === 0) return "";
|
||||
const label = t("builder.triplet.flags.label");
|
||||
const chips = active.map((f) =>
|
||||
`<span class="builder-triplet-flag-chip">${escHtml(f)}</span>`,
|
||||
).join("");
|
||||
return `<span class="builder-triplet-flags">${escHtml(label)} ${chips}</span>`;
|
||||
}
|
||||
|
||||
// overlayEventStates walks the rendered .fr-col-item nodes and:
|
||||
// - sets data-builder-state from eventsByRule lookup;
|
||||
// - appends a per-card action row (file / skip / reset);
|
||||
// - shows a +N Optionen chip when the rule has optional children
|
||||
// (the chip placeholder; B2 ships the per-card horizon control —
|
||||
// the actual horizon-count→render expansion lands when the calc
|
||||
// engine surfaces "available optionals" for a parent rule, which
|
||||
// pasteur's Options.IncludeOptional flag already exposes server-
|
||||
// side; full wiring is a follow-up). Cards without optional
|
||||
// children get no chip.
|
||||
export function overlayEventStates(
|
||||
root: HTMLElement,
|
||||
eventsByRule: Map<string, BuilderEvent>,
|
||||
on: {
|
||||
onAction: (ruleId: string, action: "file" | "skip" | "reset", payload?: { date?: string; reason?: string }) => void;
|
||||
onHorizon: (ruleId: string, delta: 1 | -1) => void;
|
||||
},
|
||||
): void {
|
||||
const items = root.querySelectorAll<HTMLElement>(".fr-col-item[data-rule-id]");
|
||||
items.forEach((item) => {
|
||||
const ruleId = item.getAttribute("data-rule-id");
|
||||
if (!ruleId) return;
|
||||
const ev = eventsByRule.get(ruleId.toLowerCase());
|
||||
const state = ev?.state || "planned";
|
||||
item.setAttribute("data-builder-state", state);
|
||||
|
||||
// Append actions (idempotent: clear any prior overlay first).
|
||||
item.querySelectorAll(".builder-event-actions, .builder-event-horizon-chip").forEach((n) => n.remove());
|
||||
|
||||
const actions = document.createElement("div");
|
||||
actions.className = "builder-event-actions";
|
||||
actions.innerHTML = actionButtonsHtml(state);
|
||||
item.appendChild(actions);
|
||||
|
||||
actions.addEventListener("click", (ev) => {
|
||||
const btn = (ev.target as HTMLElement).closest<HTMLElement>(".builder-event-action");
|
||||
if (!btn) return;
|
||||
const action = btn.getAttribute("data-action") as "file" | "skip" | "reset" | null;
|
||||
if (!action) return;
|
||||
ev.stopPropagation();
|
||||
if (action === "file") {
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const v = window.prompt(t("builder.event.actual_date.prompt"), today);
|
||||
if (v === null) return;
|
||||
on.onAction(ruleId, "file", { date: v.trim() || today });
|
||||
} else if (action === "skip") {
|
||||
const reason = window.prompt(t("builder.event.skip_reason.prompt"), "");
|
||||
if (reason === null) return;
|
||||
on.onAction(ruleId, "skip", { reason: reason.trim() });
|
||||
} else {
|
||||
on.onAction(ruleId, "reset");
|
||||
}
|
||||
});
|
||||
|
||||
// Per-card optional horizon chip. The PRD §3.4 places the chip on
|
||||
// every card with optional children; until the calc surface exposes
|
||||
// an "optionals available count" on each parent rule, the chip is
|
||||
// shown only when the card has a stored non-zero horizon (so the
|
||||
// user can see and reduce a previously-set horizon). This is the
|
||||
// graceful B2 baseline; the full surface lands once the engine
|
||||
// emits an optionalsAvailable counter (PRD §3.4 follow-up).
|
||||
const horizonCount = ev?.horizon_optional ?? 0;
|
||||
if (horizonCount > 0) {
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-toggle");
|
||||
chip.textContent = t("builder.event.horizon.label").replace("{n}", String(horizonCount));
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, -1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
} else {
|
||||
// Inline "+ Optionen" affordance — adds a horizon entry when
|
||||
// first clicked. Tagged as data-builder-feature so the cleanup
|
||||
// sweep can rip it out if the calc surface lands a counter.
|
||||
const chip = document.createElement("button");
|
||||
chip.type = "button";
|
||||
chip.className = "builder-event-horizon-chip";
|
||||
chip.setAttribute("data-action", "horizon-add");
|
||||
chip.setAttribute("data-builder-feature", "horizon-add");
|
||||
chip.textContent = "+ " + t("builder.event.horizon.label").replace("+{n} ", "");
|
||||
chip.addEventListener("click", (e) => {
|
||||
e.stopPropagation();
|
||||
on.onHorizon(ruleId, 1);
|
||||
});
|
||||
item.appendChild(chip);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function actionButtonsHtml(state: BuilderEvent["state"]): string {
|
||||
// Re-render the action row per state. Cards in the planned state
|
||||
// show "File / Skip"; filed/skipped cards show "Reset to planned".
|
||||
if (state === "planned") {
|
||||
return `
|
||||
<button type="button" class="builder-event-action" data-action="file">${escHtml(t("builder.event.action.file"))}</button>
|
||||
<button type="button" class="builder-event-action" data-action="skip">${escHtml(t("builder.event.action.skip"))}</button>
|
||||
`;
|
||||
}
|
||||
return `<button type="button" class="builder-event-action" data-action="reset">${escHtml(t("builder.event.action.reset"))}</button>`;
|
||||
}
|
||||
|
||||
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, """);
|
||||
}
|
||||
1571
frontend/src/client/builder.ts
Normal file
1571
frontend/src/client/builder.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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);
|
||||
}
|
||||
@@ -214,6 +214,142 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.panel.akte.placeholder": "Akten-Einstieg folgt in einem sp\u00e4teren Slice.",
|
||||
"nav.procedures": "Verfahren & Fristen",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder \u2014 Szenarien bauen, Verfahren stapeln, Fristen behalten.",
|
||||
"builder.header.scenario": "Szenario:",
|
||||
"builder.header.akte": "Akte:",
|
||||
"builder.header.stichtag": "Stichtag:",
|
||||
"builder.header.search": "Suche:",
|
||||
"builder.akte.none": "\u2014 ohne \u2014",
|
||||
"builder.akte.banner.prefix": "Aus Akte:",
|
||||
"builder.search.placeholder": "Ereignis, Szenario, Akte \u2026",
|
||||
"builder.action.rename": "Benennen",
|
||||
"builder.action.rename.prompt": "Name f\u00fcr dieses Szenario:",
|
||||
"builder.action.share": "Teilen",
|
||||
"builder.action.promote": "Als Projekt anlegen",
|
||||
"builder.mode.cold": "\u00dcbersicht",
|
||||
"builder.mode.event": "Ereignis",
|
||||
"builder.mode.akte": "Aus Akte",
|
||||
"builder.panel.title": "Meine Szenarien",
|
||||
"builder.panel.new": "+ Neues Szenario",
|
||||
"builder.panel.empty": "Noch keine Szenarien.",
|
||||
"builder.bucket.active": "Aktiv",
|
||||
"builder.empty.headline": "Noch kein Szenario ge\u00f6ffnet.",
|
||||
"builder.empty.hint": "Starte ein neues Szenario, w\u00e4hle aus deiner Liste oder \u00fcbernimm eine Akte (B4).",
|
||||
"builder.empty.cta": "Neues Szenario starten",
|
||||
"builder.empty.recent": "Zuletzt bearbeitet",
|
||||
"builder.picker.placeholder": "\u2014 Szenario w\u00e4hlen \u2014",
|
||||
"builder.picker.title": "Verfahren hinzuf\u00fcgen",
|
||||
"builder.picker.close": "Schlie\u00dfen",
|
||||
"builder.picker.aria": "Verfahren ausw\u00e4hlen",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Verfahren:",
|
||||
"builder.picker.empty": "Keine Verfahren verf\u00fcgbar.",
|
||||
"builder.picker.future_jurisdiction": "Andere Foren folgen sp\u00e4ter.",
|
||||
"builder.canvas.add_proceeding": "+ Verfahren hinzuf\u00fcgen",
|
||||
"builder.triplet.loading": "Berechne Fristen \u2026",
|
||||
"builder.triplet.unknown_proceeding": "Unbekannter Verfahrenstyp.",
|
||||
"builder.triplet.side.claimant": "Kl\u00e4ger-Sicht",
|
||||
"builder.triplet.side.defendant": "Beklagten-Sicht",
|
||||
"builder.triplet.flags.label": "Optionen:",
|
||||
"builder.triplet.perspective.label": "Perspektive:",
|
||||
"builder.triplet.perspective.none": "keine",
|
||||
"builder.triplet.perspective.claimant": "Kl\u00e4ger",
|
||||
"builder.triplet.perspective.defendant": "Beklagter",
|
||||
"builder.triplet.detailgrad.label": "Detailgrad:",
|
||||
"builder.triplet.detailgrad.selected": "Gew\u00e4hlt",
|
||||
"builder.triplet.detailgrad.all_options": "Alle Optionen",
|
||||
"builder.triplet.remove": "Entfernen",
|
||||
"builder.triplet.collapse": "Einklappen",
|
||||
"builder.triplet.expand": "Ausklappen",
|
||||
"builder.triplet.no_flags": "(keine Flags f\u00fcr diesen Verfahrenstyp)",
|
||||
"builder.event.state.planned": "geplant",
|
||||
"builder.event.state.filed": "eingereicht",
|
||||
"builder.event.state.skipped": "ausgelassen",
|
||||
"builder.event.action.file": "Einreichen",
|
||||
"builder.event.action.skip": "Auslassen",
|
||||
"builder.event.action.reset": "Zur\u00fcck zu geplant",
|
||||
"builder.event.actual_date.prompt": "Datum der Einreichung:",
|
||||
"builder.event.skip_reason.prompt": "Grund (optional):",
|
||||
"builder.event.horizon.label": "+{n} Optionen \u25be",
|
||||
"builder.event.horizon.hide": "Optionen ausblenden",
|
||||
"builder.save.idle": "\u00a0",
|
||||
"builder.save.saving": "Speichert \u2026",
|
||||
"builder.save.saved": "Gespeichert \u2713",
|
||||
"builder.save.error": "Speichern fehlgeschlagen",
|
||||
"builder.search.hint.start": "Tippe \u2026 z.\u202fB. \u201eKlageerwiderung\u201c, \u201eHinweis\u201c, \u201eHL-2024\u201c",
|
||||
"builder.search.hint.short": "Mindestens 2 Zeichen.",
|
||||
"builder.search.hint.loading": "Suche \u2026",
|
||||
"builder.search.hint.empty": "Keine Treffer.",
|
||||
"builder.search.hint.error": "Suche fehlgeschlagen. Erneut versuchen.",
|
||||
"builder.search.hint.akte_b4": "Akten-Modus folgt in B4.",
|
||||
"builder.search.group.events": "Ereignisse",
|
||||
"builder.search.group.scenarios": "Szenarien",
|
||||
"builder.search.group.projects": "Akten",
|
||||
"builder.search.summary.events.one": "{n} Ereignis",
|
||||
"builder.search.summary.events.other": "{n} Ereignisse",
|
||||
"builder.search.summary.scenarios.one": "{n} Szenario",
|
||||
"builder.search.summary.scenarios.other": "{n} Szenarien",
|
||||
"builder.search.summary.projects.one": "{n} Akte",
|
||||
"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",
|
||||
@@ -263,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",
|
||||
@@ -992,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",
|
||||
@@ -1417,7 +1550,25 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profil",
|
||||
"einstellungen.tab.benachrichtigungen": "Benachrichtigungen",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.names": "Namensschemata",
|
||||
"einstellungen.tab.export": "Datenexport",
|
||||
"einstellungen.names.subtitle": "Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt. Klicken Sie auf einen Platzhalter, um ihn einzuf\u00fcgen; die Vorschau zeigt das Ergebnis sofort.",
|
||||
"einstellungen.names.preview.sample": "Beispiel:",
|
||||
"einstellungen.names.preview.empty": "Ohne Projektdaten:",
|
||||
"einstellungen.names.reset": "Auf Standard zur\u00fccksetzen",
|
||||
"einstellungen.names.saved": "Gespeichert.",
|
||||
"einstellungen.names.reset_done": "Auf Standard zur\u00fcckgesetzt.",
|
||||
"einstellungen.names.override_badge": "Angepasst",
|
||||
"einstellungen.names.firm_badge": "Firmenstandard",
|
||||
"einstellungen.names.firm.heading": "Firmenstandard (f\u00fcr alle)",
|
||||
"einstellungen.names.firm.status_set": "Aktiver Firmenstandard:",
|
||||
"einstellungen.names.firm.status_unset": "Kein Firmenstandard gesetzt \u2014 es gilt der Systemstandard.",
|
||||
"einstellungen.names.firm.set": "Als Firmenstandard festlegen",
|
||||
"einstellungen.names.firm.clear": "Firmenstandard l\u00f6schen",
|
||||
"einstellungen.names.firm.saved": "Firmenstandard gespeichert.",
|
||||
"einstellungen.names.firm.cleared": "Firmenstandard gel\u00f6scht \u2014 Systemstandard gilt wieder.",
|
||||
"einstellungen.names.error.load": "Namensschemata konnten nicht geladen werden.",
|
||||
"einstellungen.names.error.invalid": "Ung\u00fcltige Vorlage \u2014 bitte pr\u00fcfen Sie die Platzhalter.",
|
||||
"einstellungen.export.subtitle": "Laden Sie Ihre pers\u00f6nlichen Paliad-Daten als Excel- + JSON- + CSV-Paket herunter. Enthalten ist alles, was Sie aktuell sehen k\u00f6nnen \u2014 Ihre Projekte, Fristen, Termine, Notizen, Genehmigungen und Einstellungen.",
|
||||
"einstellungen.export.heading": "Pers\u00f6nlicher Datenexport",
|
||||
"einstellungen.export.what": "Das Paket enth\u00e4lt Ihre sichtbaren Daten in drei Formaten in einem .zip:",
|
||||
@@ -1590,6 +1741,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.action.generate": "Generieren",
|
||||
"projects.detail.submissions.action.no_template": "Keine Vorlage",
|
||||
"projects.detail.submissions.action.edit": "Bearbeiten",
|
||||
"projects.detail.submissions.action.open": "Entwurf öffnen",
|
||||
"projects.detail.submissions.hint": "Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Schriftsatz bearbeiten — Paliad",
|
||||
@@ -1612,11 +1764,30 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"submissions.draft.language.de": "DE",
|
||||
"submissions.draft.language.en": "EN",
|
||||
"submissions.draft.language.fallback_notice": "Fallback: universelles Skelett (keine sprachspezifische Vorlage).",
|
||||
// t-paliad-354 — Dateiname-Stichwort (führt den Namen des exportierten Dokuments an).
|
||||
"submissions.draft.keyword.label": "Stichwort (Dateiname)",
|
||||
"submissions.draft.keyword.placeholder": "Automatisch aus dem Schriftsatztyp",
|
||||
"submissions.draft.keyword.hint": "Führt den Dateinamen an: <Datum> <Stichwort> (<Aktenzeichen>).",
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Vorlagenbasis",
|
||||
"submissions.draft.base.hint": "Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.",
|
||||
"submissions.draft.base.preview": "Vorschau Vorlagenbasis",
|
||||
"submissions.draft.base.preview.soon": "Bald verfügbar",
|
||||
"submissions.draft.sections.title": "Abschnitte",
|
||||
"submissions.draft.sections.hint": "Inhalt pro Abschnitt — Autosave nach 500 ms. Letztes Layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Vorlagen — Paliad",
|
||||
"templates.authoring.heading": "Vorlagen",
|
||||
"templates.authoring.intro": "Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.",
|
||||
"templates.authoring.upload.title": "Neue Vorlage hochladen",
|
||||
"templates.authoring.upload.file": "Word-Datei (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Kanzlei (optional)",
|
||||
"templates.authoring.upload.submit": "Hochladen",
|
||||
"templates.authoring.list.title": "Vorhandene Vorlagen",
|
||||
"templates.authoring.workspace.hint": "Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.",
|
||||
"templates.authoring.slots.title": "Platzhalter",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Bausteine — Paliad",
|
||||
"admin.building_blocks.heading": "Bausteine",
|
||||
@@ -3418,6 +3589,142 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"procedures.panel.akte.placeholder": "Matter entry ships in a later slice.",
|
||||
"nav.procedures": "Procedures & Deadlines",
|
||||
|
||||
// Litigation Builder (m/paliad#153 B1+B2)
|
||||
"builder.subtitle": "Litigation Builder — build scenarios, stack proceedings, track deadlines.",
|
||||
"builder.header.scenario": "Scenario:",
|
||||
"builder.header.akte": "Matter:",
|
||||
"builder.header.stichtag": "Anchor:",
|
||||
"builder.header.search": "Search:",
|
||||
"builder.akte.none": "— none —",
|
||||
"builder.akte.banner.prefix": "From matter:",
|
||||
"builder.search.placeholder": "Event, scenario, matter …",
|
||||
"builder.action.rename": "Name it",
|
||||
"builder.action.rename.prompt": "Name for this scenario:",
|
||||
"builder.action.share": "Share",
|
||||
"builder.action.promote": "Create as project",
|
||||
"builder.mode.cold": "Overview",
|
||||
"builder.mode.event": "Event",
|
||||
"builder.mode.akte": "From matter",
|
||||
"builder.panel.title": "My scenarios",
|
||||
"builder.panel.new": "+ New scenario",
|
||||
"builder.panel.empty": "No scenarios yet.",
|
||||
"builder.bucket.active": "Active",
|
||||
"builder.empty.headline": "No scenario open.",
|
||||
"builder.empty.hint": "Start a new scenario, pick one from your list, or load a matter (B4).",
|
||||
"builder.empty.cta": "Start a new scenario",
|
||||
"builder.empty.recent": "Recent",
|
||||
"builder.picker.placeholder": "— pick a scenario —",
|
||||
"builder.picker.title": "Add proceeding",
|
||||
"builder.picker.close": "Close",
|
||||
"builder.picker.aria": "Pick a proceeding",
|
||||
"builder.picker.axis.forum": "Forum:",
|
||||
"builder.picker.axis.proc": "Proceeding:",
|
||||
"builder.picker.empty": "No proceedings available.",
|
||||
"builder.picker.future_jurisdiction": "Other forums coming later.",
|
||||
"builder.canvas.add_proceeding": "+ Add proceeding",
|
||||
"builder.triplet.loading": "Calculating deadlines …",
|
||||
"builder.triplet.unknown_proceeding": "Unknown proceeding type.",
|
||||
"builder.triplet.side.claimant": "Claimant view",
|
||||
"builder.triplet.side.defendant": "Defendant view",
|
||||
"builder.triplet.flags.label": "Options:",
|
||||
"builder.triplet.perspective.label": "Perspective:",
|
||||
"builder.triplet.perspective.none": "none",
|
||||
"builder.triplet.perspective.claimant": "Claimant",
|
||||
"builder.triplet.perspective.defendant": "Defendant",
|
||||
"builder.triplet.detailgrad.label": "Detail:",
|
||||
"builder.triplet.detailgrad.selected": "Selected",
|
||||
"builder.triplet.detailgrad.all_options": "All options",
|
||||
"builder.triplet.remove": "Remove",
|
||||
"builder.triplet.collapse": "Collapse",
|
||||
"builder.triplet.expand": "Expand",
|
||||
"builder.triplet.no_flags": "(no flags for this proceeding type)",
|
||||
"builder.event.state.planned": "planned",
|
||||
"builder.event.state.filed": "filed",
|
||||
"builder.event.state.skipped": "skipped",
|
||||
"builder.event.action.file": "File",
|
||||
"builder.event.action.skip": "Skip",
|
||||
"builder.event.action.reset": "Reset to planned",
|
||||
"builder.event.actual_date.prompt": "Date of filing:",
|
||||
"builder.event.skip_reason.prompt": "Reason (optional):",
|
||||
"builder.event.horizon.label": "+{n} optional ▾",
|
||||
"builder.event.horizon.hide": "Hide optional",
|
||||
"builder.save.idle": " ",
|
||||
"builder.save.saving": "Saving …",
|
||||
"builder.save.saved": "Saved ✓",
|
||||
"builder.save.error": "Save failed",
|
||||
"builder.search.hint.start": "Type … e.g. \"defence\", \"hearing\", \"HL-2024\"",
|
||||
"builder.search.hint.short": "At least 2 characters.",
|
||||
"builder.search.hint.loading": "Searching …",
|
||||
"builder.search.hint.empty": "No matches.",
|
||||
"builder.search.hint.error": "Search failed. Try again.",
|
||||
"builder.search.hint.akte_b4": "Matter mode coming in B4.",
|
||||
"builder.search.group.events": "Events",
|
||||
"builder.search.group.scenarios": "Scenarios",
|
||||
"builder.search.group.projects": "Matters",
|
||||
"builder.search.summary.events.one": "{n} event",
|
||||
"builder.search.summary.events.other": "{n} events",
|
||||
"builder.search.summary.scenarios.one": "{n} scenario",
|
||||
"builder.search.summary.scenarios.other": "{n} scenarios",
|
||||
"builder.search.summary.projects.one": "{n} matter",
|
||||
"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",
|
||||
@@ -3467,10 +3774,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",
|
||||
@@ -4602,7 +4905,25 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"einstellungen.tab.profil": "Profile",
|
||||
"einstellungen.tab.benachrichtigungen": "Notifications",
|
||||
"einstellungen.tab.caldav": "CalDAV",
|
||||
"einstellungen.tab.names": "Naming",
|
||||
"einstellungen.tab.export": "Data export",
|
||||
"einstellungen.names.subtitle": "Define how Paliad composes draft titles and file names from project data. Click a placeholder to insert it; the preview updates instantly.",
|
||||
"einstellungen.names.preview.sample": "Sample:",
|
||||
"einstellungen.names.preview.empty": "Without project data:",
|
||||
"einstellungen.names.reset": "Reset to default",
|
||||
"einstellungen.names.saved": "Saved.",
|
||||
"einstellungen.names.reset_done": "Reset to default.",
|
||||
"einstellungen.names.override_badge": "Customised",
|
||||
"einstellungen.names.firm_badge": "Firm default",
|
||||
"einstellungen.names.firm.heading": "Firm default (for everyone)",
|
||||
"einstellungen.names.firm.status_set": "Active firm default:",
|
||||
"einstellungen.names.firm.status_unset": "No firm default set \u2014 the system default applies.",
|
||||
"einstellungen.names.firm.set": "Set as firm default",
|
||||
"einstellungen.names.firm.clear": "Clear firm default",
|
||||
"einstellungen.names.firm.saved": "Firm default saved.",
|
||||
"einstellungen.names.firm.cleared": "Firm default cleared \u2014 system default applies again.",
|
||||
"einstellungen.names.error.load": "Could not load naming schemes.",
|
||||
"einstellungen.names.error.invalid": "Invalid template \u2014 please check the placeholders.",
|
||||
"einstellungen.export.subtitle": "Download your personal Paliad data as an Excel + JSON + CSV bundle. The package contains everything you can currently see \u2014 your projects, deadlines, appointments, notes, approvals and settings.",
|
||||
"einstellungen.export.heading": "Personal data export",
|
||||
"einstellungen.export.what": "The package contains your visible data in three formats in one .zip:",
|
||||
@@ -4775,6 +5096,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.action.generate": "Generate",
|
||||
"projects.detail.submissions.action.no_template": "No template",
|
||||
"projects.detail.submissions.action.edit": "Edit",
|
||||
"projects.detail.submissions.action.open": "Open draft",
|
||||
"projects.detail.submissions.hint": "Submissions are generated as .docx directly from the project. Edit, print, file.",
|
||||
// t-paliad-238 — dedicated draft editor page.
|
||||
"submissions.draft.title": "Edit submission — Paliad",
|
||||
@@ -4792,6 +5114,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",
|
||||
@@ -4800,8 +5126,23 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice A — base picker + section list.
|
||||
"submissions.draft.base.label": "Template base",
|
||||
"submissions.draft.base.hint": "Drives fonts, letterhead, and section defaults.",
|
||||
"submissions.draft.base.preview": "Preview template base",
|
||||
"submissions.draft.base.preview.soon": "Coming soon",
|
||||
"submissions.draft.sections.title": "Sections",
|
||||
"submissions.draft.sections.hint": "Edit per section — autosaves after 500ms. Final layout in Word.",
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 6 — template authoring page.
|
||||
"templates.authoring.title": "Templates — Paliad",
|
||||
"templates.authoring.heading": "Templates",
|
||||
"templates.authoring.intro": "Upload a Word template, highlight spots and insert variables.",
|
||||
"templates.authoring.upload.title": "Upload a new template",
|
||||
"templates.authoring.upload.file": "Word file (.docx)",
|
||||
"templates.authoring.upload.name_de": "Name (DE)",
|
||||
"templates.authoring.upload.name_en": "Name (EN)",
|
||||
"templates.authoring.upload.firm": "Firm (optional)",
|
||||
"templates.authoring.upload.submit": "Upload",
|
||||
"templates.authoring.list.title": "Existing templates",
|
||||
"templates.authoring.workspace.hint": "Highlight text, then pick a variable to place a placeholder.",
|
||||
"templates.authoring.slots.title": "Placeholders",
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — building blocks admin.
|
||||
"admin.building_blocks.title": "Building blocks — Paliad",
|
||||
"admin.building_blocks.heading": "Building blocks",
|
||||
|
||||
@@ -89,7 +89,17 @@ function renderBlocks(escapedHtml: string): string {
|
||||
return out.join("");
|
||||
}
|
||||
|
||||
// The [paliadin-meta] … [/paliadin-meta] trailer is internal telemetry
|
||||
// (used_tools / rows_seen / classifier_tag) that the backend is meant to
|
||||
// strip. Defensively strip it here too so it can NEVER reach the user even
|
||||
// when the backend's strip misses it (e.g. an empty meta block on a greeting,
|
||||
// or a chunk the aichat backend didn't catch). It always sits at the end,
|
||||
// optionally preceded by a `---` separator, so we cut from the opener to EOS —
|
||||
// which also handles a truncated/unterminated trailer.
|
||||
const META_TRAILER_RE = /\n*\s*(?:-{3,}\s*\n+)?\[paliadin-meta\][\s\S]*$/i;
|
||||
|
||||
export function renderResponseHTML(raw: string): string {
|
||||
raw = raw.replace(META_TRAILER_RE, "").replace(/\s+$/, "");
|
||||
let html = raw
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
|
||||
@@ -1,150 +1,15 @@
|
||||
// /tools/procedures client (m/paliad#151,
|
||||
// docs/design-unified-procedural-events-tool-2026-05-27.md).
|
||||
// /tools/procedures bundle entry — Litigation Builder (m/paliad#153 B1).
|
||||
//
|
||||
// Boot logic + tab switching for the unified procedural-events tool.
|
||||
// Each entry tab mounts its own module; the search box and chip
|
||||
// filters in the top filter strip are wired in U1+ as each slice adds
|
||||
// its dimension-aware behaviour.
|
||||
//
|
||||
// U0 — Skeleton + tab toggling.
|
||||
// U1 — Direkt suchen mounts Mode A.
|
||||
// U2 — Geführt mounts Mode B wizard.
|
||||
// U3 — Verfahren wählen wires the Verfahrensablauf wizard + detail-mode toggle.
|
||||
//
|
||||
// Mode A renders its shell into #fristen-overhaul-root (replacing
|
||||
// children); Mode B renders into #fristen-overhaul-mode-host; the
|
||||
// result view (post-commit) writes into #fristen-overhaul-root. To
|
||||
// keep those IDs unique in the DOM, only the active tab's panel ever
|
||||
// hosts the overhaul scaffold — installOverhaulHost() tears down any
|
||||
// existing host and installs a fresh one inside the target panel
|
||||
// before handing off to the per-mode module.
|
||||
// Replaces cronus's U0-U4 catalog bootstrap. The page chrome is
|
||||
// emitted by procedures.tsx; this file boots the i18n + sidebar
|
||||
// runtime and hands off to builder.ts.
|
||||
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { mountModeA } from "./fristenrechner-mode-a";
|
||||
import { mountResultView } from "./fristenrechner-result";
|
||||
import { mountWizard } from "./fristenrechner-wizard";
|
||||
import { initVerfahrensablauf } from "./verfahrensablauf";
|
||||
|
||||
type ProceduresTab = "proceeding" | "search" | "wizard" | "akte";
|
||||
|
||||
const TABS: ProceduresTab[] = ["proceeding", "search", "wizard", "akte"];
|
||||
|
||||
function readTabFromUrl(): ProceduresTab {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const raw = params.get("mode");
|
||||
if (raw && (TABS as string[]).includes(raw)) return raw as ProceduresTab;
|
||||
return "proceeding";
|
||||
}
|
||||
|
||||
function writeTabToUrl(tab: ProceduresTab): void {
|
||||
const url = new URL(window.location.href);
|
||||
if (tab === "proceeding") {
|
||||
url.searchParams.delete("mode");
|
||||
} else {
|
||||
url.searchParams.set("mode", tab);
|
||||
}
|
||||
history.replaceState(null, "", url.pathname + url.search + url.hash);
|
||||
}
|
||||
|
||||
// installOverhaulHost moves the (legacy) #fristen-overhaul-root /
|
||||
// #fristen-overhaul-mode-host scaffold under `panelId`. Always clears
|
||||
// any existing host first, so the IDs stay unique across the page even
|
||||
// when the user toggles between Direkt-suchen and Geführt — both Mode
|
||||
// A and the wizard read these IDs from document.getElementById which
|
||||
// returns the first match in DOM order, so two parallel hosts would
|
||||
// cross-wire.
|
||||
function installOverhaulHost(panelId: string): HTMLElement | null {
|
||||
document.querySelectorAll("#fristen-overhaul-root").forEach((el) => el.remove());
|
||||
const panel = document.getElementById(panelId);
|
||||
if (!panel) return null;
|
||||
panel.innerHTML = `
|
||||
<div class="procedures-overhaul-host">
|
||||
<div class="fristen-overhaul-root" id="fristen-overhaul-root">
|
||||
<div id="fristen-overhaul-mode-host"></div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
return panel;
|
||||
}
|
||||
|
||||
function setActiveTabUI(tab: ProceduresTab): void {
|
||||
for (const t of TABS) {
|
||||
const btn = document.getElementById(`procedures-tab-${t}`);
|
||||
const panel = document.getElementById(`procedures-panel-${t}`);
|
||||
const active = t === tab;
|
||||
if (btn) {
|
||||
btn.classList.toggle("is-active", active);
|
||||
btn.setAttribute("aria-selected", active ? "true" : "false");
|
||||
}
|
||||
if (panel) panel.hidden = !active;
|
||||
}
|
||||
}
|
||||
|
||||
// Verfahrensablauf wiring is idempotent-unfriendly (module-local
|
||||
// selectedType + lastResponse + listeners that re-bind on every
|
||||
// proceeding click). Wire it exactly once per page load; on subsequent
|
||||
// activations the existing DOM + listeners are reused so picked
|
||||
// proceeding / dates / flags persist across tab switches.
|
||||
let verfahrensablaufWired = false;
|
||||
|
||||
async function activateTab(tab: ProceduresTab): Promise<void> {
|
||||
setActiveTabUI(tab);
|
||||
if (tab === "search") {
|
||||
installOverhaulHost("procedures-panel-search");
|
||||
await mountModeA();
|
||||
return;
|
||||
}
|
||||
if (tab === "wizard") {
|
||||
installOverhaulHost("procedures-panel-wizard");
|
||||
await mountWizard();
|
||||
return;
|
||||
}
|
||||
if (tab === "proceeding") {
|
||||
if (!verfahrensablaufWired) {
|
||||
initVerfahrensablauf();
|
||||
verfahrensablaufWired = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function wireTabs(): void {
|
||||
for (const t of TABS) {
|
||||
const btn = document.getElementById(`procedures-tab-${t}`);
|
||||
if (!btn) continue;
|
||||
btn.addEventListener("click", () => {
|
||||
void activateTab(t);
|
||||
writeTabToUrl(t);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// boot dispatches on the URL: a deep link with `?event=` jumps straight
|
||||
// to the linear result view (the Direkt-suchen tab stays as the visible
|
||||
// context). Otherwise the requested tab — defaulting to "proceeding" —
|
||||
// activates per readTabFromUrl().
|
||||
async function boot(): Promise<void> {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const eventRef = params.get("event") || "";
|
||||
|
||||
if (eventRef) {
|
||||
setActiveTabUI("search");
|
||||
installOverhaulHost("procedures-panel-search");
|
||||
await mountResultView({
|
||||
eventRef,
|
||||
triggerDate: params.get("trigger_date") || undefined,
|
||||
party: params.get("party") || undefined,
|
||||
courtId: params.get("court_id") || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await activateTab(readTabFromUrl());
|
||||
}
|
||||
import { mountBuilder } from "./builder";
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireTabs();
|
||||
void boot();
|
||||
void mountBuilder();
|
||||
});
|
||||
|
||||
143
frontend/src/client/row-action-menu.ts
Normal file
143
frontend/src/client/row-action-menu.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
// Shared kebab (⋯) action menu for submission catalog rows.
|
||||
//
|
||||
// t-paliad-370 (docforge UX slice S1). Both catalog surfaces — the project
|
||||
// "Schriftsätze" tab (client/submissions.ts) and the global picker
|
||||
// (client/submissions-new.ts) — read consistently: one primary CTA plus a
|
||||
// ⋯ menu for the secondary/alternate actions. This kills the two-equal-
|
||||
// buttons confusion (PRD §3 S1 / G1-b) while keeping each surface's own
|
||||
// context.
|
||||
//
|
||||
// The popover is body-attached and position:fixed (positioned in JS at open
|
||||
// time). The catalog lives inside `.entity-table-wrap`, which sets
|
||||
// overflow-x:auto — an in-flow absolutely-positioned popover would be
|
||||
// clipped. This mirrors the existing body-attached event-card choices
|
||||
// popover.
|
||||
|
||||
export interface RowActionItem {
|
||||
/** Visible menu-item label (already localized by the caller). */
|
||||
label: string;
|
||||
/** Invoked on select; not called for disabled items. */
|
||||
onSelect: () => void;
|
||||
/** Render greyed-out and non-interactive (e.g. a not-yet-wired stub). */
|
||||
disabled?: boolean;
|
||||
/** Optional tooltip — handy on a disabled stub ("Bald verfügbar"). */
|
||||
title?: string;
|
||||
}
|
||||
|
||||
let openPopover: HTMLElement | null = null;
|
||||
let openTrigger: HTMLButtonElement | null = null;
|
||||
let globalWired = false;
|
||||
|
||||
function closeOpen(): void {
|
||||
if (openPopover) {
|
||||
openPopover.remove();
|
||||
openPopover = null;
|
||||
}
|
||||
if (openTrigger) {
|
||||
openTrigger.setAttribute("aria-expanded", "false");
|
||||
openTrigger = null;
|
||||
}
|
||||
}
|
||||
|
||||
// Document/window listeners wired once: outside-click and Escape close the
|
||||
// menu; scroll/resize close it (the popover is positioned at open time and
|
||||
// would otherwise drift away from its trigger).
|
||||
function wireGlobalOnce(): void {
|
||||
if (globalWired) return;
|
||||
globalWired = true;
|
||||
document.addEventListener("click", (e) => {
|
||||
if (!openPopover) return;
|
||||
const target = e.target as Node;
|
||||
if (openPopover.contains(target)) return;
|
||||
if (openTrigger && openTrigger.contains(target)) return;
|
||||
closeOpen();
|
||||
});
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && openPopover) {
|
||||
const trigger = openTrigger;
|
||||
closeOpen();
|
||||
trigger?.focus();
|
||||
}
|
||||
});
|
||||
window.addEventListener("scroll", () => closeOpen(), true);
|
||||
window.addEventListener("resize", () => closeOpen());
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a kebab trigger + its on-demand popover menu. Returns a single inline
|
||||
* wrapper element the caller mounts into a row's action cell.
|
||||
*/
|
||||
export function createRowActionMenu(
|
||||
items: RowActionItem[],
|
||||
opts: { ariaLabel: string },
|
||||
): HTMLElement {
|
||||
wireGlobalOnce();
|
||||
|
||||
const wrap = document.createElement("span");
|
||||
wrap.className = "row-action-menu";
|
||||
|
||||
const trigger = document.createElement("button");
|
||||
trigger.type = "button";
|
||||
trigger.className = "btn-icon row-action-menu__trigger";
|
||||
trigger.setAttribute("aria-haspopup", "menu");
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
trigger.setAttribute("aria-label", opts.ariaLabel);
|
||||
trigger.textContent = "⋯";
|
||||
wrap.appendChild(trigger);
|
||||
|
||||
trigger.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (openTrigger === trigger) {
|
||||
closeOpen();
|
||||
return;
|
||||
}
|
||||
closeOpen();
|
||||
openMenu(trigger, items);
|
||||
});
|
||||
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function openMenu(trigger: HTMLButtonElement, items: RowActionItem[]): void {
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "row-action-menu__popover";
|
||||
pop.setAttribute("role", "menu");
|
||||
|
||||
for (const item of items) {
|
||||
const btn = document.createElement("button");
|
||||
btn.type = "button";
|
||||
btn.className = "row-action-menu__item";
|
||||
btn.setAttribute("role", "menuitem");
|
||||
btn.textContent = item.label;
|
||||
if (item.title) btn.title = item.title;
|
||||
if (item.disabled) {
|
||||
btn.disabled = true;
|
||||
btn.classList.add("row-action-menu__item--disabled");
|
||||
} else {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
closeOpen();
|
||||
item.onSelect();
|
||||
});
|
||||
}
|
||||
pop.appendChild(btn);
|
||||
}
|
||||
|
||||
document.body.appendChild(pop);
|
||||
|
||||
// Position fixed, right edge aligned under the trigger, clamped to viewport.
|
||||
const r = trigger.getBoundingClientRect();
|
||||
pop.style.position = "fixed";
|
||||
pop.style.top = `${Math.round(r.bottom + 4)}px`;
|
||||
let left = Math.round(r.right - pop.offsetWidth);
|
||||
if (left < 8) left = 8;
|
||||
pop.style.left = `${left}px`;
|
||||
|
||||
trigger.setAttribute("aria-expanded", "true");
|
||||
openPopover = pop;
|
||||
openTrigger = trigger;
|
||||
|
||||
pop.querySelector<HTMLButtonElement>(".row-action-menu__item:not(:disabled)")?.focus();
|
||||
}
|
||||
@@ -51,8 +51,8 @@ interface SyncLogEntry {
|
||||
duration_ms?: number;
|
||||
}
|
||||
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav" | "export";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "export"];
|
||||
type TabName = "profil" | "benachrichtigungen" | "caldav" | "names" | "export";
|
||||
const TABS: TabName[] = ["profil", "benachrichtigungen", "caldav", "names", "export"];
|
||||
const DEFAULT_TAB: TabName = "profil";
|
||||
|
||||
let me: Me | null = null;
|
||||
@@ -115,6 +115,7 @@ function showTab(tab: TabName, pushHistory: boolean) {
|
||||
if (tab === "profil") void loadProfilTab();
|
||||
else if (tab === "benachrichtigungen") void loadPrefsTab();
|
||||
else if (tab === "caldav") void loadCalDAVTab();
|
||||
else if (tab === "names") void loadNamesTab();
|
||||
else if (tab === "export") void loadExportTab();
|
||||
}
|
||||
}
|
||||
@@ -1119,6 +1120,415 @@ function runExport(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Namensschemata tab (t-paliad-356 Slice 4) ------------------------------
|
||||
//
|
||||
// Per-artifact token-template editor. All parsing, validation and preview
|
||||
// rendering happen server-side (the nomen engine is the single source of
|
||||
// truth); this client only inserts {tokens} at the cursor, debounces a preview
|
||||
// request, and persists via PUT/DELETE.
|
||||
|
||||
interface NameVar {
|
||||
var: string;
|
||||
label: string;
|
||||
label_en: string;
|
||||
}
|
||||
|
||||
interface NameArtifactCard {
|
||||
artifact_id: string;
|
||||
label: string;
|
||||
label_en: string;
|
||||
template: string;
|
||||
system_template: string;
|
||||
is_override: boolean;
|
||||
firm_is_set: boolean;
|
||||
firm_template: string;
|
||||
palette: NameVar[];
|
||||
preview_full: string;
|
||||
preview_empty: string;
|
||||
}
|
||||
|
||||
let nameCards: NameArtifactCard[] = [];
|
||||
let nameIsAdmin = false;
|
||||
const namePreviewTimers = new Map<string, number>();
|
||||
|
||||
function nameVarLabel(v: NameVar): string {
|
||||
return getLang() === "en" ? v.label_en : v.label;
|
||||
}
|
||||
|
||||
function artifactLabel(c: NameArtifactCard): string {
|
||||
return getLang() === "en" ? c.label_en : c.label;
|
||||
}
|
||||
|
||||
async function loadNamesTab(): Promise<void> {
|
||||
const loading = document.getElementById("names-loading");
|
||||
const list = document.getElementById("names-list");
|
||||
if (!list) return;
|
||||
try {
|
||||
const resp = await fetch("/api/me/name-compositions");
|
||||
if (!resp.ok) {
|
||||
if (loading) loading.textContent = t("einstellungen.names.error.load");
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
nameCards = (data.artifacts ?? []) as NameArtifactCard[];
|
||||
nameIsAdmin = data.is_admin === true;
|
||||
} catch {
|
||||
if (loading) loading.textContent = t("einstellungen.names.error.load");
|
||||
return;
|
||||
}
|
||||
if (loading) loading.style.display = "none";
|
||||
list.style.display = "";
|
||||
renderNameCards();
|
||||
}
|
||||
|
||||
function renderNameCards(): void {
|
||||
const list = document.getElementById("names-list");
|
||||
if (!list) return;
|
||||
list.innerHTML = nameCards.map(nameCardHTML).join("");
|
||||
for (const card of nameCards) wireNameCard(card.artifact_id);
|
||||
}
|
||||
|
||||
function nameCardHTML(c: NameArtifactCard): string {
|
||||
const id = c.artifact_id;
|
||||
const chips = c.palette
|
||||
.map(
|
||||
(v) =>
|
||||
`<button type="button" class="names-chip" data-var="${esc(v.var)}" data-art="${esc(id)}">${esc(nameVarLabel(v))}</button>`,
|
||||
)
|
||||
.join("");
|
||||
return `
|
||||
<div class="names-artifact" data-art="${esc(id)}">
|
||||
<div class="names-artifact-head">
|
||||
<h2>${esc(artifactLabel(c))}</h2>
|
||||
${nameBadgeHTML(c)}
|
||||
</div>
|
||||
<div class="names-palette" id="names-palette-${esc(id)}">${chips}</div>
|
||||
<input type="text" class="names-template-input" id="names-input-${esc(id)}"
|
||||
value="${esc(c.template)}" autocomplete="off" spellcheck="false" />
|
||||
<p class="form-msg form-msg-error names-error" id="names-error-${esc(id)}" style="display:none"></p>
|
||||
<div class="names-preview">
|
||||
<div class="names-preview-row">
|
||||
<span class="names-preview-label" data-i18n="einstellungen.names.preview.sample">${esc(t("einstellungen.names.preview.sample"))}</span>
|
||||
<code class="names-preview-value" id="names-full-${esc(id)}">${esc(c.preview_full)}</code>
|
||||
</div>
|
||||
<div class="names-preview-row">
|
||||
<span class="names-preview-label" data-i18n="einstellungen.names.preview.empty">${esc(t("einstellungen.names.preview.empty"))}</span>
|
||||
<code class="names-preview-value" id="names-empty-${esc(id)}">${esc(c.preview_empty)}</code>
|
||||
</div>
|
||||
</div>
|
||||
<p class="form-msg names-saved" id="names-saved-${esc(id)}"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-secondary" id="names-reset-${esc(id)}" data-i18n="einstellungen.names.reset">${esc(t("einstellungen.names.reset"))}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime" id="names-save-${esc(id)}" data-i18n="einstellungen.save">${esc(t("einstellungen.save"))}</button>
|
||||
</div>
|
||||
${nameIsAdmin ? nameFirmAdminHTML(c) : ""}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Badge: "Angepasst" when the user has their own override, else "Firmenstandard"
|
||||
// when the firm default is the source of the shown name. Hidden otherwise.
|
||||
function nameBadgeHTML(c: NameArtifactCard): string {
|
||||
const id = c.artifact_id;
|
||||
if (c.is_override) {
|
||||
return `<span class="names-badge" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.override_badge"))}</span>`;
|
||||
}
|
||||
if (c.firm_is_set) {
|
||||
return `<span class="names-badge names-badge--firm" id="names-badge-${esc(id)}">${esc(t("einstellungen.names.firm_badge"))}</span>`;
|
||||
}
|
||||
return `<span class="names-badge" id="names-badge-${esc(id)}" style="display:none"></span>`;
|
||||
}
|
||||
|
||||
// Admin-only firm-default controls (mirrors the firm-dashboard-default promote
|
||||
// pattern). "Set as firm default" takes whatever is in the template field;
|
||||
// "Clear" reverts the firm tier to the system default for everyone.
|
||||
function nameFirmAdminHTML(c: NameArtifactCard): string {
|
||||
const id = c.artifact_id;
|
||||
const status = c.firm_is_set
|
||||
? `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`
|
||||
: esc(t("einstellungen.names.firm.status_unset"));
|
||||
return `
|
||||
<div class="names-firm-admin" id="names-firm-${esc(id)}">
|
||||
<h3 class="names-firm-heading" data-i18n="einstellungen.names.firm.heading">${esc(t("einstellungen.names.firm.heading"))}</h3>
|
||||
<p class="form-hint names-firm-status" id="names-firm-status-${esc(id)}">${status}</p>
|
||||
<p class="form-msg names-firm-msg" id="names-firm-msg-${esc(id)}"></p>
|
||||
<div class="form-actions">
|
||||
<button type="button" class="btn-danger" id="names-firm-clear-${esc(id)}" data-i18n="einstellungen.names.firm.clear"
|
||||
style="${c.firm_is_set ? "" : "display:none"}">${esc(t("einstellungen.names.firm.clear"))}</button>
|
||||
<button type="button" class="btn-secondary" id="names-firm-set-${esc(id)}" data-i18n="einstellungen.names.firm.set">${esc(t("einstellungen.names.firm.set"))}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function wireNameCard(id: string): void {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
input.addEventListener("input", () => scheduleNamePreview(id));
|
||||
document.querySelectorAll<HTMLButtonElement>(`.names-chip[data-art="${cssEscapeAttr(id)}"]`).forEach((chip) => {
|
||||
chip.addEventListener("click", () => insertNameToken(id, chip.getAttribute("data-var") ?? ""));
|
||||
});
|
||||
document.getElementById(`names-reset-${id}`)?.addEventListener("click", () => resetNameComposition(id));
|
||||
document.getElementById(`names-save-${id}`)?.addEventListener("click", () => saveNameComposition(id));
|
||||
document.getElementById(`names-firm-set-${id}`)?.addEventListener("click", () => setFirmNameComposition(id));
|
||||
document.getElementById(`names-firm-clear-${id}`)?.addEventListener("click", () => clearFirmNameComposition(id));
|
||||
}
|
||||
|
||||
// Artifact ids are [a-z_] only, but keep the attribute-selector value safe.
|
||||
function cssEscapeAttr(s: string): string {
|
||||
return s.replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function insertNameToken(id: string, varName: string): void {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input || !varName) return;
|
||||
const token = `{${varName}}`;
|
||||
const start = input.selectionStart ?? input.value.length;
|
||||
const end = input.selectionEnd ?? input.value.length;
|
||||
input.value = input.value.slice(0, start) + token + input.value.slice(end);
|
||||
const caret = start + token.length;
|
||||
input.focus();
|
||||
input.setSelectionRange(caret, caret);
|
||||
scheduleNamePreview(id);
|
||||
}
|
||||
|
||||
function scheduleNamePreview(id: string): void {
|
||||
clearSavedMsg(id);
|
||||
const existing = namePreviewTimers.get(id);
|
||||
if (existing) window.clearTimeout(existing);
|
||||
namePreviewTimers.set(id, window.setTimeout(() => void runNamePreview(id), 250));
|
||||
}
|
||||
|
||||
async function runNamePreview(id: string): Promise<void> {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
const template = input.value;
|
||||
try {
|
||||
const resp = await fetch("/api/me/name-compositions/preview", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ artifact_id: id, template }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (data.ok) {
|
||||
setNamePreview(id, data.preview_full, data.preview_empty);
|
||||
clearNameError(id);
|
||||
} else {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
} catch {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
function setNamePreview(id: string, full: string, empty: string): void {
|
||||
const f = document.getElementById(`names-full-${id}`);
|
||||
const e = document.getElementById(`names-empty-${id}`);
|
||||
if (f) f.textContent = full;
|
||||
if (e) e.textContent = empty;
|
||||
}
|
||||
|
||||
function setNameError(id: string, msg: string): void {
|
||||
const err = document.getElementById(`names-error-${id}`);
|
||||
if (err) {
|
||||
err.textContent = msg;
|
||||
err.style.display = "";
|
||||
}
|
||||
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
|
||||
if (save) save.disabled = true;
|
||||
}
|
||||
|
||||
function clearNameError(id: string): void {
|
||||
const err = document.getElementById(`names-error-${id}`);
|
||||
if (err) {
|
||||
err.textContent = "";
|
||||
err.style.display = "none";
|
||||
}
|
||||
const save = document.getElementById(`names-save-${id}`) as HTMLButtonElement | null;
|
||||
if (save) save.disabled = false;
|
||||
}
|
||||
|
||||
function clearSavedMsg(id: string): void {
|
||||
const saved = document.getElementById(`names-saved-${id}`);
|
||||
if (saved) saved.textContent = "";
|
||||
}
|
||||
|
||||
function applyNameCard(updated: NameArtifactCard): void {
|
||||
const idx = nameCards.findIndex((c) => c.artifact_id === updated.artifact_id);
|
||||
if (idx >= 0) nameCards[idx] = updated;
|
||||
const id = updated.artifact_id;
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (input) input.value = updated.template;
|
||||
setNamePreview(id, updated.preview_full, updated.preview_empty);
|
||||
clearNameError(id);
|
||||
updateNameBadge(updated);
|
||||
updateFirmStatus(updated);
|
||||
}
|
||||
|
||||
// updateNameBadge reflects the override → firm → none state on the chip.
|
||||
function updateNameBadge(c: NameArtifactCard): void {
|
||||
const badge = document.getElementById(`names-badge-${c.artifact_id}`);
|
||||
if (!badge) return;
|
||||
if (c.is_override) {
|
||||
badge.textContent = t("einstellungen.names.override_badge");
|
||||
badge.classList.remove("names-badge--firm");
|
||||
badge.style.display = "";
|
||||
} else if (c.firm_is_set) {
|
||||
badge.textContent = t("einstellungen.names.firm_badge");
|
||||
badge.classList.add("names-badge--firm");
|
||||
badge.style.display = "";
|
||||
} else {
|
||||
badge.style.display = "none";
|
||||
}
|
||||
}
|
||||
|
||||
// updateFirmStatus refreshes the admin firm-default status line + clear button.
|
||||
function updateFirmStatus(c: NameArtifactCard): void {
|
||||
const status = document.getElementById(`names-firm-status-${c.artifact_id}`);
|
||||
if (status) {
|
||||
if (c.firm_is_set) {
|
||||
status.innerHTML = `${esc(t("einstellungen.names.firm.status_set"))} <code>${esc(c.firm_template)}</code>`;
|
||||
} else {
|
||||
status.textContent = t("einstellungen.names.firm.status_unset");
|
||||
}
|
||||
}
|
||||
const clearBtn = document.getElementById(`names-firm-clear-${c.artifact_id}`);
|
||||
if (clearBtn) clearBtn.style.display = c.firm_is_set ? "" : "none";
|
||||
}
|
||||
|
||||
async function setFirmNameComposition(id: string): Promise<void> {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
const msg = document.getElementById(`names-firm-msg-${id}`);
|
||||
if (!input) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template: input.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
// The admin PUT response carries no user override; preserve the caller's
|
||||
// own is_override/template view by merging only the firm fields.
|
||||
mergeFirmFields(id, updated);
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.firm.saved");
|
||||
msg.className = "form-msg form-msg-success names-firm-msg";
|
||||
}
|
||||
} catch {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function clearFirmNameComposition(id: string): Promise<void> {
|
||||
const msg = document.getElementById(`names-firm-msg-${id}`);
|
||||
try {
|
||||
const resp = await fetch(`/api/admin/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
mergeFirmFields(id, updated);
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.firm.cleared");
|
||||
msg.className = "form-msg form-msg-success names-firm-msg";
|
||||
}
|
||||
} catch {
|
||||
if (msg) {
|
||||
msg.textContent = t("einstellungen.names.error.invalid");
|
||||
msg.className = "form-msg form-msg-error names-firm-msg";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// mergeFirmFields applies the firm-tier fields from an admin PUT/DELETE
|
||||
// response onto the stored card without disturbing the caller's own
|
||||
// user-override view, then refreshes the badge + firm status.
|
||||
function mergeFirmFields(id: string, fromAdmin: NameArtifactCard): void {
|
||||
const idx = nameCards.findIndex((c) => c.artifact_id === id);
|
||||
if (idx < 0) return;
|
||||
nameCards[idx].firm_is_set = fromAdmin.firm_is_set;
|
||||
nameCards[idx].firm_template = fromAdmin.firm_template;
|
||||
updateNameBadge(nameCards[idx]);
|
||||
updateFirmStatus(nameCards[idx]);
|
||||
}
|
||||
|
||||
async function saveNameComposition(id: string): Promise<void> {
|
||||
const input = document.getElementById(`names-input-${id}`) as HTMLInputElement | null;
|
||||
if (!input) return;
|
||||
try {
|
||||
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ template: input.value }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
applyNameCard(updated);
|
||||
const saved = document.getElementById(`names-saved-${id}`);
|
||||
if (saved) {
|
||||
saved.textContent = t("einstellungen.names.saved");
|
||||
saved.className = "form-msg form-msg-success names-saved";
|
||||
}
|
||||
} catch {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetNameComposition(id: string): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch(`/api/me/name-compositions/${encodeURIComponent(id)}`, { method: "DELETE" });
|
||||
if (!resp.ok) {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
return;
|
||||
}
|
||||
const updated = (await resp.json()) as NameArtifactCard;
|
||||
applyNameCard(updated);
|
||||
const saved = document.getElementById(`names-saved-${id}`);
|
||||
if (saved) {
|
||||
saved.textContent = t("einstellungen.names.reset_done");
|
||||
saved.className = "form-msg form-msg-success names-saved";
|
||||
}
|
||||
} catch {
|
||||
setNameError(id, t("einstellungen.names.error.invalid"));
|
||||
}
|
||||
}
|
||||
|
||||
// Re-localise palette chips + artifact headings on language change without
|
||||
// rebuilding the cards (which would discard in-progress edits).
|
||||
function relocaliseNameCards(): void {
|
||||
for (const card of nameCards) {
|
||||
const head = document.querySelector(`.names-artifact[data-art="${cssEscapeAttr(card.artifact_id)}"] h2`);
|
||||
if (head) head.textContent = artifactLabel(card);
|
||||
const badge = document.getElementById(`names-badge-${card.artifact_id}`);
|
||||
if (badge && badge.style.display !== "none") badge.textContent = t("einstellungen.names.override_badge");
|
||||
for (const v of card.palette) {
|
||||
const chip = document.querySelector(
|
||||
`.names-chip[data-art="${cssEscapeAttr(card.artifact_id)}"][data-var="${cssEscapeAttr(v.var)}"]`,
|
||||
);
|
||||
if (chip) chip.textContent = nameVarLabel(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Init -------------------------------------------------------------------
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
@@ -1152,6 +1562,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
renderCalDAVStatus();
|
||||
void loadCalDAVLog();
|
||||
}
|
||||
if (loadedTabs.has("names")) relocaliseNameCards();
|
||||
});
|
||||
|
||||
showTab(parseTab(), false);
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBasePreview } from "./base-preview-modal";
|
||||
import { escapeHtml, cssEscape } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, labelMap } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
@@ -33,6 +36,9 @@ interface SubmissionDraftJSON {
|
||||
// path stays the fallback). composer_meta carries the seed-time
|
||||
// section order in later slices.
|
||||
base_id?: string | null;
|
||||
// t-paliad-349 slice 7 — pinned uploaded docforge template version.
|
||||
// Mutually exclusive with base_id in practice (export checks this first).
|
||||
template_version_id?: string | null;
|
||||
composer_meta?: Record<string, unknown>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
@@ -69,6 +75,17 @@ interface SubmissionBaseRow {
|
||||
section_count: number;
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — an uploaded docforge template offered in the
|
||||
// picker for generation. version_id is what a draft pins.
|
||||
interface PickerTemplate {
|
||||
id: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
firm?: string | null;
|
||||
version: number;
|
||||
version_id?: string;
|
||||
}
|
||||
|
||||
interface AvailablePartyJSON {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -153,19 +170,16 @@ function isEN(): boolean {
|
||||
return document.documentElement.lang === "en";
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
// escapeHtml + cssEscape now come from ../lib/docforge-editor/dom (the
|
||||
// shared editor utilities); the local copies were removed in slice 5.
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Variable contract — DE/EN labels per dotted-path placeholder.
|
||||
// Mirrors the same shape the email-template variables sidebar uses;
|
||||
// keeps the lawyer's mental model anchored on the same vocabulary.
|
||||
// Labels come from the Go-side catalogue (GET /api/docforge/variables),
|
||||
// fetched once on boot into state.varLabels. The frontend keeps only the
|
||||
// presentation grouping (VARIABLE_GROUPS) — which keys to show and how to
|
||||
// section them — not the label data itself, so labels can't drift from the
|
||||
// resolvers that produce the values (t-paliad-349 slice 5).
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
@@ -186,71 +200,6 @@ interface VariableGroup {
|
||||
collapsedByDefault?: boolean;
|
||||
}
|
||||
|
||||
const VARIABLE_LABELS: Record<string, VariableLabel> = {
|
||||
"firm.name": { de: "Kanzlei", en: "Firm" },
|
||||
"firm.signature_block": { de: "Signatur-Block", en: "Signature block" },
|
||||
"today": { de: "Heute", en: "Today" },
|
||||
"today.iso": { de: "Heute (ISO)", en: "Today (ISO)" },
|
||||
"today.long_de": { de: "Heute (DE lang)", en: "Today (DE long)" },
|
||||
"today.long_en": { de: "Heute (EN lang)", en: "Today (EN long)" },
|
||||
"user.display_name": { de: "Bearbeiter", en: "Author" },
|
||||
"user.email": { de: "E-Mail", en: "Email" },
|
||||
"user.office": { de: "Büro", en: "Office" },
|
||||
"project.title": { de: "Projekttitel", en: "Project title" },
|
||||
"project.reference": { de: "Aktenzeichen (intern)", en: "Internal reference" },
|
||||
"project.case_number": { de: "Aktenzeichen (Gericht)", en: "Court case number" },
|
||||
"project.court": { de: "Gericht", en: "Court" },
|
||||
"project.patent_number": { de: "Patentnummer", en: "Patent number" },
|
||||
"project.patent_number_upc": { de: "Patentnummer (UPC-Format)", en: "Patent number (UPC format)" },
|
||||
"project.filing_date": { de: "Anmeldedatum", en: "Filing date" },
|
||||
"project.grant_date": { de: "Erteilungsdatum", en: "Grant date" },
|
||||
"project.our_side": { de: "Unsere Seite", en: "Our side" },
|
||||
"project.our_side_de": { de: "Unsere Seite (DE)", en: "Our side (DE)" },
|
||||
"project.our_side_en": { de: "Unsere Seite (EN)", en: "Our side (EN)" },
|
||||
"project.instance_level": { de: "Instanz", en: "Instance" },
|
||||
"project.client_number": { de: "Mandantennummer", en: "Client number" },
|
||||
"project.matter_number": { de: "Matter-Nummer", en: "Matter number" },
|
||||
"project.proceeding.code": { de: "Verfahrenstyp (Code)", en: "Proceeding type (code)" },
|
||||
"project.proceeding.name": { de: "Verfahrenstyp", en: "Proceeding type" },
|
||||
"project.proceeding.name_de": { de: "Verfahrenstyp (DE)", en: "Proceeding type (DE)" },
|
||||
"project.proceeding.name_en": { de: "Verfahrenstyp (EN)", en: "Proceeding type (EN)" },
|
||||
"parties.claimant.name": { de: "Klägerin", en: "Claimant" },
|
||||
"parties.claimant.representative": { de: "Klägerin-Vertreter", en: "Claimant representative" },
|
||||
"parties.defendant.name": { de: "Beklagte", en: "Defendant" },
|
||||
"parties.defendant.representative":{ de: "Beklagten-Vertreter", en: "Defendant representative" },
|
||||
"parties.other.name": { de: "Weitere Partei", en: "Other party" },
|
||||
"parties.other.representative": { de: "Weitere-Partei-Vertreter", en: "Other party representative" },
|
||||
// Procedural-event namespace (t-paliad-262 Slice A, design doc
|
||||
// docs/design-procedural-events-model-2026-05-25.md). The canonical
|
||||
// placeholder names are below; the `rule.*` aliases that follow are
|
||||
// @deprecated but kept forever per m's Q7 lock — existing Word
|
||||
// templates and saved drafts authored with the old names keep
|
||||
// merging identically.
|
||||
"procedural_event.code": { de: "Code (Verfahrensschritt)", en: "Code (procedural event)" },
|
||||
"procedural_event.name": { de: "Verfahrensschritt", en: "Procedural event" },
|
||||
"procedural_event.name_de": { de: "Verfahrensschritt (DE)", en: "Procedural event (DE)" },
|
||||
"procedural_event.name_en": { de: "Verfahrensschritt (EN)", en: "Procedural event (EN)" },
|
||||
"procedural_event.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"procedural_event.legal_source_pretty":{ de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"procedural_event.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"procedural_event.event_kind": { de: "Art des Verfahrensschritts", en: "Procedural-event kind" },
|
||||
// Legacy aliases — @deprecated, kept forever (m/paliad#93 Q7).
|
||||
"rule.submission_code": { de: "Schriftsatz-Code (legacy)", en: "Submission code (legacy)" },
|
||||
"rule.name": { de: "Schriftsatz (legacy)", en: "Submission (legacy)" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE, legacy)", en: "Submission (DE, legacy)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN, legacy)", en: "Submission (EN, legacy)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code, legacy)", en: "Legal source (code, legacy)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage (legacy)", en: "Legal source (legacy)" },
|
||||
"rule.primary_party": { de: "Partei (typisch, legacy)", en: "Primary party (legacy)" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ (legacy)", en: "Event type (legacy)" },
|
||||
"deadline.due_date": { de: "Frist (ISO)", en: "Deadline (ISO)" },
|
||||
"deadline.due_date_long_de": { de: "Frist (DE lang)", en: "Deadline (DE long)" },
|
||||
"deadline.due_date_long_en": { de: "Frist (EN lang)", en: "Deadline (EN long)" },
|
||||
"deadline.original_due_date": { de: "Ursprüngliche Frist", en: "Original deadline" },
|
||||
"deadline.computed_from": { de: "Frist berechnet aus", en: "Deadline computed from" },
|
||||
"deadline.title": { de: "Frist-Titel", en: "Deadline title" },
|
||||
"deadline.source": { de: "Frist-Quelle", en: "Deadline source" },
|
||||
};
|
||||
|
||||
// t-paliad-287 — variable groups restructured into four lawyer-facing
|
||||
// sections: Mandant/Verfahren up top (the case identity), then Parteien
|
||||
@@ -341,7 +290,7 @@ const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
const entry = state.varLabels[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
@@ -373,6 +322,15 @@ interface State {
|
||||
// completes) keeps the picker hidden permanently for this load.
|
||||
bases: SubmissionBaseRow[];
|
||||
basesLoaded: boolean;
|
||||
// t-paliad-349 slice 7 — uploaded templates offered in the picker.
|
||||
templates: PickerTemplate[];
|
||||
templatesLoaded: boolean;
|
||||
// t-paliad-349 slice 5 — variable labels fetched once on boot from the
|
||||
// Go catalogue (GET /api/docforge/variables), the single source of
|
||||
// truth. Empty until the fetch lands; labelFor falls back to the raw
|
||||
// key, so a failed fetch degrades gracefully rather than breaking the
|
||||
// form.
|
||||
varLabels: Record<string, VariableLabel>;
|
||||
}
|
||||
|
||||
type PartySide = "claimant" | "defendant" | "other";
|
||||
@@ -401,6 +359,9 @@ const state: State = {
|
||||
addPartyBusy: false,
|
||||
bases: [],
|
||||
basesLoaded: false,
|
||||
templates: [],
|
||||
templatesLoaded: false,
|
||||
varLabels: {},
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -425,6 +386,21 @@ async function boot(): Promise<void> {
|
||||
console.warn("submission-draft: base catalog fetch failed", err);
|
||||
state.basesLoaded = true;
|
||||
});
|
||||
// t-paliad-349 slice 7 — uploaded-template catalog for the picker.
|
||||
loadTemplates().catch(err => {
|
||||
console.warn("submission-draft: template catalog fetch failed", err);
|
||||
state.templatesLoaded = true;
|
||||
});
|
||||
|
||||
// t-paliad-349 slice 5 — load the variable-label catalogue (Go SSOT)
|
||||
// before the first paint so the sidebar form labels render. Awaited
|
||||
// because labelFor needs it at paint time; a failure leaves varLabels
|
||||
// empty and labelFor falls back to the raw key (degraded but usable).
|
||||
try {
|
||||
state.varLabels = labelMap(await fetchVariableCatalogue());
|
||||
} catch (err) {
|
||||
console.warn("submission-draft: variable catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
@@ -528,7 +504,7 @@ async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string }): Promise<SubmissionDraftView> {
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null; selected_parties?: string[]; language?: string; filename_keyword?: string }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
@@ -583,6 +559,7 @@ function paint(): void {
|
||||
paintPartyPicker();
|
||||
paintLanguageRow();
|
||||
paintLanguageFallback();
|
||||
paintKeywordRow();
|
||||
paintVariables();
|
||||
paintSectionList();
|
||||
paintPreview();
|
||||
@@ -693,6 +670,29 @@ function paintNameRow(): void {
|
||||
|
||||
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
|
||||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||||
|
||||
// t-paliad-370 S3 — 👁 Vorschau opens the base-preview modal for the
|
||||
// current draft (its data), with the base-switcher as the chooser; picking
|
||||
// "Diese Basis verwenden" commits via the existing base-swap path.
|
||||
const previewBaseBtn = document.getElementById("submission-draft-preview-base-btn") as HTMLButtonElement | null;
|
||||
if (previewBaseBtn) {
|
||||
previewBaseBtn.onclick = () => {
|
||||
if (!state.view) return;
|
||||
const d = state.view.draft;
|
||||
const current = d.template_version_id
|
||||
? "tpl:" + d.template_version_id
|
||||
: (d.base_id ?? "");
|
||||
void openBasePreview({
|
||||
code: d.submission_code,
|
||||
lang: d.language || state.view.lang || "de",
|
||||
draftId: d.id,
|
||||
projectId: d.project_id,
|
||||
currentBase: current,
|
||||
defaultData: "mine",
|
||||
onApply: (value) => { void onBaseChange(value); },
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// t-paliad-277 — "Aus Projekt importieren" + last-imported-at stamp.
|
||||
@@ -1059,6 +1059,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;
|
||||
@@ -1186,7 +1233,7 @@ function paintVariables(): void {
|
||||
function paintPreview(): void {
|
||||
const host = document.getElementById("submission-draft-preview");
|
||||
if (!host || !state.view) return;
|
||||
host.innerHTML = state.view.preview_html ?? "";
|
||||
host.innerHTML = softenEmptyMarkers(state.view.preview_html ?? "");
|
||||
wireDraftVars(host);
|
||||
// t-paliad-274 (B) — preview HTML was just blown away by innerHTML,
|
||||
// so any prior --active classes are gone. Re-apply for whichever
|
||||
@@ -1200,6 +1247,20 @@ function paintPreview(): void {
|
||||
}
|
||||
}
|
||||
|
||||
// softenEmptyMarkers — t-paliad-370 S5. In the LIVE preview only, render honest
|
||||
// gaps as a muted '‹noch leer: key›' prompt instead of the harsh
|
||||
// [KEIN WERT: key] marker. The EXPORTED .docx keeps the real marker (an honest
|
||||
// blank the lawyer fills in Word) — this is a preview-display nicety, so it
|
||||
// runs here on the client, not in the render pipeline. The key in the marker is
|
||||
// a variable key (letters/dots/underscores), so it never collides with the
|
||||
// preview's data-var attributes.
|
||||
function softenEmptyMarkers(html: string): string {
|
||||
return html.replace(/\[(?:KEIN WERT|NO VALUE):\s*([^\]]+)\]/g, (_m, key: string) => {
|
||||
const prefix = isEN() ? "empty: " : "noch leer: ";
|
||||
return `<span class="preview-empty">‹${prefix}${escapeHtml(key.trim())}›</span>`;
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// t-paliad-313 Composer Slice A — base picker + section list
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
@@ -1217,29 +1278,46 @@ async function loadBases(): Promise<void> {
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
// loadTemplates fetches the firm-shared uploaded-template catalog
|
||||
// (t-paliad-349 slice 7). Failure leaves the list empty — the picker
|
||||
// simply offers no uploaded templates, the editor stays usable.
|
||||
async function loadTemplates(): Promise<void> {
|
||||
const res = await fetch("/api/templates", { credentials: "include" });
|
||||
if (!res.ok) {
|
||||
throw new Error("template list HTTP " + res.status);
|
||||
}
|
||||
const body = await res.json() as { templates?: PickerTemplate[] };
|
||||
state.templates = (body.templates ?? []).filter(t => !!t.version_id);
|
||||
state.templatesLoaded = true;
|
||||
if (state.view) paintBasePicker();
|
||||
}
|
||||
|
||||
function paintBasePicker(): void {
|
||||
const row = document.getElementById("submission-draft-base-row") as HTMLDivElement | null;
|
||||
const sel = document.getElementById("submission-draft-base") as HTMLSelectElement | null;
|
||||
if (!row || !sel || !state.view) return;
|
||||
|
||||
// Hide the picker until the catalog has loaded AND the catalog has
|
||||
// at least one entry. A failed fetch (basesLoaded=true, bases empty)
|
||||
// keeps the picker hidden indefinitely so the editor stays usable.
|
||||
if (!state.basesLoaded || state.bases.length === 0) {
|
||||
// Hide the picker only when BOTH catalogs are loaded-but-empty. As long
|
||||
// as bases OR uploaded templates exist, the picker is useful. A failed
|
||||
// fetch leaves the respective list empty; the editor stays usable.
|
||||
const hasBases = state.basesLoaded && state.bases.length > 0;
|
||||
const hasTemplates = state.templatesLoaded && state.templates.length > 0;
|
||||
if (!hasBases && !hasTemplates) {
|
||||
row.style.display = "none";
|
||||
return;
|
||||
}
|
||||
row.style.display = "";
|
||||
|
||||
// Rebuild the <option> list each paint so language toggles + base
|
||||
// catalog updates flow through.
|
||||
// Rebuild the <option> list each paint so language toggles + catalog
|
||||
// updates flow through.
|
||||
sel.innerHTML = "";
|
||||
const currentBaseID = state.view.draft.base_id ?? "";
|
||||
const currentTplVersion = state.view.draft.template_version_id ?? "";
|
||||
|
||||
// "Keine Vorlagenbasis" only listed when the draft is currently in
|
||||
// that state (pre-Composer / cleared). Avoids tempting the lawyer
|
||||
// to clear after they've already picked one.
|
||||
if (!currentBaseID) {
|
||||
// that state (no base, no template). Avoids tempting the lawyer to
|
||||
// clear after they've already picked one.
|
||||
if (!currentBaseID && !currentTplVersion) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "";
|
||||
opt.textContent = isEN() ? "— no base —" : "— keine Vorlagenbasis —";
|
||||
@@ -1252,6 +1330,21 @@ function paintBasePicker(): void {
|
||||
if (b.id === currentBaseID) opt.selected = true;
|
||||
sel.appendChild(opt);
|
||||
}
|
||||
// t-paliad-349 slice 7 — uploaded templates as a separate optgroup.
|
||||
// The value is "tpl:<version_id>" so onBaseChange can route it to the
|
||||
// template_version_id PATCH instead of base_id.
|
||||
if (hasTemplates) {
|
||||
const group = document.createElement("optgroup");
|
||||
group.label = isEN() ? "Uploaded templates" : "Hochgeladene Vorlagen";
|
||||
for (const tmpl of state.templates) {
|
||||
const opt = document.createElement("option");
|
||||
opt.value = "tpl:" + tmpl.version_id;
|
||||
opt.textContent = isEN() ? tmpl.name_en : tmpl.name_de;
|
||||
if (tmpl.version_id === currentTplVersion) opt.selected = true;
|
||||
group.appendChild(opt);
|
||||
}
|
||||
sel.appendChild(group);
|
||||
}
|
||||
|
||||
// Wire change handler once per paint. Removing then re-adding
|
||||
// keeps the binding consistent across repaints (e.g. after
|
||||
@@ -1259,12 +1352,17 @@ function paintBasePicker(): void {
|
||||
sel.onchange = () => { onBaseChange(sel.value); };
|
||||
}
|
||||
|
||||
async function onBaseChange(newBaseID: string): Promise<void> {
|
||||
async function onBaseChange(newValue: string): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const payload: Record<string, unknown> = {
|
||||
// Empty string in the picker maps to null = clear.
|
||||
base_id: newBaseID === "" ? null : newBaseID,
|
||||
};
|
||||
// The picker mixes legacy bases (plain uuid) and uploaded templates
|
||||
// ("tpl:<version_id>"). Route to the matching field and clear the other
|
||||
// so the two render paths stay mutually exclusive. Empty = clear both.
|
||||
let payload: Record<string, unknown>;
|
||||
if (newValue.startsWith("tpl:")) {
|
||||
payload = { template_version_id: newValue.slice(4), base_id: null };
|
||||
} else {
|
||||
payload = { base_id: newValue === "" ? null : newValue, template_version_id: null };
|
||||
}
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/api/submission-drafts/${state.view.draft.id}`,
|
||||
@@ -1299,10 +1397,22 @@ function paintSectionList(): void {
|
||||
if (!wrap || !list || !state.view) return;
|
||||
|
||||
const sections = state.view.sections ?? [];
|
||||
|
||||
// t-paliad-370 S5 — make the section panel's presence EXPLICIT instead of
|
||||
// silently hiding it (the §1.4 2-vs-3-panel inconsistency). An empty Composer
|
||||
// draft (base_id set) gets an "add section" affordance; a fixed-template
|
||||
// draft (merge path / uploaded template) is signposted as having none.
|
||||
if (sections.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
list.innerHTML = "";
|
||||
const staleTrailer = document.getElementById("submission-draft-sections-trailer");
|
||||
if (staleTrailer) staleTrailer.remove();
|
||||
paintSectionsEmptyState(wrap, !!state.view.draft.base_id);
|
||||
return;
|
||||
}
|
||||
// Has sections — drop any empty-state from a previous paint.
|
||||
const staleEmpty = document.getElementById("submission-draft-sections-empty");
|
||||
if (staleEmpty) staleEmpty.remove();
|
||||
wrap.style.display = "";
|
||||
|
||||
// Don't blow away the editor if a section is currently focused —
|
||||
@@ -1339,6 +1449,40 @@ function paintSectionList(): void {
|
||||
trailer.appendChild(addBtn);
|
||||
}
|
||||
|
||||
// paintSectionsEmptyState signposts WHY a draft shows no section rows
|
||||
// (t-paliad-370 S5). A Composer draft (base_id) can add them; a fixed-template
|
||||
// draft (merge path / uploaded template) has none by design.
|
||||
function paintSectionsEmptyState(wrap: HTMLElement, composerCapable: boolean): void {
|
||||
let empty = document.getElementById("submission-draft-sections-empty");
|
||||
if (!empty) {
|
||||
empty = document.createElement("div");
|
||||
empty.id = "submission-draft-sections-empty";
|
||||
empty.className = "submission-draft-sections-empty";
|
||||
wrap.appendChild(empty);
|
||||
}
|
||||
empty.innerHTML = "";
|
||||
|
||||
const msg = document.createElement("p");
|
||||
if (composerCapable) {
|
||||
msg.textContent = isEN()
|
||||
? "This draft has no sections yet."
|
||||
: "Dieser Entwurf hat noch keine Abschnitte.";
|
||||
empty.appendChild(msg);
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "btn-small btn-secondary";
|
||||
addBtn.textContent = isEN() ? "+ Add section" : "+ Abschnitt hinzufügen";
|
||||
addBtn.addEventListener("click", () => openAddSectionForm(empty!));
|
||||
empty.appendChild(addBtn);
|
||||
} else {
|
||||
msg.className = "submission-draft-sections-note";
|
||||
msg.textContent = isEN()
|
||||
? "This submission type uses a fixed template — no editable sections. Fill the variables on the left; the preview shows the result."
|
||||
: "Dieser Schriftsatztyp nutzt eine feste Vorlage — keine editierbaren Abschnitte. Füllen Sie links die Variablen; die Vorschau zeigt das Ergebnis.";
|
||||
empty.appendChild(msg);
|
||||
}
|
||||
}
|
||||
|
||||
function renderSectionRow(sec: SubmissionSectionJSON, lang: string, isActive: boolean): HTMLLIElement {
|
||||
const li = document.createElement("li");
|
||||
li.className = "submission-draft-section";
|
||||
@@ -1985,11 +2129,11 @@ function paintPickerList(host: HTMLElement, blocks: BuildingBlockPickJSON[], sec
|
||||
const preview = ((lang === "en" ? b.content_md_en : b.content_md_de) || "").slice(0, 200);
|
||||
row.innerHTML = `
|
||||
<div class="submission-bb-picker-row-head">
|
||||
<strong>${escapeHTML(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHTML(b.visibility)}">${escapeHTML(b.visibility)}</span>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<span class="submission-bb-picker-vis submission-bb-picker-vis--${escapeHtml(b.visibility)}">${escapeHtml(b.visibility)}</span>
|
||||
</div>
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHTML(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHTML(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
${desc ? `<div class="submission-bb-picker-row-desc">${escapeHtml(desc)}</div>` : ""}
|
||||
<pre class="submission-bb-picker-row-preview">${escapeHtml(preview)}${preview.length === 200 ? "…" : ""}</pre>`;
|
||||
row.addEventListener("click", () => {
|
||||
void insertBlockIntoSection(b.id, sec.id, overlay);
|
||||
});
|
||||
@@ -2019,15 +2163,6 @@ async function insertBlockIntoSection(blockID: string, sectionID: string, overla
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHTML(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
async function patchSection(sectionID: string, payload: Record<string, unknown>): Promise<void> {
|
||||
try {
|
||||
const draftID = state.view?.draft.id;
|
||||
@@ -2104,17 +2239,6 @@ function findVarInput(key: string): HTMLInputElement | null {
|
||||
);
|
||||
}
|
||||
|
||||
function cssEscape(s: string): string {
|
||||
// CSS.escape covers our placeholder keys ([A-Za-z][A-Za-z0-9_.]*) but
|
||||
// older browsers may lack it; defensive fallback escapes characters
|
||||
// CSS treats as special. Placeholder keys never carry whitespace or
|
||||
// quotes so escaping is straightforward.
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
|
||||
function onDraftVarClick(key: string, ev: Event): void {
|
||||
const input = findVarInput(key);
|
||||
if (!input) return;
|
||||
|
||||
@@ -1,10 +1,16 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { createRowActionMenu } from "./row-action-menu";
|
||||
import { openBasePreview } from "./base-preview-modal";
|
||||
|
||||
// t-paliad-243 — client for /submissions/new. Fetches the
|
||||
// cross-proceeding submission catalog, groups it by proceeding, filters
|
||||
// by text + chip, and offers two start paths per row: with project
|
||||
// (modal picker) or without (project-less draft → /submissions/draft/{id}).
|
||||
// by text + chip.
|
||||
//
|
||||
// t-paliad-370 (docforge UX S1): each row is one primary "Entwurf starten"
|
||||
// CTA (free-start, project-less — m keeps this first-class) + a ⋯ menu for
|
||||
// the alternates ("Mit Projekt verknüpfen…" → modal picker, base preview),
|
||||
// consistent with the project Schriftsätze tab.
|
||||
|
||||
interface CatalogEntry {
|
||||
submission_code: string;
|
||||
@@ -180,11 +186,31 @@ function renderTable(): void {
|
||||
if (code) void startDraft(code, null);
|
||||
});
|
||||
});
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-with-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
if (code) openProjectPicker(code);
|
||||
});
|
||||
|
||||
// Mount the ⋯ menu per row: "Mit Projekt verknüpfen…" (modal picker) +
|
||||
// base-preview stub. Fresh each render to avoid stale closures.
|
||||
body.querySelectorAll<HTMLSpanElement>(".row-action-menu-mount").forEach((mount) => {
|
||||
const code = mount.dataset.code ?? "";
|
||||
const menu = createRowActionMenu(
|
||||
[
|
||||
{
|
||||
label: isEN() ? "Link a project…" : "Mit Projekt verknüpfen…",
|
||||
onSelect: () => { if (code) openProjectPicker(code); },
|
||||
},
|
||||
{
|
||||
// t-paliad-370 S3 — eyeball the base before starting a draft.
|
||||
// Look-only (no draft, no project): sample data fills the page.
|
||||
label: isEN() ? "Preview template base" : "Vorschau Vorlagenbasis",
|
||||
onSelect: () => void openBasePreview({
|
||||
code,
|
||||
lang: isEN() ? "en" : "de",
|
||||
defaultData: "sample",
|
||||
}),
|
||||
},
|
||||
],
|
||||
{ ariaLabel: isEN() ? "More actions" : "Weitere Aktionen" },
|
||||
);
|
||||
mount.replaceWith(menu);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -194,8 +220,7 @@ function renderRow(entry: CatalogEntry): string {
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${esc(isEN() ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage")}">${esc(isEN() ? "universal" : "universell")}</span>`;
|
||||
const withProject = isEN() ? "Mit Projekt…" : "Mit Projekt…";
|
||||
const noProject = isEN() ? "Ohne Projekt" : "Ohne Projekt";
|
||||
const startLabel = isEN() ? "Start draft" : "Entwurf starten";
|
||||
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
@@ -205,8 +230,8 @@ function renderRow(entry: CatalogEntry): string {
|
||||
<td>${esc(partyLabel(entry.primary_party))}</td>
|
||||
<td>${esc(source)}</td>
|
||||
<td class="submission-action-cell">
|
||||
<button type="button" class="btn-secondary btn-small submissions-new-start-with-project" data-code="${esc(entry.submission_code)}">${esc(withProject)}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(noProject)}</button>
|
||||
<button type="button" class="btn-primary btn-cta-lime btn-small submissions-new-start-no-project" data-code="${esc(entry.submission_code)}">${esc(startLabel)}</button>
|
||||
<span class="row-action-menu-mount" data-code="${esc(entry.submission_code)}"></span>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@
|
||||
// DE.INF.LG, an Opposition under EPO, etc. — the editor (t-paliad-238)
|
||||
// handles missing variables gracefully via the [KEIN WERT: …] marker,
|
||||
// so cross-proceeding picks still render cleanly.
|
||||
//
|
||||
// t-paliad-370 (docforge UX S1): each row is one primary "Entwurf öffnen"
|
||||
// CTA + a ⋯ menu holding the secondary actions (direct export, base
|
||||
// preview), consistent with the global picker.
|
||||
|
||||
import { createRowActionMenu } from "./row-action-menu";
|
||||
import { openBasePreview } from "./base-preview-modal";
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
@@ -145,14 +152,33 @@ function render(data: SubmissionListResponse): void {
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
// Wire button clicks. One handler per render to avoid stale closures
|
||||
// Mount the ⋯ menu per row, fresh each render to avoid stale closures
|
||||
// from the previous render's data.
|
||||
body.querySelectorAll<HTMLButtonElement>(".submission-generate-btn").forEach((btn) => {
|
||||
btn.addEventListener("click", (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
void onGenerateClick(btn);
|
||||
});
|
||||
body.querySelectorAll<HTMLSpanElement>(".row-action-menu-mount").forEach((mount) => {
|
||||
const code = mount.dataset.code ?? "";
|
||||
const projectID = mount.dataset.project ?? "";
|
||||
const menu = createRowActionMenu(
|
||||
[
|
||||
{
|
||||
label: isEN ? "Export directly (.docx)" : "Direkt exportieren (.docx)",
|
||||
onSelect: () => void generateAndDownload(code, projectID),
|
||||
},
|
||||
{
|
||||
// t-paliad-370 S3 — eyeball the base before opening the editor.
|
||||
// Look-only here (no draft yet): no onApply, scoped to the project
|
||||
// so the preview can use its data. Sample data fills the gaps.
|
||||
label: isEN ? "Preview template base" : "Vorschau Vorlagenbasis",
|
||||
onSelect: () => void openBasePreview({
|
||||
code,
|
||||
lang: isEN ? "en" : "de",
|
||||
projectId: projectID,
|
||||
defaultData: "sample",
|
||||
}),
|
||||
},
|
||||
],
|
||||
{ ariaLabel: isEN ? "More actions" : "Weitere Aktionen" },
|
||||
);
|
||||
mount.replaceWith(menu);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -164,14 +190,15 @@ function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): st
|
||||
const templateBadge = entry.has_template
|
||||
? ""
|
||||
: ` <span class="submission-template-badge" title="${isEN ? "Uses the universal style template" : "Verwendet die universelle Stilvorlage"}">${isEN ? "universal" : "universell"}</span>`;
|
||||
const editBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
||||
const openBtn = `<a href="${escapeHtml(draftHref)}" class="btn-primary btn-cta-lime btn-small submission-edit-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-i18n="projects.detail.submissions.action.edit">${isEN ? "Edit" : "Bearbeiten"}</a>`;
|
||||
const generateBtn = `<button type="button" class="btn-secondary btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(projectID)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`;
|
||||
const action = `${editBtn} ${generateBtn}`;
|
||||
data-i18n="projects.detail.submissions.action.open">${isEN ? "Open draft" : "Entwurf öffnen"}</a>`;
|
||||
// ⋯ menu (direct export, base preview) is mounted into this slot after
|
||||
// innerHTML in render() — see createRowActionMenu wiring above.
|
||||
const menuMount = `<span class="row-action-menu-mount"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(projectID)}"></span>`;
|
||||
const action = `${openBtn} ${menuMount}`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
@@ -206,18 +233,15 @@ function formatParty(role: string | undefined, isEN: boolean): string {
|
||||
}
|
||||
}
|
||||
|
||||
// onGenerateClick triggers a download. Disables the button while the
|
||||
// request is in flight to prevent double-submits and surfaces an
|
||||
// inline error on failure.
|
||||
async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
const code = btn.dataset.code;
|
||||
const projectID = btn.dataset.project;
|
||||
// generateAndDownload runs the "Direkt exportieren" kebab action: POST the
|
||||
// generate endpoint and stream the .docx down. The trigger is a menu item
|
||||
// that closes on select, so progress is signalled via the busy cursor
|
||||
// (rather than a button label) and failures surface as an alert.
|
||||
async function generateAndDownload(code: string, projectID: string): Promise<void> {
|
||||
if (!code || !projectID) return;
|
||||
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = document.documentElement.lang === "en" ? "Generating…" : "Wird generiert…";
|
||||
|
||||
const prevCursor = document.body.style.cursor;
|
||||
document.body.style.cursor = "progress";
|
||||
try {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/generate`;
|
||||
const resp = await fetch(url, { method: "POST" });
|
||||
@@ -241,8 +265,7 @@ async function onGenerateClick(btn: HTMLButtonElement): Promise<void> {
|
||||
?? `${code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
document.body.style.cursor = prevCursor;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
314
frontend/src/client/templates-authoring.ts
Normal file
314
frontend/src/client/templates-authoring.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
import { initI18n } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { escapeHtml } from "../lib/docforge-editor/dom";
|
||||
import { fetchVariableCatalogue, type VariableEntry } from "../lib/docforge-editor/catalogue";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — client for the template authoring page.
|
||||
//
|
||||
// Flow: list templates → upload a .docx (or open one) → the carrier renders
|
||||
// as run spans (<span class="docforge-run" data-run="N">) → the admin
|
||||
// selects text within one run, then clicks a variable in the palette → the
|
||||
// server injects {{slot}} at the selection and returns the updated view.
|
||||
//
|
||||
// The select-then-pick gesture keys on the run index (data-run) + the
|
||||
// selected text, matching the server's text-based InjectSlot so umlauts
|
||||
// can't desync the selection from the slice. Selections that span more than
|
||||
// one run are rejected with a hint (v1 scope: single-run text slots).
|
||||
|
||||
interface TemplateMeta {
|
||||
id: string;
|
||||
slug?: string;
|
||||
name_de: string;
|
||||
name_en: string;
|
||||
kind: string;
|
||||
source_format: string;
|
||||
firm?: string;
|
||||
is_active: boolean;
|
||||
version: number;
|
||||
}
|
||||
|
||||
interface TemplateSlot {
|
||||
key: string;
|
||||
anchor: string;
|
||||
label?: string;
|
||||
order_index: number;
|
||||
}
|
||||
|
||||
interface AuthoringView {
|
||||
template: TemplateMeta;
|
||||
preview_html: string;
|
||||
slots: TemplateSlot[];
|
||||
}
|
||||
|
||||
interface Selection1Run {
|
||||
runIndex: number;
|
||||
text: string;
|
||||
}
|
||||
|
||||
interface State {
|
||||
catalogue: VariableEntry[];
|
||||
openID: string | null;
|
||||
activeSlotKey: string | null;
|
||||
selection: Selection1Run | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
catalogue: [],
|
||||
openID: null,
|
||||
activeSlotKey: null,
|
||||
selection: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return (document.documentElement.lang || "de").toLowerCase().startsWith("en");
|
||||
}
|
||||
|
||||
function labelOf(e: VariableEntry): string {
|
||||
return isEN() ? e.label_en : e.label_de;
|
||||
}
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
try {
|
||||
state.catalogue = await fetchVariableCatalogue();
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: catalogue fetch failed", err);
|
||||
}
|
||||
|
||||
wireUploadForm();
|
||||
await loadList();
|
||||
}
|
||||
|
||||
async function loadList(): Promise<void> {
|
||||
const host = document.getElementById("docforge-template-list");
|
||||
if (!host) return;
|
||||
let metas: TemplateMeta[] = [];
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { headers: { Accept: "application/json" } });
|
||||
if (res.ok) {
|
||||
const body = (await res.json()) as { templates: TemplateMeta[] };
|
||||
metas = body.templates ?? [];
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: list fetch failed", err);
|
||||
}
|
||||
if (metas.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-template-empty">${escapeHtml(isEN() ? "No templates yet." : "Noch keine Vorlagen.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = metas
|
||||
.map((m) => {
|
||||
const name = isEN() ? m.name_en : m.name_de;
|
||||
const firm = m.firm ? ` · ${escapeHtml(m.firm)}` : "";
|
||||
return `<li class="docforge-template-row" data-template-id="${escapeHtml(m.id)}">
|
||||
<span class="docforge-template-name">${escapeHtml(name)}</span>
|
||||
<span class="docforge-template-meta">v${m.version}${firm}</span>
|
||||
</li>`;
|
||||
})
|
||||
.join("");
|
||||
host.querySelectorAll<HTMLLIElement>(".docforge-template-row").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const id = li.dataset.templateId;
|
||||
if (id) void openTemplate(id);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function wireUploadForm(): void {
|
||||
const form = document.getElementById("docforge-upload-form") as HTMLFormElement | null;
|
||||
if (!form) return;
|
||||
form.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const status = document.getElementById("docforge-upload-status");
|
||||
const data = new FormData(form);
|
||||
setText(status, isEN() ? "Uploading…" : "Lädt hoch…");
|
||||
try {
|
||||
const res = await fetch("/api/admin/templates", { method: "POST", body: data });
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
const view = (await res.json()) as AuthoringView;
|
||||
setText(status, "");
|
||||
form.reset();
|
||||
await loadList();
|
||||
openView(view);
|
||||
} catch (err) {
|
||||
setText(status, (isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function openTemplate(id: string): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(id)}`, {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) return;
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
console.warn("templates-authoring: open failed", err);
|
||||
}
|
||||
}
|
||||
|
||||
function openView(view: AuthoringView): void {
|
||||
state.openID = view.template.id;
|
||||
state.activeSlotKey = null;
|
||||
state.selection = null;
|
||||
|
||||
const workspace = document.getElementById("docforge-workspace");
|
||||
if (workspace) workspace.hidden = false;
|
||||
|
||||
const title = document.getElementById("docforge-workspace-title");
|
||||
if (title) {
|
||||
const name = isEN() ? view.template.name_en : view.template.name_de;
|
||||
title.textContent = `${name} · v${view.template.version}`;
|
||||
}
|
||||
|
||||
renderPreview(view.preview_html);
|
||||
renderSlots(view.slots);
|
||||
renderPalette();
|
||||
setWorkspaceStatus("");
|
||||
}
|
||||
|
||||
function renderPreview(html: string): void {
|
||||
const host = document.getElementById("docforge-preview");
|
||||
if (!host) return;
|
||||
host.innerHTML = html;
|
||||
host.addEventListener("mouseup", onPreviewSelect);
|
||||
}
|
||||
|
||||
// onPreviewSelect captures a selection that lies entirely within one run
|
||||
// span; otherwise it clears the pending selection and hints.
|
||||
function onPreviewSelect(): void {
|
||||
const sel = window.getSelection();
|
||||
if (!sel || sel.isCollapsed || sel.rangeCount === 0) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const text = sel.toString();
|
||||
if (text === "") {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
const anchorRun = closestRun(sel.anchorNode);
|
||||
const focusRun = closestRun(sel.focusNode);
|
||||
if (!anchorRun || anchorRun !== focusRun) {
|
||||
state.selection = null;
|
||||
setWorkspaceStatus(isEN()
|
||||
? "Select within a single text span."
|
||||
: "Bitte innerhalb einer Textstelle markieren.");
|
||||
return;
|
||||
}
|
||||
const runIndex = Number(anchorRun.dataset.run);
|
||||
if (Number.isNaN(runIndex)) {
|
||||
state.selection = null;
|
||||
return;
|
||||
}
|
||||
state.selection = { runIndex, text };
|
||||
setWorkspaceStatus(state.activeSlotKey
|
||||
? (isEN() ? `Click to bind “${text}” → ${state.activeSlotKey}` : `Variable wählen, um „${text}“ zu setzen`)
|
||||
: (isEN() ? `Selected “${text}” — now pick a variable.` : `„${text}" markiert — jetzt Variable wählen.`));
|
||||
}
|
||||
|
||||
function closestRun(node: Node | null): HTMLElement | null {
|
||||
let el: Node | null = node;
|
||||
while (el && el !== document.body) {
|
||||
if (el instanceof HTMLElement && el.classList.contains("docforge-run")) return el;
|
||||
el = el.parentNode;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// renderPalette groups catalogue entries by their namespace group and wires
|
||||
// each as a click-to-place control.
|
||||
function renderPalette(): void {
|
||||
const host = document.getElementById("docforge-palette");
|
||||
if (!host) return;
|
||||
if (state.catalogue.length === 0) {
|
||||
host.innerHTML = `<p class="docforge-palette-empty">${escapeHtml(isEN() ? "No variables." : "Keine Variablen.")}</p>`;
|
||||
return;
|
||||
}
|
||||
const groups = new Map<string, VariableEntry[]>();
|
||||
for (const e of state.catalogue) {
|
||||
const arr = groups.get(e.group) ?? [];
|
||||
arr.push(e);
|
||||
groups.set(e.group, arr);
|
||||
}
|
||||
let html = `<h3>${escapeHtml(isEN() ? "Variables" : "Variablen")}</h3>`;
|
||||
for (const [group, entries] of groups) {
|
||||
html += `<div class="docforge-palette-group"><h4>${escapeHtml(group)}</h4>`;
|
||||
for (const e of entries) {
|
||||
html += `<button type="button" class="docforge-palette-var" data-slot-key="${escapeHtml(e.key)}" title="{{${escapeHtml(e.key)}}}">${escapeHtml(labelOf(e))}</button>`;
|
||||
}
|
||||
html += `</div>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
host.querySelectorAll<HTMLButtonElement>(".docforge-palette-var").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onPaletteClick(btn.dataset.slotKey ?? "", btn));
|
||||
});
|
||||
}
|
||||
|
||||
function onPaletteClick(slotKey: string, btn: HTMLButtonElement): void {
|
||||
state.activeSlotKey = slotKey;
|
||||
const host = document.getElementById("docforge-palette");
|
||||
host?.querySelectorAll(".docforge-palette-var--active").forEach((el) => el.classList.remove("docforge-palette-var--active"));
|
||||
btn.classList.add("docforge-palette-var--active");
|
||||
|
||||
if (state.selection) {
|
||||
void placeSlot(state.selection.runIndex, state.selection.text, slotKey);
|
||||
} else {
|
||||
setWorkspaceStatus(isEN()
|
||||
? `${slotKey} selected — now highlight the text to replace.`
|
||||
: `${slotKey} gewählt — jetzt den zu ersetzenden Text markieren.`);
|
||||
}
|
||||
}
|
||||
|
||||
async function placeSlot(runIndex: number, selectedText: string, slotKey: string): Promise<void> {
|
||||
if (!state.openID) return;
|
||||
setWorkspaceStatus(isEN() ? "Placing…" : "Setze…");
|
||||
try {
|
||||
const res = await fetch(`/api/admin/templates/${encodeURIComponent(state.openID)}/slots`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ run_index: runIndex, selected_text: selectedText, slot_key: slotKey }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.json().catch(() => ({ error: `HTTP ${res.status}` }));
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + (body.error ?? res.status));
|
||||
return;
|
||||
}
|
||||
openView((await res.json()) as AuthoringView);
|
||||
} catch (err) {
|
||||
setWorkspaceStatus((isEN() ? "Error: " : "Fehler: ") + String(err));
|
||||
}
|
||||
}
|
||||
|
||||
function renderSlots(slots: TemplateSlot[]): void {
|
||||
const host = document.getElementById("docforge-slot-list");
|
||||
if (!host) return;
|
||||
if (slots.length === 0) {
|
||||
host.innerHTML = `<li class="docforge-slot-empty">${escapeHtml(isEN() ? "No slots yet." : "Noch keine Platzhalter.")}</li>`;
|
||||
return;
|
||||
}
|
||||
host.innerHTML = slots
|
||||
.map((s) => `<li class="docforge-slot-row" data-slot="${escapeHtml(s.key)}"><code>{{${escapeHtml(s.key)}}}</code></li>`)
|
||||
.join("");
|
||||
}
|
||||
|
||||
function setWorkspaceStatus(msg: string): void {
|
||||
setText(document.getElementById("docforge-workspace-status"), msg);
|
||||
}
|
||||
|
||||
function setText(el: Element | null, msg: string): void {
|
||||
if (el) el.textContent = msg;
|
||||
}
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => void boot());
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,320 +0,0 @@
|
||||
// Per-event-card choice popover + chip indicator (t-paliad-265 /
|
||||
// m/paliad#96).
|
||||
//
|
||||
// The shared rendering core (verfahrensablauf-core.ts) emits a caret
|
||||
// button on cards that carry a non-empty `choices_offered` declaration
|
||||
// and an inert chip span next to the title. This module:
|
||||
//
|
||||
// 1. Wires a delegated click handler on the result container so the
|
||||
// caret opens a popover with the offered choice-kinds.
|
||||
// 2. Commits the user's pick — either by POSTing to the project-
|
||||
// bound endpoint or by mutating the in-memory state for the
|
||||
// unbound (no-project) case.
|
||||
// 3. Rehydrates the chip on every render + after every commit so the
|
||||
// glanceable indicator matches the active state.
|
||||
//
|
||||
// Two consumer pages — /tools/verfahrensablauf (unbound) and
|
||||
// /tools/fristenrechner (project-bound) — both wire this module
|
||||
// once at boot via attachEventCardChoices().
|
||||
|
||||
import { escAttr, escHtml } from "./verfahrensablauf-core";
|
||||
import { t } from "../i18n";
|
||||
|
||||
export type ChoiceKind = "appellant" | "include_ccr" | "skip";
|
||||
|
||||
export interface EventChoice {
|
||||
submission_code: string;
|
||||
choice_kind: ChoiceKind;
|
||||
choice_value: string;
|
||||
}
|
||||
|
||||
// State surface — the page passes in callbacks that own persistence.
|
||||
// commit / remove must trigger a recalc on the page side (the popover
|
||||
// only owns its own visual state).
|
||||
export interface EventCardChoicesOpts {
|
||||
container: HTMLElement;
|
||||
// Initial state: a list of choices. The page seeds this from the
|
||||
// server response (project-bound) or from URL params (unbound).
|
||||
initial: EventChoice[];
|
||||
// commit gets called for an UPSERT. The page POSTs to the API (or
|
||||
// mutates URL state) AND triggers a recalc.
|
||||
commit: (choice: EventChoice) => Promise<void> | void;
|
||||
// remove gets called when the user resets a choice.
|
||||
remove: (submissionCode: string, kind: ChoiceKind) => Promise<void> | void;
|
||||
}
|
||||
|
||||
// One mutable bag per attach() call. The current implementation is a
|
||||
// single-page singleton — paginated views (admin tables) are not in
|
||||
// scope. Last-write-wins on the in-memory state.
|
||||
interface AttachedState {
|
||||
opts: EventCardChoicesOpts;
|
||||
// active: submission_code → kind → value. Rebuilt from `initial`
|
||||
// on every reseed() call.
|
||||
active: Map<string, Map<ChoiceKind, string>>;
|
||||
popover: HTMLDivElement | null;
|
||||
}
|
||||
|
||||
const states = new WeakMap<HTMLElement, AttachedState>();
|
||||
|
||||
// attachEventCardChoices wires the delegated click + popover lifecycle
|
||||
// to the given container. Call once per page after mount; safe to call
|
||||
// again with a fresh container.
|
||||
export function attachEventCardChoices(opts: EventCardChoicesOpts): void {
|
||||
const state: AttachedState = {
|
||||
opts,
|
||||
active: new Map(),
|
||||
popover: null,
|
||||
};
|
||||
for (const c of opts.initial) {
|
||||
if (!state.active.has(c.submission_code)) {
|
||||
state.active.set(c.submission_code, new Map());
|
||||
}
|
||||
state.active.get(c.submission_code)!.set(c.choice_kind, c.choice_value);
|
||||
}
|
||||
states.set(opts.container, state);
|
||||
|
||||
opts.container.addEventListener("click", (e) => {
|
||||
const targetEl = e.target as HTMLElement | null;
|
||||
const caret = targetEl?.closest<HTMLElement>(".event-card-choices-caret");
|
||||
if (caret) {
|
||||
e.stopPropagation();
|
||||
openPopover(state, caret);
|
||||
return;
|
||||
}
|
||||
// Outside-click closes the popover.
|
||||
if (state.popover && !state.popover.contains(e.target as Node)) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// ESC also closes.
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.popover) {
|
||||
closePopover(state);
|
||||
}
|
||||
});
|
||||
|
||||
// Repaint chips on every renderResults() call. The page is
|
||||
// responsible for calling reseedChips() after re-render so the chip
|
||||
// dom node (re-created by the renderer) picks the active state up.
|
||||
reseedChips(opts.container);
|
||||
}
|
||||
|
||||
// reseedChips walks every chip span in the container and re-renders
|
||||
// its content from the active state map. Idempotent.
|
||||
export function reseedChips(container: HTMLElement): void {
|
||||
const state = states.get(container);
|
||||
if (!state) return;
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const kinds = state.active.get(code);
|
||||
if (!kinds || kinds.size === 0) {
|
||||
chip.innerHTML = "";
|
||||
chip.dataset.empty = "true";
|
||||
return;
|
||||
}
|
||||
chip.dataset.empty = "false";
|
||||
chip.innerHTML = renderChip(kinds);
|
||||
});
|
||||
// Skipped rows fade out via a class on the card-item ancestor.
|
||||
container.querySelectorAll<HTMLElement>(".event-card-choices-chip").forEach((chip) => {
|
||||
const code = chip.dataset.submissionCode || "";
|
||||
const skipped = state.active.get(code)?.get("skip") === "true";
|
||||
const itemEl = chip.closest<HTMLElement>(".timeline-item, .fr-col-item");
|
||||
if (itemEl) itemEl.classList.toggle("timeline-item--skipped", skipped);
|
||||
});
|
||||
}
|
||||
|
||||
function renderChip(kinds: Map<ChoiceKind, string>): string {
|
||||
const parts: string[] = [];
|
||||
if (kinds.get("skip") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part event-card-choices-chip-part--skipped">${escHtml(t("choices.skipped.chip"))}</span>`);
|
||||
}
|
||||
const ap = kinds.get("appellant");
|
||||
if (ap && ap !== "" ) {
|
||||
let label = "";
|
||||
switch (ap) {
|
||||
case "claimant": label = t("choices.appellant.claimant"); break;
|
||||
case "defendant": label = t("choices.appellant.defendant"); break;
|
||||
case "both": label = t("choices.appellant.both"); break;
|
||||
case "none": label = t("choices.appellant.none"); break;
|
||||
}
|
||||
if (label) {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.appellant.chip"))} ${escHtml(label)}</span>`);
|
||||
}
|
||||
}
|
||||
if (kinds.get("include_ccr") === "true") {
|
||||
parts.push(`<span class="event-card-choices-chip-part">${escHtml(t("choices.include_ccr.chip"))}</span>`);
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
|
||||
function openPopover(state: AttachedState, caret: HTMLElement): void {
|
||||
closePopover(state);
|
||||
const code = caret.dataset.submissionCode || "";
|
||||
if (!code) return;
|
||||
let offered: Record<string, unknown> = {};
|
||||
try {
|
||||
offered = JSON.parse(caret.dataset.choicesOffered || "{}");
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const isHidden = caret.dataset.isHidden === "1";
|
||||
|
||||
const pop = document.createElement("div");
|
||||
pop.className = "event-card-choices-popover";
|
||||
pop.setAttribute("role", "dialog");
|
||||
pop.setAttribute("aria-label", t("choices.caret.title"));
|
||||
|
||||
const blocks: string[] = [];
|
||||
// t-paliad-293: hidden-card prominence. When the user opens the
|
||||
// popover on a re-surfaced hidden card, "Wieder einblenden" is the
|
||||
// most likely intent — surface it as a single high-contrast action
|
||||
// at the top of the popover (rather than burying it under the skip
|
||||
// toggle's reset link). Clicking it clears the `skip` choice, which
|
||||
// is the same wire effect as the legacy inline chip from t-paliad-290.
|
||||
if (isHidden) {
|
||||
blocks.push(renderUnhideBlock());
|
||||
}
|
||||
if (Array.isArray(offered.appellant)) {
|
||||
blocks.push(renderAppellantBlock(state, code, offered.appellant as unknown[]));
|
||||
}
|
||||
if (Array.isArray(offered.include_ccr)) {
|
||||
blocks.push(renderToggleBlock(state, code, "include_ccr"));
|
||||
}
|
||||
if (Array.isArray(offered.skip)) {
|
||||
blocks.push(renderToggleBlock(state, code, "skip"));
|
||||
}
|
||||
pop.innerHTML = blocks.join("");
|
||||
|
||||
document.body.appendChild(pop);
|
||||
state.popover = pop;
|
||||
positionPopover(pop, caret);
|
||||
|
||||
pop.addEventListener("click", async (e) => {
|
||||
const btn = (e.target as HTMLElement | null)?.closest<HTMLButtonElement>("button[data-choice-action]");
|
||||
if (!btn) return;
|
||||
e.stopPropagation();
|
||||
const kind = btn.dataset.choiceKind as ChoiceKind | undefined;
|
||||
const value = btn.dataset.choiceValue || "";
|
||||
const action = btn.dataset.choiceAction;
|
||||
if (!kind) return;
|
||||
try {
|
||||
if (action === "set") {
|
||||
await state.opts.commit({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
if (!state.active.has(code)) state.active.set(code, new Map());
|
||||
state.active.get(code)!.set(kind, value);
|
||||
} else if (action === "clear") {
|
||||
await state.opts.remove(code, kind);
|
||||
state.active.get(code)?.delete(kind);
|
||||
}
|
||||
reseedChips(state.opts.container);
|
||||
closePopover(state);
|
||||
} catch (err) {
|
||||
console.error("event card choice commit failed", err);
|
||||
// Surface a soft inline error inside the popover; do NOT close.
|
||||
const errEl = document.createElement("div");
|
||||
errEl.className = "event-card-choices-error";
|
||||
errEl.textContent = t("choices.commit.error");
|
||||
pop.appendChild(errEl);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function renderAppellantBlock(state: AttachedState, code: string, values: unknown[]): string {
|
||||
const current = state.active.get(code)?.get("appellant") || "";
|
||||
const buttons = values
|
||||
.filter((v): v is string => typeof v === "string")
|
||||
.map((v) => {
|
||||
const labelKey = `choices.appellant.${v}` as const;
|
||||
const isActive = v === current;
|
||||
return `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="appellant"
|
||||
data-choice-value="${escAttr(v)}"
|
||||
class="event-card-choices-option${isActive ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
})
|
||||
.join("");
|
||||
const reset = current
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="appellant"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t("choices.appellant.title"))}</div>
|
||||
<div class="event-card-choices-options">${buttons}</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderToggleBlock(state: AttachedState, code: string, kind: "include_ccr" | "skip"): string {
|
||||
const current = state.active.get(code)?.get(kind) || "false";
|
||||
const titleKey = kind === "include_ccr" ? "choices.include_ccr.title" : "choices.skip.title";
|
||||
const trueKey = kind === "include_ccr" ? "choices.include_ccr.true" : "choices.skip.true";
|
||||
const falseKey = kind === "include_ccr" ? "choices.include_ccr.false" : "choices.skip.false";
|
||||
const opt = (v: "true" | "false", labelKey: string) => `<button type="button"
|
||||
data-choice-action="set"
|
||||
data-choice-kind="${kind}"
|
||||
data-choice-value="${v}"
|
||||
class="event-card-choices-option${v === current ? " event-card-choices-option--active" : ""}">${escHtml(t(labelKey as any))}</button>`;
|
||||
const reset = state.active.get(code)?.has(kind)
|
||||
? `<button type="button" data-choice-action="clear" data-choice-kind="${kind}"
|
||||
class="event-card-choices-reset">${escHtml(t("choices.reset"))}</button>`
|
||||
: "";
|
||||
return `<div class="event-card-choices-block">
|
||||
<div class="event-card-choices-title">${escHtml(t(titleKey as any))}</div>
|
||||
<div class="event-card-choices-options">
|
||||
${opt("true", trueKey)}
|
||||
${opt("false", falseKey)}
|
||||
</div>
|
||||
${reset}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// renderUnhideBlock is the popover's prominent "Wieder einblenden"
|
||||
// action — surfaced only when the caret is opened on a re-surfaced
|
||||
// hidden card (data-is-hidden="1" on the caret). Clicking it dispatches
|
||||
// the same `clear` action as the skip-block reset link below, but
|
||||
// labelled in the user's terms ("restore this card" rather than
|
||||
// "reset skip choice"). Drops out of the popover automatically on
|
||||
// non-hidden cards so the popover stays minimal. (t-paliad-293)
|
||||
function renderUnhideBlock(): string {
|
||||
const label = t("choices.unhide.chip");
|
||||
return `<div class="event-card-choices-block event-card-choices-block--unhide">
|
||||
<button type="button"
|
||||
data-choice-action="clear"
|
||||
data-choice-kind="skip"
|
||||
class="event-card-choices-unhide-btn">${escHtml(label)}</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function closePopover(state: AttachedState): void {
|
||||
if (state.popover) {
|
||||
state.popover.remove();
|
||||
state.popover = null;
|
||||
}
|
||||
}
|
||||
|
||||
function positionPopover(pop: HTMLDivElement, caret: HTMLElement): void {
|
||||
const rect = caret.getBoundingClientRect();
|
||||
const scrollY = window.scrollY || document.documentElement.scrollTop;
|
||||
const scrollX = window.scrollX || document.documentElement.scrollLeft;
|
||||
pop.style.position = "absolute";
|
||||
pop.style.top = `${rect.bottom + scrollY + 4}px`;
|
||||
pop.style.left = `${Math.max(8, rect.right + scrollX - 240)}px`;
|
||||
pop.style.zIndex = "1000";
|
||||
}
|
||||
|
||||
// Returns the current in-memory choice list for the given container —
|
||||
// used by the unbound /tools/verfahrensablauf page to keep the URL
|
||||
// param in sync.
|
||||
export function currentChoices(container: HTMLElement): EventChoice[] {
|
||||
const state = states.get(container);
|
||||
if (!state) return [];
|
||||
const out: EventChoice[] = [];
|
||||
state.active.forEach((kinds, code) => {
|
||||
kinds.forEach((value, kind) => {
|
||||
out.push({ submission_code: code, choice_kind: kind, choice_value: value });
|
||||
});
|
||||
});
|
||||
return out;
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
||||
import {
|
||||
type CalculatedDeadline,
|
||||
type DeadlineResponse,
|
||||
bucketDeadlinesIntoColumns,
|
||||
calculateDeadlines,
|
||||
deadlineCardHtml,
|
||||
formatDurationLabel,
|
||||
renderColumnsBody,
|
||||
@@ -773,3 +774,81 @@ describe("stripLeadingDurationFromNotes — render-side dedup (t-paliad-307)", (
|
||||
.toBe("Time limit set by the court");
|
||||
});
|
||||
});
|
||||
|
||||
// Pin the engine-options plumbing surface (t-paliad-348 / yoUPC#178).
|
||||
// calculateDeadlines must forward `includeOptional` and
|
||||
// `triggerEventAnchors` straight into the POST body so the Go handler
|
||||
// (handleFristenrechnerAPI) can pass them into lp.CalcOptions. If a
|
||||
// future refactor drops the fields, the Builder triplet silently
|
||||
// reverts to "engine emits optional rules" and the unified
|
||||
// /tools/procedures page loses its naked-proceeding default.
|
||||
describe("calculateDeadlines — forwards engine options into request body", () => {
|
||||
type CapturedRequest = { url: string; body: Record<string, unknown> };
|
||||
let captured: CapturedRequest | null;
|
||||
let originalFetch: typeof globalThis.fetch;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
originalFetch = globalThis.fetch;
|
||||
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const body = typeof init?.body === "string" ? JSON.parse(init.body) : {};
|
||||
captured = { url: String(input), body };
|
||||
return new Response(JSON.stringify({
|
||||
proceedingType: "x", proceedingName: "x", triggerDate: "2026-01-01", deadlines: [],
|
||||
}), { status: 200, headers: { "Content-Type": "application/json" } });
|
||||
}) as typeof globalThis.fetch;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.fetch = originalFetch;
|
||||
});
|
||||
|
||||
test("default call omits includeOptional and triggerEventAnchors", async () => {
|
||||
await calculateDeadlines({ proceedingType: "upc.inf.cfi", triggerDate: "2026-05-26" });
|
||||
expect(captured).not.toBeNull();
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
|
||||
test("includeOptional=true sends includeOptional: true", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: true,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBe(true);
|
||||
});
|
||||
|
||||
test("includeOptional=false is omitted (matches engine default)", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
includeOptional: false,
|
||||
});
|
||||
expect(captured!.body.includeOptional).toBeUndefined();
|
||||
});
|
||||
|
||||
test("triggerEventAnchors forwarded as object", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toEqual({
|
||||
"upc.inf.cfi.oral": "2026-09-01",
|
||||
"upc.inf.cfi.decision": "2026-12-15",
|
||||
});
|
||||
});
|
||||
|
||||
test("empty triggerEventAnchors is omitted", async () => {
|
||||
await calculateDeadlines({
|
||||
proceedingType: "upc.inf.cfi",
|
||||
triggerDate: "2026-05-26",
|
||||
triggerEventAnchors: {},
|
||||
});
|
||||
expect(captured!.body.triggerEventAnchors).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -271,6 +271,12 @@ export interface DeadlineResponse {
|
||||
// when the toggle is OFF — so users know there's something to
|
||||
// re-surface.
|
||||
hiddenCount?: number;
|
||||
// rulesAwaitingAnchor (t-paliad-348 / yoUPC#178): number of rules the
|
||||
// engine suppressed because their `trigger_event_id` anchor wasn't
|
||||
// supplied via CalcParams.triggerEventAnchors. Mirrors the Go
|
||||
// Timeline.RulesAwaitingAnchor counter — a single integer surface for
|
||||
// "N rules waiting on an anchor" UI affordances.
|
||||
rulesAwaitingAnchor?: number;
|
||||
}
|
||||
|
||||
export interface CourtRow {
|
||||
@@ -311,6 +317,20 @@ export interface CalcParams {
|
||||
// endentscheidung | kostenentscheidung | anordnung |
|
||||
// schadensbemessung | bucheinsicht.
|
||||
appealTarget?: string;
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new CalcOptions
|
||||
// axes to the HTTP boundary:
|
||||
//
|
||||
// includeOptional: when true, the engine returns priority='optional'
|
||||
// rules in the timeline. Default false matches the engine default
|
||||
// (mandatory backbone only). The /tools/procedures detailgrad
|
||||
// toggle ("all_options" mode) drives this to true so the dimmed
|
||||
// optional cards can be rendered for the lawyer to opt into.
|
||||
// triggerEventAnchors: per-event-code anchor dates the engine
|
||||
// consults for rules carrying trigger_event_id. Empty/omitted =
|
||||
// no anchors → such rules render as IsConditional (the engine
|
||||
// refuses to fabricate a date off the proceeding's trigger date).
|
||||
includeOptional?: boolean;
|
||||
triggerEventAnchors?: Record<string, string>;
|
||||
}
|
||||
|
||||
const PARTY_CLASS: Record<string, string> = {
|
||||
@@ -1042,7 +1062,15 @@ export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts
|
||||
// timeline-item — dotted border + faded styling.
|
||||
dl.isConditional ? "fr-col-item--conditional" : "",
|
||||
].filter(Boolean).join(" ");
|
||||
return `<div class="${itemClasses}">
|
||||
// data-rule-id on the card root lets the Litigation Builder
|
||||
// overlay per-card state (planned/filed/skipped) + action
|
||||
// affordances onto cards rendered through this shared body
|
||||
// without re-implementing the columns renderer. Empty on
|
||||
// synthetic rows (appeal trigger marker etc.); the Builder
|
||||
// skips state lookup when missing.
|
||||
const ruleIdAttr = dl.ruleId ? ` data-rule-id="${escAttr(dl.ruleId)}"` : "";
|
||||
const submissionCodeAttr = dl.code ? ` data-submission-code="${escAttr(dl.code)}"` : "";
|
||||
return `<div class="${itemClasses}"${ruleIdAttr}${submissionCodeAttr}>
|
||||
${deadlineCardHtml(dl, cardOpts)}
|
||||
${mirrorTag}
|
||||
</div>`;
|
||||
@@ -1110,6 +1138,10 @@ export async function calculateDeadlines(params: CalcParams): Promise<DeadlineRe
|
||||
: undefined,
|
||||
includeHidden: params.includeHidden ? true : undefined,
|
||||
appealTarget: params.appealTarget || undefined,
|
||||
includeOptional: params.includeOptional ? true : undefined,
|
||||
triggerEventAnchors: params.triggerEventAnchors && Object.keys(params.triggerEventAnchors).length > 0
|
||||
? params.triggerEventAnchors
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -728,6 +728,138 @@ export type I18nKey =
|
||||
| "bottomnav.add.title"
|
||||
| "bottomnav.badge.deadlines"
|
||||
| "bottomnav.menu"
|
||||
| "builder.action.promote"
|
||||
| "builder.action.rename"
|
||||
| "builder.action.rename.prompt"
|
||||
| "builder.action.share"
|
||||
| "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"
|
||||
| "builder.empty.hint"
|
||||
| "builder.empty.recent"
|
||||
| "builder.event.action.file"
|
||||
| "builder.event.action.reset"
|
||||
| "builder.event.action.skip"
|
||||
| "builder.event.actual_date.prompt"
|
||||
| "builder.event.horizon.hide"
|
||||
| "builder.event.horizon.label"
|
||||
| "builder.event.skip_reason.prompt"
|
||||
| "builder.event.state.filed"
|
||||
| "builder.event.state.planned"
|
||||
| "builder.event.state.skipped"
|
||||
| "builder.header.akte"
|
||||
| "builder.header.scenario"
|
||||
| "builder.header.search"
|
||||
| "builder.header.stichtag"
|
||||
| "builder.mobile.blocked"
|
||||
| "builder.mode.akte"
|
||||
| "builder.mode.cold"
|
||||
| "builder.mode.event"
|
||||
| "builder.panel.empty"
|
||||
| "builder.panel.new"
|
||||
| "builder.panel.title"
|
||||
| "builder.picker.aria"
|
||||
| "builder.picker.axis.forum"
|
||||
| "builder.picker.axis.proc"
|
||||
| "builder.picker.close"
|
||||
| "builder.picker.empty"
|
||||
| "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"
|
||||
| "builder.save.saving"
|
||||
| "builder.search.anchor.divider"
|
||||
| "builder.search.group.events"
|
||||
| "builder.search.group.projects"
|
||||
| "builder.search.group.scenarios"
|
||||
| "builder.search.hint.akte_b4"
|
||||
| "builder.search.hint.empty"
|
||||
| "builder.search.hint.error"
|
||||
| "builder.search.hint.loading"
|
||||
| "builder.search.hint.short"
|
||||
| "builder.search.hint.start"
|
||||
| "builder.search.placeholder"
|
||||
| "builder.search.summary.events.one"
|
||||
| "builder.search.summary.events.other"
|
||||
| "builder.search.summary.projects.one"
|
||||
| "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"
|
||||
| "builder.triplet.detailgrad.label"
|
||||
| "builder.triplet.detailgrad.selected"
|
||||
| "builder.triplet.expand"
|
||||
| "builder.triplet.flags.label"
|
||||
| "builder.triplet.loading"
|
||||
| "builder.triplet.no_flags"
|
||||
| "builder.triplet.perspective.claimant"
|
||||
| "builder.triplet.perspective.defendant"
|
||||
| "builder.triplet.perspective.label"
|
||||
| "builder.triplet.perspective.none"
|
||||
| "builder.triplet.remove"
|
||||
| "builder.triplet.side.claimant"
|
||||
| "builder.triplet.side.defendant"
|
||||
| "builder.triplet.unknown_proceeding"
|
||||
| "cal.day.back_to_month"
|
||||
| "cal.day.fri"
|
||||
| "cal.day.mon"
|
||||
@@ -1630,6 +1762,23 @@ export type I18nKey =
|
||||
| "einstellungen.export.what"
|
||||
| "einstellungen.heading"
|
||||
| "einstellungen.loading"
|
||||
| "einstellungen.names.error.invalid"
|
||||
| "einstellungen.names.error.load"
|
||||
| "einstellungen.names.firm.clear"
|
||||
| "einstellungen.names.firm.cleared"
|
||||
| "einstellungen.names.firm.heading"
|
||||
| "einstellungen.names.firm.saved"
|
||||
| "einstellungen.names.firm.set"
|
||||
| "einstellungen.names.firm.status_set"
|
||||
| "einstellungen.names.firm.status_unset"
|
||||
| "einstellungen.names.firm_badge"
|
||||
| "einstellungen.names.override_badge"
|
||||
| "einstellungen.names.preview.empty"
|
||||
| "einstellungen.names.preview.sample"
|
||||
| "einstellungen.names.reset"
|
||||
| "einstellungen.names.reset_done"
|
||||
| "einstellungen.names.saved"
|
||||
| "einstellungen.names.subtitle"
|
||||
| "einstellungen.optional"
|
||||
| "einstellungen.prefs.escalation.default_option"
|
||||
| "einstellungen.prefs.escalation.heading"
|
||||
@@ -1672,6 +1821,7 @@ export type I18nKey =
|
||||
| "einstellungen.tab.benachrichtigungen"
|
||||
| "einstellungen.tab.caldav"
|
||||
| "einstellungen.tab.export"
|
||||
| "einstellungen.tab.names"
|
||||
| "einstellungen.tab.profil"
|
||||
| "einstellungen.title"
|
||||
| "event.description.appointment_approval_approved"
|
||||
@@ -2467,6 +2617,7 @@ export type I18nKey =
|
||||
| "projects.detail.submissions.action.edit"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.action.open"
|
||||
| "projects.detail.submissions.col.action"
|
||||
| "projects.detail.submissions.col.name"
|
||||
| "projects.detail.submissions.col.party"
|
||||
@@ -2709,7 +2860,12 @@ export type I18nKey =
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.base.hint"
|
||||
| "submissions.draft.base.label"
|
||||
| "submissions.draft.base.preview"
|
||||
| "submissions.draft.base.preview.soon"
|
||||
| "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"
|
||||
@@ -2809,6 +2965,18 @@ export type I18nKey =
|
||||
| "team.selection.toggle_card"
|
||||
| "team.subtitle"
|
||||
| "team.title"
|
||||
| "templates.authoring.heading"
|
||||
| "templates.authoring.intro"
|
||||
| "templates.authoring.list.title"
|
||||
| "templates.authoring.slots.title"
|
||||
| "templates.authoring.title"
|
||||
| "templates.authoring.upload.file"
|
||||
| "templates.authoring.upload.firm"
|
||||
| "templates.authoring.upload.name_de"
|
||||
| "templates.authoring.upload.name_en"
|
||||
| "templates.authoring.upload.submit"
|
||||
| "templates.authoring.upload.title"
|
||||
| "templates.authoring.workspace.hint"
|
||||
| "theme.toggle.auto"
|
||||
| "theme.toggle.cycle.auto"
|
||||
| "theme.toggle.cycle.dark"
|
||||
|
||||
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
43
frontend/src/lib/docforge-editor/catalogue.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
// docforge-editor — the variable catalogue client.
|
||||
//
|
||||
// The catalogue (key + bilingual label + namespace group) is served by the
|
||||
// Go backend at GET /api/docforge/variables, built from the resolvers'
|
||||
// Keys() as the single source of truth. A consumer fetches it once and uses
|
||||
// labelMap() to label its sidebar form + authoring palette, instead of
|
||||
// hard-coding a parallel label table that can drift from the resolvers.
|
||||
|
||||
export interface VariableEntry {
|
||||
key: string;
|
||||
label_de: string;
|
||||
label_en: string;
|
||||
group: string;
|
||||
}
|
||||
|
||||
interface VariablesResponse {
|
||||
variables: VariableEntry[];
|
||||
}
|
||||
|
||||
// fetchVariableCatalogue loads the catalogue from the backend. Throws on a
|
||||
// non-2xx response so the caller can decide how to degrade.
|
||||
export async function fetchVariableCatalogue(): Promise<VariableEntry[]> {
|
||||
const res = await fetch("/api/docforge/variables", {
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`docforge variables: HTTP ${res.status}`);
|
||||
}
|
||||
const body = (await res.json()) as VariablesResponse;
|
||||
return body.variables ?? [];
|
||||
}
|
||||
|
||||
// labelMap turns a catalogue into a key → {de, en} lookup for a label
|
||||
// function. Keys absent from the map fall back to the raw key at the call
|
||||
// site, so a failed fetch degrades to dotted-key labels rather than a
|
||||
// broken form.
|
||||
export function labelMap(catalogue: VariableEntry[]): Record<string, { de: string; en: string }> {
|
||||
const out: Record<string, { de: string; en: string }> = {};
|
||||
for (const e of catalogue) {
|
||||
out[e.key] = { de: e.label_de, en: e.label_en };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
26
frontend/src/lib/docforge-editor/dom.test.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { test, expect } from "bun:test";
|
||||
import { escapeHtml, cssEscape } from "./dom";
|
||||
|
||||
test("escapeHtml escapes the five HTML-significant characters", () => {
|
||||
expect(escapeHtml(`<a href="x" title='y'>& z</a>`)).toBe(
|
||||
"<a href="x" title='y'>& z</a>",
|
||||
);
|
||||
});
|
||||
|
||||
test("escapeHtml is a no-op on plain text", () => {
|
||||
expect(escapeHtml("Aktenzeichen 4c O 12/23")).toBe("Aktenzeichen 4c O 12/23");
|
||||
});
|
||||
|
||||
test("escapeHtml escapes & first to avoid double-encoding", () => {
|
||||
expect(escapeHtml("<")).toBe("&lt;");
|
||||
});
|
||||
|
||||
test("cssEscape backslash-escapes the dots in a placeholder key", () => {
|
||||
// Both CSS.escape and the regex fallback escape '.' the same way, so the
|
||||
// result is stable across environments (bun has no CSS global → fallback).
|
||||
expect(cssEscape("project.case_number")).toBe("project\\.case_number");
|
||||
});
|
||||
|
||||
test("cssEscape leaves identifier-safe characters untouched", () => {
|
||||
expect(cssEscape("today")).toBe("today");
|
||||
});
|
||||
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
32
frontend/src/lib/docforge-editor/dom.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// docforge-editor — shared, framework-agnostic editor utilities.
|
||||
//
|
||||
// Slice 5 of the docforge train (t-paliad-349 / m/paliad#157) begins
|
||||
// extracting the generic editor plumbing out of the submission-specific
|
||||
// client bundle so a second consumer (and the slice-6 authoring page) can
|
||||
// reuse it. This module holds the pure DOM-string helpers — no DOM
|
||||
// mutation, no editor state — so they unit-test cleanly under bun.
|
||||
|
||||
// escapeHtml escapes the five HTML-significant characters for safe
|
||||
// insertion into element text or an attribute value. Matches the
|
||||
// server-side emitTextWithDraftVars/htmlEscape contract so preview markup
|
||||
// round-trips identically.
|
||||
export function escapeHtml(s: string): string {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
// cssEscape escapes a string for use inside a CSS attribute selector
|
||||
// (e.g. `[data-var="${cssEscape(key)}"]`). Prefers the native CSS.escape
|
||||
// and falls back to escaping CSS-special characters for older runtimes.
|
||||
// Placeholder keys ([A-Za-z][A-Za-z0-9_.]*) never carry whitespace or
|
||||
// quotes, so the fallback is straightforward.
|
||||
export function cssEscape(s: string): string {
|
||||
if (typeof CSS !== "undefined" && typeof CSS.escape === "function") {
|
||||
return CSS.escape(s);
|
||||
}
|
||||
return s.replace(/([!"#$%&'()*+,./:;<=>?@[\\\]^`{|}~])/g, "\\$1");
|
||||
}
|
||||
@@ -4,23 +4,19 @@ import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
import { VerfahrensablaufBody } from "./components/VerfahrensablaufBody";
|
||||
|
||||
// U0 — Skeleton for the unified procedural-events tool
|
||||
// (m/paliad#151, design docs/design-unified-procedural-events-tool-2026-05-27.md).
|
||||
// /tools/procedures — Litigation Builder (m/paliad#153 PRD §3).
|
||||
//
|
||||
// Folds /tools/fristenrechner (Mode A + Mode B + result) and
|
||||
// /tools/verfahrensablauf into a single page at /tools/procedures. Each
|
||||
// later slice fills one of the four entry tabs:
|
||||
// Replaces cronus's 4-tab catalog (U0-U4) with a persistence-backed
|
||||
// builder shell. Server-rendered chrome is minimal — the page-header
|
||||
// scenario picker, side panel, and canvas are all hydrated by
|
||||
// `builder.ts` at boot. The builder loads scenarios from
|
||||
// /api/builder/scenarios (B0 surface, t-paliad-340) and renders the
|
||||
// per-proceeding triplets with the existing verfahrensablauf-core calc.
|
||||
//
|
||||
// U1 — Direkt suchen (Mode A search)
|
||||
// U2 — Geführt (Mode B wizard)
|
||||
// U3 — Verfahren (Verfahrensablauf tree + 3-way detail filter)
|
||||
// U4 — Hard-cut 301 (drop legacy pages, redirect URLs)
|
||||
//
|
||||
// This file ships only the page chrome — sidebar, header, filter strip
|
||||
// with search box, four entry-mode tabs, and the host containers the
|
||||
// later slices mount their UI into. No data wiring.
|
||||
// B1 — Builder shell + cold-open mode + single triplet end-to-end.
|
||||
// B2 — Multi-triplet stack + spawn nesting + per-event state machine.
|
||||
// B3+ — event-triggered + Akte modes, sharing, promotion (head-gated).
|
||||
|
||||
export function renderProcedures(): string {
|
||||
const today = new Date().toISOString().split("T")[0];
|
||||
@@ -36,151 +32,153 @@ export function renderProcedures(): string {
|
||||
<title data-i18n="procedures.title">Verfahren & Fristen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-procedures">
|
||||
<body className="has-sidebar page-procedures page-builder">
|
||||
<Sidebar currentPath="/tools/procedures" />
|
||||
<BottomNav currentPath="/tools/procedures" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<section className="tool-page builder-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="procedures.heading">Verfahren & Fristen</h1>
|
||||
<p className="tool-subtitle" data-i18n="procedures.subtitle">
|
||||
Verfahrensablauf, Fristenrechner und gerührte Suche in einem Tool.
|
||||
<p className="tool-subtitle" data-i18n="builder.subtitle">
|
||||
Litigation Builder — Szenarien bauen, Verfahren stapeln, Fristen behalten.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Shared filter strip — search box + four chip groups
|
||||
(forum / proceeding / event_kind / party). Lives at the
|
||||
top of the page so every entry tab and output mode reads
|
||||
the same active filter set (design §4 + m's Q3
|
||||
divergence: search composes with chip filters). U0
|
||||
ships the markup only; chip hydration + search wiring
|
||||
arrive with U1-U3. */}
|
||||
<section className="procedures-filter-strip" aria-label="Filter">
|
||||
<div className="procedures-filter-search">
|
||||
<svg className="procedures-filter-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="procedures-search-input"
|
||||
className="procedures-filter-search-input"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
data-i18n-placeholder="procedures.filter.search.placeholder"
|
||||
placeholder="Klageerhebung, Hinweisbeschluss, oral hearing…"
|
||||
/>
|
||||
{/* Page header (PRD §3.1): scenario picker · save state · name · share · promote
|
||||
· Akte picker · Stichtag input. B1 wires the scenario picker
|
||||
+ name action + Stichtag + save indicator. Akte / share /
|
||||
promote land at B4 / B5; the affordances render disabled in
|
||||
B1 so the layout is stable across slices. */}
|
||||
<section className="builder-pageheader" aria-label="Builder-Steuerung">
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.scenario">Szenario:</span>
|
||||
<select id="builder-scenario-picker" className="builder-scenario-picker" aria-label="Szenario wählen"></select>
|
||||
</label>
|
||||
<span id="builder-save-status" className="builder-save-status" aria-live="polite" data-state="idle">
|
||||
<span data-i18n="builder.save.idle"> </span>
|
||||
</span>
|
||||
<span className="builder-pageheader-spacer"></span>
|
||||
<button type="button" id="builder-rename-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.rename">Benennen</button>
|
||||
<button type="button" id="builder-share-btn"
|
||||
className="builder-action-btn builder-action-btn--secondary"
|
||||
disabled
|
||||
data-i18n="builder.action.share">Teilen</button>
|
||||
<button type="button" id="builder-promote-btn"
|
||||
className="builder-action-btn builder-action-btn--primary"
|
||||
disabled
|
||||
data-i18n="builder.action.promote">Als Projekt anlegen</button>
|
||||
</div>
|
||||
<div className="procedures-filter-chips" id="procedures-filter-chips">
|
||||
<div className="procedures-filter-chip-row" data-axis="forum">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.forum">Forum:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-forum"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="proc">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.proc">Verfahren:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-proc"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="kind">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.kind">Ereignisart:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-kind"></div>
|
||||
</div>
|
||||
<div className="procedures-filter-chip-row" data-axis="party">
|
||||
<span className="procedures-filter-axis-label" data-i18n="procedures.filter.axis.party">Partei:</span>
|
||||
<div className="procedures-filter-chip-host" id="procedures-filter-chips-party"></div>
|
||||
</div>
|
||||
<div className="builder-pageheader-row">
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.akte">Akte:</span>
|
||||
<select id="builder-akte-picker" className="builder-akte-picker" disabled aria-label="Akte wählen">
|
||||
<option value="" data-i18n="builder.akte.none">— ohne —</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="builder-pageheader-field">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.stichtag">Stichtag:</span>
|
||||
<input type="date" id="builder-stichtag-input" className="builder-stichtag-input"
|
||||
defaultValue={today} aria-label="Stichtag" />
|
||||
</label>
|
||||
<label className="builder-pageheader-field builder-pageheader-field--grow">
|
||||
<span className="builder-pageheader-label" data-i18n="builder.header.search">Suche:</span>
|
||||
<input type="search" id="builder-search-input" className="builder-search-input"
|
||||
data-i18n-placeholder="builder.search.placeholder"
|
||||
placeholder="Ereignis, Szenario, Akte …"
|
||||
autocomplete="off" spellcheck="false" />
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Entry-mode tab strip — all four tabs visible from boot
|
||||
(m's Q3 divergence). The active tab is URL-driven
|
||||
(?mode=proceeding|search|wizard|akte); cold open lands
|
||||
on "proceeding" per design §11.5.Q3. */}
|
||||
<nav className="procedures-tabs" role="tablist" aria-label="Einstieg">
|
||||
{/* Entry-mode radio (PRD §0.2, §2). B1 ships cold-open active;
|
||||
event-triggered + akte ship at B3 / B4 and are disabled
|
||||
here so the layout stays stable across slices. */}
|
||||
<nav className="builder-modebar" role="tablist" aria-label="Einstieg">
|
||||
<button type="button"
|
||||
className="procedures-tab is-active"
|
||||
className="builder-mode is-active"
|
||||
role="tab"
|
||||
aria-selected="true"
|
||||
data-tab="proceeding"
|
||||
id="procedures-tab-proceeding">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📚</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.proceeding">Verfahren wählen</span>
|
||||
data-mode="cold"
|
||||
id="builder-mode-cold">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.cold">Übersicht</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="search"
|
||||
id="procedures-tab-search">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">⚡</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.search">Direkt suchen</span>
|
||||
data-mode="event"
|
||||
id="builder-mode-event">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.event">Ereignis</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
className="builder-mode"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="wizard"
|
||||
id="procedures-tab-wizard">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">🧭</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.wizard">Geführt</span>
|
||||
</button>
|
||||
<button type="button"
|
||||
className="procedures-tab"
|
||||
role="tab"
|
||||
aria-selected="false"
|
||||
data-tab="akte"
|
||||
id="procedures-tab-akte">
|
||||
<span className="procedures-tab-icon" aria-hidden="true">📁</span>
|
||||
<span className="procedures-tab-label" data-i18n="procedures.tab.akte">Aus Akte</span>
|
||||
data-mode="akte"
|
||||
id="builder-mode-akte">
|
||||
<span className="builder-mode-label" data-i18n="builder.mode.akte">Aus Akte</span>
|
||||
</button>
|
||||
</nav>
|
||||
|
||||
{/* Per-tab content hosts. Only one is visible at a time —
|
||||
procedures.ts toggles `hidden` on the inactive ones.
|
||||
Each later slice fills the corresponding host. */}
|
||||
<section className="procedures-panel" id="procedures-panel-proceeding" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-proceeding">
|
||||
{/* Verfahrensablauf wizard body — shared TSX component
|
||||
used by /tools/verfahrensablauf (legacy) and the
|
||||
unified /tools/procedures page. procedures.ts calls
|
||||
initVerfahrensablauf() on the first activation of
|
||||
this tab, which wires the .proceeding-btn clicks,
|
||||
timeline-container, detail-mode toggle, etc. against
|
||||
the markup. The legacy page's auto-boot is guarded
|
||||
against the procedures-only #procedures-panel-proceeding
|
||||
element so it doesn't fire twice. */}
|
||||
<VerfahrensablaufBody todayIso={today} />
|
||||
</section>
|
||||
{/* Two-column body: side panel (left, scenarios list) + canvas (right). */}
|
||||
<div className="builder-body">
|
||||
<aside className="builder-sidepanel" aria-label="Meine Szenarien">
|
||||
<header className="builder-sidepanel-header">
|
||||
<h2 className="builder-sidepanel-title" data-i18n="builder.panel.title">Meine Szenarien</h2>
|
||||
<button type="button" id="builder-new-scenario-btn"
|
||||
className="builder-sidepanel-newbtn"
|
||||
data-i18n="builder.panel.new">+ Neues Szenario</button>
|
||||
</header>
|
||||
<div className="builder-sidepanel-bucket" data-bucket="active">
|
||||
<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>
|
||||
{/* 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="procedures-panel" id="procedures-panel-search" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-search" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-wizard" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-wizard" hidden></section>
|
||||
|
||||
<section className="procedures-panel" id="procedures-panel-akte" role="tabpanel"
|
||||
aria-labelledby="procedures-tab-akte" hidden>
|
||||
<div className="procedures-panel-placeholder" data-i18n="procedures.panel.akte.placeholder">
|
||||
Akten-Einstieg folgt in einem späteren Slice.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tree output host. Slice U3 mounts the Verfahrensablauf
|
||||
tree here; U0 leaves it empty + hidden so the
|
||||
tab placeholders are the only thing visible. */}
|
||||
<section className="procedures-output procedures-output-tree" id="procedures-output-tree"
|
||||
aria-label="Tree output" hidden></section>
|
||||
|
||||
{/* Linear-drawer host. Inline drawer expanding beneath a
|
||||
tree card (design §8 — desktop) AND the standalone
|
||||
linear follow-up view that Mode A / Mode B land on
|
||||
after locking a trigger event (design §3.2). U1
|
||||
switches it on. */}
|
||||
<section className="procedures-output procedures-output-linear" id="procedures-output-linear"
|
||||
aria-label="Linear output" hidden></section>
|
||||
<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. */}
|
||||
<div className="builder-empty" id="builder-empty">
|
||||
<p className="builder-empty-headline" data-i18n="builder.empty.headline">
|
||||
Noch kein Szenario geöffnet.
|
||||
</p>
|
||||
<p className="builder-empty-hint" data-i18n="builder.empty.hint">
|
||||
Starte ein neues Szenario, wähle aus deiner Liste oder übernimm eine Akte (B4).
|
||||
</p>
|
||||
<button type="button" id="builder-cta-new" className="builder-cta-new"
|
||||
data-i18n="builder.empty.cta">
|
||||
Neues Szenario starten
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
@@ -40,6 +40,7 @@ export function renderSettings(): string {
|
||||
<a className="entity-tab" data-tab="profil" href="?tab=profil" data-i18n="einstellungen.tab.profil">Profil</a>
|
||||
<a className="entity-tab" data-tab="benachrichtigungen" href="?tab=benachrichtigungen" data-i18n="einstellungen.tab.benachrichtigungen">Benachrichtigungen</a>
|
||||
<a className="entity-tab" data-tab="caldav" href="?tab=caldav" data-i18n="einstellungen.tab.caldav">CalDAV</a>
|
||||
<a className="entity-tab" data-tab="names" href="?tab=names" data-i18n="einstellungen.tab.names">Namensschemata</a>
|
||||
<a className="entity-tab" data-tab="export" href="?tab=export" data-i18n="einstellungen.tab.export">Datenexport</a>
|
||||
</nav>
|
||||
|
||||
@@ -362,6 +363,23 @@ export function renderSettings(): string {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Namensschemata tab (t-paliad-356 Slice 4) -------- */}
|
||||
<section className="entity-tab-panel" id="tab-names" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="einstellungen.names.subtitle">
|
||||
Legen Sie fest, wie Paliad Entwurfstitel und Dateinamen aus Projektdaten zusammensetzt.
|
||||
Klicken Sie auf einen Platzhalter, um ihn einzufügen; die Vorschau zeigt das Ergebnis sofort.
|
||||
</p>
|
||||
|
||||
<div id="names-loading" className="entity-loading">
|
||||
<p data-i18n="einstellungen.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
{/* Per-artifact cards are built client-side from
|
||||
/api/me/name-compositions so the wired-artifact list stays
|
||||
server-driven (no duplicated catalog in the frontend). */}
|
||||
<div id="names-list" className="names-list" style="display:none" />
|
||||
</section>
|
||||
|
||||
{/* --- Datenexport tab (t-paliad-214 Slice 1) ----------- */}
|
||||
<section className="entity-tab-panel" id="tab-export" style="display:none">
|
||||
<p className="tool-subtitle" data-i18n="einstellungen.export.subtitle">
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -75,103 +75,150 @@ export function renderSubmissionDraft(): string {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="submission-draft-grid">
|
||||
{/* Sidebar — draft switcher + variable groups. */}
|
||||
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
|
||||
<div className="submission-draft-switcher">
|
||||
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
|
||||
Entwurf
|
||||
</label>
|
||||
<select id="submission-draft-pick" />
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-new-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.action.new">
|
||||
+ Neuer Entwurf
|
||||
</button>
|
||||
</div>
|
||||
{/* t-paliad-370 S2 — meta toolbar. Draft management +
|
||||
template controls lifted out of the sidebar into a header
|
||||
strip so the sidebar holds only the fill-in work (parties
|
||||
+ variables). Pure relayout: every control keeps its id +
|
||||
wiring (switcher, name, keyword→composer_meta, base picker
|
||||
PATCH base_id, language autosave). */}
|
||||
<div className="submission-draft-toolbar" id="submission-draft-toolbar">
|
||||
<div className="submission-draft-switcher">
|
||||
<label htmlFor="submission-draft-pick" data-i18n="submissions.draft.switcher.label">
|
||||
Entwurf
|
||||
</label>
|
||||
<select id="submission-draft-pick" />
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-new-btn"
|
||||
className="btn-small btn-secondary"
|
||||
data-i18n="submissions.draft.action.new">
|
||||
+ Neuer Entwurf
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-name-row">
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-name"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.name.placeholder"
|
||||
placeholder="Name dieses Entwurfs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-delete-btn"
|
||||
className="btn-small btn-link-danger"
|
||||
data-i18n="submissions.draft.action.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<div className="submission-draft-name-row">
|
||||
<input
|
||||
type="text"
|
||||
id="submission-draft-name"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.draft.name.placeholder"
|
||||
placeholder="Name dieses Entwurfs"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-delete-btn"
|
||||
className="btn-small btn-link-danger"
|
||||
data-i18n="submissions.draft.action.delete">
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A —
|
||||
base picker. Hydrated by client/submission-draft.ts
|
||||
once /api/submission-bases returns. Disabled
|
||||
for pre-Composer drafts (base_id NULL); switching
|
||||
autosaves the draft. */}
|
||||
<div
|
||||
className="submission-draft-base-row"
|
||||
id="submission-draft-base-row"
|
||||
style="display:none">
|
||||
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
|
||||
Vorlagenbasis
|
||||
</label>
|
||||
{/* t-paliad-354 — keyword that leads the exported document
|
||||
name "<date> <keyword> (<case>)". Empty falls back to the
|
||||
auto-derived rule name. Persisted to
|
||||
composer_meta.filename_keyword 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>
|
||||
|
||||
{/* t-paliad-313 (m/paliad#141) Composer Slice A — base
|
||||
picker. Hydrated by client/submission-draft.ts once
|
||||
/api/submission-bases returns; switching autosaves
|
||||
base_id. t-paliad-370 S2 adds the 👁 Vorschau button
|
||||
(disabled stub; S3 wires it to the truthful base-preview
|
||||
modal, PRD §2). */}
|
||||
<div
|
||||
className="submission-draft-base-row"
|
||||
id="submission-draft-base-row"
|
||||
style="display:none">
|
||||
<label htmlFor="submission-draft-base" data-i18n="submissions.draft.base.label">
|
||||
Vorlagenbasis
|
||||
</label>
|
||||
<div className="submission-draft-base-controls">
|
||||
<select id="submission-draft-base" />
|
||||
<p
|
||||
className="submission-draft-base-hint"
|
||||
id="submission-draft-base-hint"
|
||||
data-i18n="submissions.draft.base.hint">
|
||||
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* t-paliad-276 — output language toggle (DE/EN).
|
||||
Hydrated by client/submission-draft.ts; switching
|
||||
autosaves the draft and re-renders the preview. */}
|
||||
<div
|
||||
className="submission-draft-language-row"
|
||||
id="submission-draft-language-row"
|
||||
role="radiogroup"
|
||||
aria-labelledby="submission-draft-language-label">
|
||||
<span
|
||||
id="submission-draft-language-label"
|
||||
className="submission-draft-language-label"
|
||||
data-i18n="submissions.draft.language">
|
||||
Sprache
|
||||
</span>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="de"
|
||||
id="submission-draft-language-de"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.de">DE</span>
|
||||
</label>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="en"
|
||||
id="submission-draft-language-en"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.en">EN</span>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
id="submission-draft-preview-base-btn"
|
||||
className="btn-icon submission-draft-preview-base-btn"
|
||||
aria-label="Vorschau Vorlagenbasis"
|
||||
data-i18n-title="submissions.draft.base.preview"
|
||||
title="Vorschau Vorlagenbasis">
|
||||
👁
|
||||
</button>
|
||||
</div>
|
||||
<p
|
||||
className="submission-draft-language-fallback"
|
||||
id="submission-draft-language-fallback"
|
||||
style="display:none"
|
||||
data-i18n="submissions.draft.language.fallback_notice">
|
||||
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
|
||||
className="submission-draft-base-hint"
|
||||
id="submission-draft-base-hint"
|
||||
data-i18n="submissions.draft.base.hint">
|
||||
Steuert Schriftarten, Briefkopf und Abschnitts-Defaults.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
{/* t-paliad-276 — output language toggle (DE/EN). Hydrated
|
||||
by client/submission-draft.ts; switching autosaves the
|
||||
draft and re-renders the preview. */}
|
||||
<div
|
||||
className="submission-draft-language-row"
|
||||
id="submission-draft-language-row"
|
||||
role="radiogroup"
|
||||
aria-labelledby="submission-draft-language-label">
|
||||
<span
|
||||
id="submission-draft-language-label"
|
||||
className="submission-draft-language-label"
|
||||
data-i18n="submissions.draft.language">
|
||||
Sprache
|
||||
</span>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="de"
|
||||
id="submission-draft-language-de"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.de">DE</span>
|
||||
</label>
|
||||
<label className="submission-draft-language-option">
|
||||
<input
|
||||
type="radio"
|
||||
name="submission-draft-language"
|
||||
value="en"
|
||||
id="submission-draft-language-en"
|
||||
/>
|
||||
<span data-i18n="submissions.draft.language.en">EN</span>
|
||||
</label>
|
||||
</div>
|
||||
<p
|
||||
className="submission-draft-language-fallback"
|
||||
id="submission-draft-language-fallback"
|
||||
style="display:none"
|
||||
data-i18n="submissions.draft.language.fallback_notice">
|
||||
Fallback: universelles Skelett (keine sprachspezifische Vorlage).
|
||||
</p>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
</div>
|
||||
|
||||
<div className="submission-draft-grid">
|
||||
{/* Sidebar — parties + variable groups (the fill-in work).
|
||||
Draft/template meta lives in the toolbar above. */}
|
||||
<aside className="submission-draft-sidebar" id="submission-draft-sidebar">
|
||||
|
||||
{/* t-paliad-277: "Aus Projekt importieren" + last-
|
||||
imported-at timestamp. Only visible when the
|
||||
|
||||
112
frontend/src/templates-authoring.tsx
Normal file
112
frontend/src/templates-authoring.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import { h } from "./jsx";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { PaliadinWidget } from "./components/PaliadinWidget";
|
||||
import { BottomNav } from "./components/BottomNav";
|
||||
import { Footer } from "./components/Footer";
|
||||
import { PWAHead } from "./components/PWAHead";
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring page at
|
||||
// /admin/templates.
|
||||
//
|
||||
// Admin uploads a base .docx, sees it rendered as run-addressable text,
|
||||
// selects a span + a variable from the palette to drop a {{slot}}, and the
|
||||
// result saves as a reusable docforge template. Pure shell:
|
||||
// client/templates-authoring.ts hydrates the list, upload form, preview,
|
||||
// palette, and slot list after load. The palette labels come from the Go
|
||||
// variable catalogue (GET /api/docforge/variables, the SSOT from slice 5).
|
||||
//
|
||||
// Design ref: docs/plans/prd-docforge-2026-05-29.md §2.1.
|
||||
|
||||
export function renderTemplatesAuthoring(): string {
|
||||
return "<!DOCTYPE html>" + (
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#BFF355" />
|
||||
<PWAHead />
|
||||
<title data-i18n="templates.authoring.title">Vorlagen — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-templates-authoring">
|
||||
<Sidebar currentPath="/admin" />
|
||||
<BottomNav currentPath="/admin" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page docforge-templates-page">
|
||||
<div className="container">
|
||||
<header className="docforge-templates-header">
|
||||
<h1 data-i18n="templates.authoring.heading">Vorlagen</h1>
|
||||
<p
|
||||
className="docforge-templates-intro"
|
||||
data-i18n="templates.authoring.intro">
|
||||
Lade eine Word-Vorlage hoch, markiere Stellen und setze Variablen ein.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{/* Upload a new base .docx */}
|
||||
<section className="docforge-upload" id="docforge-upload">
|
||||
<h2 data-i18n="templates.authoring.upload.title">Neue Vorlage hochladen</h2>
|
||||
<form id="docforge-upload-form" className="entity-form">
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.file">Word-Datei (.docx)</span>
|
||||
<input type="file" name="file" accept=".docx,.dotx,.docm,.dotm" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_de">Name (DE)</span>
|
||||
<input type="text" name="name_de" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.name_en">Name (EN)</span>
|
||||
<input type="text" name="name_en" className="entity-form-input" required />
|
||||
</label>
|
||||
<label className="entity-form-row">
|
||||
<span data-i18n="templates.authoring.upload.firm">Kanzlei (optional)</span>
|
||||
<input type="text" name="firm" className="entity-form-input" />
|
||||
</label>
|
||||
<button type="submit" className="btn-primary" data-i18n="templates.authoring.upload.submit">
|
||||
Hochladen
|
||||
</button>
|
||||
<span className="docforge-upload-status" id="docforge-upload-status" />
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{/* Existing templates */}
|
||||
<section className="docforge-template-list-wrap">
|
||||
<h2 data-i18n="templates.authoring.list.title">Vorhandene Vorlagen</h2>
|
||||
<ul className="entity-table docforge-template-list" id="docforge-template-list" />
|
||||
</section>
|
||||
|
||||
{/* Authoring workspace — hidden until a template is opened. */}
|
||||
<section className="docforge-workspace" id="docforge-workspace" hidden>
|
||||
<header className="docforge-workspace-header">
|
||||
<h2 id="docforge-workspace-title" />
|
||||
<span className="docforge-workspace-hint" data-i18n="templates.authoring.workspace.hint">
|
||||
Text markieren, dann eine Variable wählen, um einen Platzhalter zu setzen.
|
||||
</span>
|
||||
<span className="docforge-workspace-status" id="docforge-workspace-status" />
|
||||
</header>
|
||||
<div className="docforge-workspace-grid">
|
||||
{/* Variable palette (left) — populated from the catalogue. */}
|
||||
<aside className="docforge-palette" id="docforge-palette" />
|
||||
{/* Run-addressable preview (center) — selection target. */}
|
||||
<div className="docforge-preview" id="docforge-preview" />
|
||||
{/* Placed slots (right). */}
|
||||
<aside className="docforge-slots">
|
||||
<h3 data-i18n="templates.authoring.slots.title">Platzhalter</h3>
|
||||
<ul className="docforge-slot-list" id="docforge-slot-list" />
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/templates-authoring.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
12
internal/db/migrations/158_docforge_templates.down.sql
Normal file
12
internal/db/migrations/158_docforge_templates.down.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- t-paliad-349: revert docforge template authoring tables.
|
||||
--
|
||||
-- Drop the FK first so the templates ↔ template_versions cycle unwinds,
|
||||
-- then the tables (template_slots + template_versions cascade from their
|
||||
-- parents, but drop explicitly for clarity and order-independence).
|
||||
|
||||
ALTER TABLE IF EXISTS paliad.templates
|
||||
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
|
||||
|
||||
DROP TABLE IF EXISTS paliad.template_slots;
|
||||
DROP TABLE IF EXISTS paliad.template_versions;
|
||||
DROP TABLE IF EXISTS paliad.templates;
|
||||
127
internal/db/migrations/158_docforge_templates.up.sql
Normal file
127
internal/db/migrations/158_docforge_templates.up.sql
Normal file
@@ -0,0 +1,127 @@
|
||||
-- t-paliad-349 (m/paliad#157): docforge slice 4 — template authoring tables.
|
||||
--
|
||||
-- These three tables are the persistence home for the docforge authoring
|
||||
-- flow (upload a base .docx → place variable slots → save as a reusable
|
||||
-- template) and the generation flow (pick a template → bind data →
|
||||
-- export). They are paliad's implementation of the docforge.TemplateStore
|
||||
-- contract; docforge itself owns no tables (the litigationplanner pattern).
|
||||
--
|
||||
-- Generic on purpose (NOT submission_*-named): authoring is a
|
||||
-- domain-neutral capability, so the eventual second docforge consumer can
|
||||
-- reuse the same shape. submission_bases (Gitea-backed, section_spec) stays
|
||||
-- for the legacy base catalog during the transition; convergence is a
|
||||
-- later, separate task.
|
||||
--
|
||||
-- paliad.templates — one row per template (the catalog entry).
|
||||
-- paliad.template_versions — immutable snapshots; editing a template
|
||||
-- inserts a new version. The carrier .docx
|
||||
-- bytes live here (bytea) — the TemplateStore
|
||||
-- bytea backend. A draft pins a version
|
||||
-- (snapshot-at-create, PRD §4 A3) so later
|
||||
-- edits don't shift an in-flight draft.
|
||||
-- paliad.template_slots — the variable slots placed in a version's
|
||||
-- carrier. anchor is the sentinel token the
|
||||
-- authoring surface injects into the carrier
|
||||
-- OOXML to locate the slot (PRD §5 lean);
|
||||
-- slot_key is the variable bound there.
|
||||
--
|
||||
-- Visibility: the template catalog is shared firm-wide (every
|
||||
-- authenticated user generates from it), so SELECT is open to
|
||||
-- authenticated, mirroring submission_bases. Mutations (upload, edit) are
|
||||
-- admin-only and gated in Go at the handler layer — no INSERT/UPDATE/DELETE
|
||||
-- RLS path means RLS denies them by default.
|
||||
--
|
||||
-- Slice 4 ships the schema + the TemplateStore only; no rows are seeded and
|
||||
-- no UI writes here yet (authoring is slice 6, generation-on-templates is
|
||||
-- slice 7).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.templates (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
slug text UNIQUE,
|
||||
name_de text NOT NULL,
|
||||
name_en text NOT NULL,
|
||||
kind text NOT NULL DEFAULT 'submission',
|
||||
source_format text NOT NULL DEFAULT 'docx',
|
||||
firm text,
|
||||
is_active bool NOT NULL DEFAULT true,
|
||||
current_version_id uuid,
|
||||
created_by uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT templates_source_format_check CHECK (source_format IN ('docx'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.template_versions (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_id uuid NOT NULL REFERENCES paliad.templates(id) ON DELETE CASCADE,
|
||||
version int NOT NULL,
|
||||
carrier_blob bytea NOT NULL,
|
||||
stylemap jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
created_by uuid NOT NULL,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT template_versions_unique_per_template UNIQUE (template_id, version)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.template_slots (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
template_version_id uuid NOT NULL REFERENCES paliad.template_versions(id) ON DELETE CASCADE,
|
||||
slot_key text NOT NULL,
|
||||
anchor text NOT NULL,
|
||||
label text,
|
||||
order_index int NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT template_slots_unique_anchor UNIQUE (template_version_id, anchor)
|
||||
);
|
||||
|
||||
-- current_version_id FK is added after template_versions exists to avoid a
|
||||
-- circular CREATE-TABLE dependency. ON DELETE SET NULL: dropping the
|
||||
-- pinned version detaches it rather than cascading the template away.
|
||||
ALTER TABLE paliad.templates
|
||||
DROP CONSTRAINT IF EXISTS templates_current_version_fk;
|
||||
ALTER TABLE paliad.templates
|
||||
ADD CONSTRAINT templates_current_version_fk
|
||||
FOREIGN KEY (current_version_id)
|
||||
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS templates_firm_kind_idx
|
||||
ON paliad.templates (firm, kind) WHERE is_active;
|
||||
CREATE INDEX IF NOT EXISTS template_versions_template_idx
|
||||
ON paliad.template_versions (template_id, version);
|
||||
CREATE INDEX IF NOT EXISTS template_slots_version_idx
|
||||
ON paliad.template_slots (template_version_id, order_index);
|
||||
|
||||
ALTER TABLE paliad.templates ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.template_versions ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE paliad.template_slots ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Firm-shared catalog: any authenticated user reads. Mutations are
|
||||
-- admin-only, gated in Go (no mutation RLS policy = RLS denies by default).
|
||||
DROP POLICY IF EXISTS templates_select ON paliad.templates;
|
||||
CREATE POLICY templates_select
|
||||
ON paliad.templates FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS template_versions_select ON paliad.template_versions;
|
||||
CREATE POLICY template_versions_select
|
||||
ON paliad.template_versions FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP POLICY IF EXISTS template_slots_select ON paliad.template_slots;
|
||||
CREATE POLICY template_slots_select
|
||||
ON paliad.template_slots FOR SELECT TO authenticated
|
||||
USING (true);
|
||||
|
||||
DROP TRIGGER IF EXISTS templates_set_updated_at ON paliad.templates;
|
||||
CREATE TRIGGER templates_set_updated_at
|
||||
BEFORE UPDATE ON paliad.templates
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.templates IS
|
||||
't-paliad-349: docforge template catalog. One row per uploaded template; current_version_id pins the live version.';
|
||||
COMMENT ON TABLE paliad.template_versions IS
|
||||
't-paliad-349: immutable docforge template snapshots. carrier_blob holds the base .docx bytes (TemplateStore bytea backend).';
|
||||
COMMENT ON TABLE paliad.template_slots IS
|
||||
't-paliad-349: variable slots placed in a template version. anchor = sentinel token locating the slot in the carrier OOXML; slot_key = the bound variable.';
|
||||
@@ -0,0 +1,6 @@
|
||||
-- t-paliad-349: revert the template-version pin on submission drafts.
|
||||
|
||||
DROP INDEX IF EXISTS paliad.submission_drafts_template_version_idx;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
DROP COLUMN IF EXISTS template_version_id;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- t-paliad-349 (m/paliad#157): docforge slice 7 — pin an uploaded template
|
||||
-- version onto a submission draft (generation-on-uploaded-templates).
|
||||
--
|
||||
-- A draft can now source its document from a docforge uploaded template
|
||||
-- (paliad.template_versions) instead of a legacy Gitea base. template_version_id
|
||||
-- is the snapshot pin (PRD §4 A3): the draft renders the exact carrier of the
|
||||
-- version it was bound to, so a later template edit (which creates a new
|
||||
-- version) doesn't shift an in-flight draft.
|
||||
--
|
||||
-- Nullable + additive: existing drafts keep template_version_id NULL and
|
||||
-- render via their existing path (Composer base_id, or the v1 fallback).
|
||||
-- The three sources are mutually exclusive in practice; the export path
|
||||
-- checks template_version_id first, then base_id, then v1.
|
||||
--
|
||||
-- ON DELETE SET NULL: if the pinned version is removed, the draft detaches
|
||||
-- and falls back rather than failing — same posture as base_id's
|
||||
-- ON DELETE SET NULL.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ADD COLUMN IF NOT EXISTS template_version_id uuid
|
||||
REFERENCES paliad.template_versions(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_template_version_idx
|
||||
ON paliad.submission_drafts (template_version_id)
|
||||
WHERE template_version_id IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN paliad.submission_drafts.template_version_id IS
|
||||
't-paliad-349: pinned docforge template version (snapshot-at-create). NULL = render via base_id Composer path or v1 fallback.';
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE paliad.users
|
||||
DROP COLUMN IF EXISTS name_compositions;
|
||||
12
internal/db/migrations/160_user_name_compositions.up.sql
Normal file
12
internal/db/migrations/160_user_name_compositions.up.sql
Normal file
@@ -0,0 +1,12 @@
|
||||
-- Per-user name-composition overrides (t-paliad-356 Slice 3, PRD §3.2).
|
||||
--
|
||||
-- A free-form JSONB map of { artifact_id: Composition } overriding the
|
||||
-- code-resident system-default name composition for that artifact (the two
|
||||
-- seed schemes: submission_draft_title, submission_docx_filename). An empty
|
||||
-- object means "no overrides — use the system defaults"; unknown artifact
|
||||
-- ids and segments referencing unknown variables are dropped on read
|
||||
-- (NameCompositionSpec.SanitizeForRead) and rejected on write
|
||||
-- (NameCompositionSpec.Validate), mirroring the user_dashboard_layouts
|
||||
-- pattern.
|
||||
ALTER TABLE paliad.users
|
||||
ADD COLUMN IF NOT EXISTS name_compositions jsonb NOT NULL DEFAULT '{}'::jsonb;
|
||||
@@ -0,0 +1,55 @@
|
||||
-- Revert t-paliad-358 A-S2: restore each base's original (pre-parametric)
|
||||
-- caption seed_md from migrations 146 / 150, verbatim. One UPDATE per slug
|
||||
-- because the originals differed per base.
|
||||
|
||||
-- hlc-letterhead (mig 146): heading + parties with "vertreten durch" + court.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'hlc-letterhead' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- neutral (mig 146): heading + parties (no representative) + Aktenzeichen, no court.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'neutral' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- lg-duesseldorf (mig 150): heading + parties (no representative) + court.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'In der Sache\n\n**{{parties.claimant.0.name}}**\n— Klägerin —\n\ngegen\n\n**{{parties.defendant.0.name}}**\n— Beklagte —\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'In the matter\n\n**{{parties.claimant.0.name}}**\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\n— Defendant —\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'lg-duesseldorf' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- upc-formal (mig 150): UPC heading + parties with "represented by" + UPC case number + patent.
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC-Aktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}',
|
||||
'seed_md_en', E'# In the matter\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n— Claimant —\n\nv.\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n— Defendant —\n\nUPC case number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'upc-formal' AND b.section_spec ? 'defaults';
|
||||
@@ -0,0 +1,43 @@
|
||||
-- t-paliad-358 A-S2 — unify the Composer caption (Rubrum) seed across every
|
||||
-- base onto the shared parametric caption.* resolver keys.
|
||||
--
|
||||
-- Before: each base seeded a hand-written caption with hard-coded designations
|
||||
-- ("— Klägerin —" / "— Claimant —") and heading ("In der Sache" / "In the
|
||||
-- matter"). That wording diverged from the per-code .docx templates and the
|
||||
-- merge-fallback skeleton, and could not reflect the forum (UPC vs DE-LG vs
|
||||
-- nullity vs appeal).
|
||||
--
|
||||
-- After: every base's caption section references the {{caption.*}} keys
|
||||
-- (addCaptionVars, submission_vars.go), so the heading, party designations,
|
||||
-- versus connector and "wegen" subject are resolved per forum from
|
||||
-- project.proceeding (jurisdiction + code + role-label overrides) +
|
||||
-- project.instance_level — the SAME wording the templates and the fallback
|
||||
-- skeleton now use. One parametric caption, shared keys.
|
||||
--
|
||||
-- Forward-only effect: section seeds are applied when a NEW draft is created
|
||||
-- from a base; existing drafts keep their already-seeded (possibly user-edited)
|
||||
-- caption text untouched.
|
||||
--
|
||||
-- Position-independent: rewrites only the element whose section_key='caption'
|
||||
-- inside section_spec->'defaults', preserving order (WITH ORDINALITY) and every
|
||||
-- other field on the element (elem || patch).
|
||||
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(
|
||||
b.section_spec,
|
||||
'{defaults}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem
|
||||
END
|
||||
ORDER BY ord
|
||||
)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)
|
||||
)
|
||||
)
|
||||
WHERE b.slug IN ('hlc-letterhead', 'neutral', 'lg-duesseldorf', 'upc-formal')
|
||||
AND b.section_spec ? 'defaults';
|
||||
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS paliad.firm_name_compositions;
|
||||
31
internal/db/migrations/162_firm_name_compositions.up.sql
Normal file
31
internal/db/migrations/162_firm_name_compositions.up.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Firm-wide default name compositions (t-paliad-356 Slice 5, PRD §3.2 / §8).
|
||||
--
|
||||
-- The firm tier of the name-composition precedence chain
|
||||
-- (per-document → user → FIRM → system). A single optional row holds the
|
||||
-- firm's house naming convention as a JSONB { artifact_id: Composition } map,
|
||||
-- validated by NameCompositionSpec exactly like the per-user
|
||||
-- users.name_compositions column (mig 160). Cleared → resolution falls through
|
||||
-- to the always-present code-resident system default.
|
||||
--
|
||||
-- Mirrors paliad.firm_dashboard_default (mig 117) exactly: single-row design
|
||||
-- via CHECK (id = 1), all authenticated users may SELECT (the render path
|
||||
-- reads it for every draft-name / filename), writes happen only under the
|
||||
-- service-role connection behind the admin HTTP gate.
|
||||
|
||||
CREATE TABLE paliad.firm_name_compositions (
|
||||
id smallint PRIMARY KEY DEFAULT 1 CHECK (id = 1),
|
||||
compositions_json jsonb NOT NULL,
|
||||
updated_by uuid REFERENCES paliad.users(id) ON DELETE SET NULL,
|
||||
updated_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
ALTER TABLE paliad.firm_name_compositions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- All authenticated users can SELECT — the name-render path needs to read the
|
||||
-- firm default when composing any draft title / export filename. The HTTP
|
||||
-- handler enforces admin-only on the PUT/DELETE paths; the service runs under
|
||||
-- service-role so writes bypass RLS anyway. No INSERT/UPDATE policy means no
|
||||
-- Supabase-JWT-authenticated client can write, which is the desired posture.
|
||||
CREATE POLICY firm_name_compositions_read
|
||||
ON paliad.firm_name_compositions FOR SELECT
|
||||
USING (true);
|
||||
40
internal/db/migrations/163_caption_wording_followup.down.sql
Normal file
40
internal/db/migrations/163_caption_wording_followup.down.sql
Normal file
@@ -0,0 +1,40 @@
|
||||
-- Revert 163_caption_wording_followup (t-paliad-361). Restores the A-S2
|
||||
-- (post-mig-161 / mig-137) state for all three changes.
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 1 down — UPC appeal EN responding party back to 'Appellee'.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_reactive_label_en = 'Appellee'
|
||||
WHERE code = 'upc.apl.unified'
|
||||
AND role_reactive_label_en = 'Respondent';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 2 down — drop the Streitpatent line from the upc-formal caption seed,
|
||||
-- restoring the verbatim post-mig-161 parametric seed.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(b.section_spec, '{defaults}', (
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\n{{project.court}}',
|
||||
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\n{{project.court}}')
|
||||
ELSE elem END
|
||||
ORDER BY ord)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)))
|
||||
WHERE b.slug = 'upc-formal' AND b.section_spec ? 'defaults';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 3 down — clear the backfilled role labels (back to NULL, the
|
||||
-- pre-163 state for these four proceedings).
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = NULL,
|
||||
role_reactive_label_de = NULL,
|
||||
role_proactive_label_en = NULL,
|
||||
role_reactive_label_en = NULL
|
||||
WHERE code IN ('de.inf.olg', 'de.inf.bgh', 'de.null.bpatg', 'de.null.bgh');
|
||||
108
internal/db/migrations/163_caption_wording_followup.up.sql
Normal file
108
internal/db/migrations/163_caption_wording_followup.up.sql
Normal file
@@ -0,0 +1,108 @@
|
||||
-- 163_caption_wording_followup — t-paliad-361, follow-up to t-paliad-358 A-S2.
|
||||
--
|
||||
-- m ruled on the 7 lexy-wording flags from A-S2 via AskUserQuestion
|
||||
-- (2026-06-01 14:30). Most flags CONFIRMED the live wording; three changes
|
||||
-- land here. All three are caption (Rubrum) wording and share this one
|
||||
-- reversible migration.
|
||||
--
|
||||
-- Change 1 — UPC appeal responding party (EN): 'Appellee' → 'Respondent'.
|
||||
-- m chose Respondent over Appellee. The only place 'Appellee' is stored is
|
||||
-- the mig-137 role-label override on upc.apl.unified (id=160, retired by
|
||||
-- mig 155 but kept as the canonical UPC-appeal role-label row). The caption
|
||||
-- resolver's instance-derived EN fallback already says 'Respondent'
|
||||
-- (submission_vars.go), so this fixes the wording at the data source rather
|
||||
-- than downstream. DE side (Berufungsbeklagter) is left untouched per m.
|
||||
--
|
||||
-- Change 2 — restore the standalone 'Streitpatent' / 'Patent in suit' line in
|
||||
-- the upc-formal Composer caption seed. A-S2 (mig 161) dropped it when it
|
||||
-- unified the caption onto the {{caption.*}} keys. m wants the patent-in-suit
|
||||
-- line back, but KEEPS the parametric 'In der Sache' heading (he did not
|
||||
-- revert that). Only the upc-formal base's caption seed is touched.
|
||||
--
|
||||
-- Change 3 — backfill role-label overrides for the four DE appeal/nullity
|
||||
-- proceedings that carry none (de.inf.olg, de.inf.bgh, de.null.bpatg,
|
||||
-- de.null.bgh). Without an override these fall to the instance-derived path,
|
||||
-- which is only correct when project.instance_level is set. The backfill
|
||||
-- makes the designations right regardless of instance_level. Wording is
|
||||
-- lexy-confirmed (statute-grounded: §§ 511, 542, 544 ZPO; §§ 81, 110 PatG),
|
||||
-- bracketed-inclusive gender style to match the A-S2-confirmed convention.
|
||||
--
|
||||
-- ADDITIVE / data-only. No schema changes. Reversible (see .down.sql).
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 1 — UPC appeal EN responding party: 'Appellee' → 'Respondent'.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'upc.apl.unified'
|
||||
AND role_reactive_label_en = 'Appellee';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 2 — restore the Streitpatent line in the upc-formal caption seed.
|
||||
-- Position-independent: rewrites only the section_key='caption' element of
|
||||
-- section_spec->'defaults', preserving order (WITH ORDINALITY) and every
|
||||
-- other field on the element (elem || patch). Keeps the parametric heading;
|
||||
-- re-adds 'Streitpatent: {{project.patent_number_upc}}' (DE) /
|
||||
-- 'Patent in suit: {{...}}' (EN) grouped with the case number, ahead of the
|
||||
-- {{project.court}} line.
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.submission_bases AS b
|
||||
SET section_spec = jsonb_set(
|
||||
b.section_spec,
|
||||
'{defaults}',
|
||||
(
|
||||
SELECT jsonb_agg(
|
||||
CASE WHEN elem->>'section_key' = 'caption'
|
||||
THEN elem || jsonb_build_object(
|
||||
'seed_md_de', E'{{caption.heading_de}}\n\n**{{parties.claimant.0.name}}**\nvertreten durch {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_de}} —\n\n{{caption.versus_de}}\n\n**{{parties.defendant.0.name}}**\nvertreten durch {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_de}} —\n\nwegen {{caption.subject_de}}\n\nAktenzeichen: {{project.case_number}}\nStreitpatent: {{project.patent_number_upc}}\n{{project.court}}',
|
||||
'seed_md_en', E'{{caption.heading_en}}\n\n**{{parties.claimant.0.name}}**\nrepresented by {{parties.claimant.0.representative}}\n\n— {{caption.claimant_designation_en}} —\n\n{{caption.versus_en}}\n\n**{{parties.defendant.0.name}}**\nrepresented by {{parties.defendant.0.representative}}\n\n— {{caption.defendant_designation_en}} —\n\nre {{caption.subject_en}}\n\nCase number: {{project.case_number}}\nPatent in suit: {{project.patent_number_upc}}\n{{project.court}}')
|
||||
ELSE elem
|
||||
END
|
||||
ORDER BY ord
|
||||
)
|
||||
FROM jsonb_array_elements(b.section_spec->'defaults') WITH ORDINALITY AS d(elem, ord)
|
||||
)
|
||||
)
|
||||
WHERE b.slug = 'upc-formal'
|
||||
AND b.section_spec ? 'defaults';
|
||||
|
||||
-- ----------------------------------------------------------------
|
||||
-- Change 3 — backfill lexy-confirmed role labels for the four DE
|
||||
-- appeal/nullity proceedings (mig-137 mechanism). Bracketed-inclusive
|
||||
-- gender style; EN equivalents.
|
||||
--
|
||||
-- de.inf.olg Berufungskläger(in) / Berufungsbeklagte(r) // Appellant / Respondent (§ 511 ZPO Berufung)
|
||||
-- de.inf.bgh Revisionskläger(in) / Revisionsbeklagte(r) // Appellant / Respondent (§§ 542/544 ZPO; Revision as default over NZB)
|
||||
-- de.null.bpatg Nichtigkeitskläger(in) / Beklagte(r) (Patentinhaber(in)) // Nullity claimant / Defendant (patent proprietor) (§ 81 PatG)
|
||||
-- de.null.bgh Berufungskläger(in) / Berufungsbeklagte(r) // Appellant / Respondent (§ 110 PatG, post-2009 Berufung)
|
||||
-- ----------------------------------------------------------------
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger(in)',
|
||||
role_reactive_label_de = 'Berufungsbeklagte(r)',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'de.inf.olg';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Revisionskläger(in)',
|
||||
role_reactive_label_de = 'Revisionsbeklagte(r)',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'de.inf.bgh';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Nichtigkeitskläger(in)',
|
||||
role_reactive_label_de = 'Beklagte(r) (Patentinhaber(in))',
|
||||
role_proactive_label_en = 'Nullity claimant',
|
||||
role_reactive_label_en = 'Defendant (patent proprietor)'
|
||||
WHERE code = 'de.null.bpatg';
|
||||
|
||||
UPDATE paliad.proceeding_types
|
||||
SET role_proactive_label_de = 'Berufungskläger(in)',
|
||||
role_reactive_label_de = 'Berufungsbeklagte(r)',
|
||||
role_proactive_label_en = 'Appellant',
|
||||
role_reactive_label_en = 'Respondent'
|
||||
WHERE code = 'de.null.bgh';
|
||||
20
internal/db/migrations/164_rekind_court_act_rules.down.sql
Normal file
20
internal/db/migrations/164_rekind_court_act_rules.down.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Down for 164_rekind_court_act_rules — restore the pre-fix mislabel
|
||||
-- (event_type='filing', primary_party='both') on the 8 court/trigger rows.
|
||||
-- Scoped to the re-kinded state so it only reverses this migration's effect.
|
||||
|
||||
UPDATE paliad.deadline_rules_unified
|
||||
SET event_type = 'filing',
|
||||
primary_party = 'both',
|
||||
updated_at = now()
|
||||
WHERE submission_code IN (
|
||||
'de.inf.bgh.urteil_olg',
|
||||
'de.inf.olg.urteil_lg',
|
||||
'de.null.bgh.urteil_bpatg',
|
||||
'dpma.appeal.bgh.entsch_bpatg',
|
||||
'dpma.appeal.bpatg.entscheidung',
|
||||
'epa.opp.boa.entsch',
|
||||
'dpma.opp.dpma.publish',
|
||||
'epa.opp.opd.grant'
|
||||
)
|
||||
AND event_type = 'decision'
|
||||
AND primary_party = 'court';
|
||||
64
internal/db/migrations/164_rekind_court_act_rules.up.sql
Normal file
64
internal/db/migrations/164_rekind_court_act_rules.up.sql
Normal file
@@ -0,0 +1,64 @@
|
||||
-- 164_rekind_court_act_rules — t-paliad-365 (P1), diagnosis t-paliad-363.
|
||||
--
|
||||
-- Re-kind 8 anchor/trigger rows in paliad.deadline_rules_unified that are
|
||||
-- MISLABELED event_type='filing' + primary_party='both'. They are COURT /
|
||||
-- registry acts — the service of a lower-court judgment ("Zustellung … Urteil"
|
||||
-- / "Zustellung … Entscheidung") or the publication of grant ("Veröffentlichung
|
||||
-- der Erteilung") — that START a deadline chain. They are NOT party
|
||||
-- submissions, yet they surfaced in the global /submissions/new picker, whose
|
||||
-- catalog query filters event_type='filing' (loadSubmissionCatalog,
|
||||
-- internal/handlers/submissions.go). m noticed "Service of OLG Judgment"
|
||||
-- (de.inf.bgh.urteil_olg) listed as draftable.
|
||||
--
|
||||
-- The model already carries a correct court-act tier: 16 sibling rows with
|
||||
-- event_type='decision' + primary_party='court' (de.inf.olg.urteil_olg,
|
||||
-- upc.inf.cfi.decision, …). This migration aligns the 8 mislabeled rows with
|
||||
-- that tier. The picker filter (event_type='filing') then excludes them
|
||||
-- automatically — no handler change is required for the core fix (a defensive
|
||||
-- primary_party guard is added in submissions.go as belt-and-braces).
|
||||
--
|
||||
-- m's fork LOCKED to data-correction (t-paliad-363 §P1, fork i).
|
||||
--
|
||||
-- submission_code is KEPT (the 16 sibling decision rows keep theirs as stable
|
||||
-- anchor identifiers; the deadline chain links via parent_id (uuid FK), which
|
||||
-- this migration does not touch — so anchoring is unaffected).
|
||||
--
|
||||
-- ── SAFETY (grep of the deadline engine, t-paliad-365 Step 1) ──────────────
|
||||
-- The ONLY code coupled to event_type for these rows is ruleAnchorKind
|
||||
-- (projection_service.go:1825): event_type IN ('hearing','decision','order')
|
||||
-- → the rule anchors as a paliad.appointments row; everything else anchors as
|
||||
-- a paliad.deadlines row. After this migration, clicking "Datum setzen" on one
|
||||
-- of these 8 court/trigger events records it as an APPOINTMENT (milestone)
|
||||
-- instead of a deadline — which is the semantically correct, sibling-consistent
|
||||
-- behaviour.
|
||||
-- * Deadline COMPUTATION is NOT affected: the forward projection
|
||||
-- (computeProjections) keys off the is_court_set boolean, never event_type.
|
||||
-- * Child chains are NOT broken: the sequence guard parentHasAnchoredActual
|
||||
-- (projection_service.go:1803) UNIONs paliad.deadlines AND
|
||||
-- paliad.appointments, so a child (e.g. Revisionsfrist) still finds its
|
||||
-- re-kinded parent's anchor regardless of which table it lands in.
|
||||
-- * Existing data: exactly 1 paliad.deadlines row is already anchored to one
|
||||
-- of these 8 rules (0 appointments) as of 2026-06-01. It stays valid and
|
||||
-- is still found by the dual-table guard. The only cosmetic effect is that
|
||||
-- a future RE-anchor of that same event would write an appointment beside
|
||||
-- the old deadline; benign, and only if re-anchored.
|
||||
-- Verdict: re-kinding does not break deadline computation. Surfaced to head.
|
||||
--
|
||||
-- ADDITIVE / data-only. No schema changes. Reversible (see .down.sql).
|
||||
-- Idempotent: scoped to the mislabeled state (event_type='filing').
|
||||
|
||||
UPDATE paliad.deadline_rules_unified
|
||||
SET event_type = 'decision',
|
||||
primary_party = 'court',
|
||||
updated_at = now()
|
||||
WHERE submission_code IN (
|
||||
'de.inf.bgh.urteil_olg', -- Zustellung OLG-Urteil
|
||||
'de.inf.olg.urteil_lg', -- Zustellung LG-Urteil
|
||||
'de.null.bgh.urteil_bpatg', -- Zustellung BPatG-Urteil
|
||||
'dpma.appeal.bgh.entsch_bpatg', -- Zustellung BPatG-Entscheidung
|
||||
'dpma.appeal.bpatg.entscheidung',-- Zustellung DPMA-Entscheidung
|
||||
'epa.opp.boa.entsch', -- Zustellung der Beschwerdeentscheidung
|
||||
'dpma.opp.dpma.publish', -- Veröffentlichung der Erteilung (DPMA)
|
||||
'epa.opp.opd.grant' -- Veröffentlichung der Erteilung (EPA)
|
||||
)
|
||||
AND event_type = 'filing';
|
||||
199
internal/handlers/builder_search.go
Normal file
199
internal/handlers/builder_search.go
Normal file
@@ -0,0 +1,199 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// t-paliad-346 / m/paliad#153 B3 — universal search for the Litigation
|
||||
// Builder. Returns events + scenarios + projects (Akten) keyed by type
|
||||
// so the search dropdown can render typed result groups.
|
||||
//
|
||||
// GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Response shape:
|
||||
//
|
||||
// {
|
||||
// "query": "<echoed q>",
|
||||
// "events": [ EventSearchHit, ... ], // anchor_rule_id + proceeding_type embedded
|
||||
// "scenarios": [ { id, name, status, updated_at }, ... ],
|
||||
// "projects": [ { id, title, type, reference, case_number, matter_number, client_number }, ... ],
|
||||
// "counts": { "events": N, "scenarios": M, "projects": K }
|
||||
// }
|
||||
//
|
||||
// Each group is independently capped (default 8 events / 5 scenarios /
|
||||
// 5 projects, max 30 per group). Missing services degrade gracefully —
|
||||
// an unavailable group is returned as an empty array, not an error,
|
||||
// so a knowledge-only deploy (DATABASE_URL unset) can still serve a
|
||||
// best-effort empty response shape rather than a 503 wall.
|
||||
|
||||
type builderSearchScenarioHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
}
|
||||
|
||||
type builderSearchProjectHit struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Reference *string `json:"reference,omitempty"`
|
||||
CaseNumber *string `json:"case_number,omitempty"`
|
||||
MatterNumber *string `json:"matter_number,omitempty"`
|
||||
ClientNumber *string `json:"client_number,omitempty"`
|
||||
}
|
||||
|
||||
type builderSearchResponse struct {
|
||||
Query string `json:"query"`
|
||||
Events []services.EventSearchHit `json:"events"`
|
||||
Scenarios []builderSearchScenarioHit `json:"scenarios"`
|
||||
Projects []builderSearchProjectHit `json:"projects"`
|
||||
Counts builderSearchCounts `json:"counts"`
|
||||
}
|
||||
|
||||
type builderSearchCounts struct {
|
||||
Events int `json:"events"`
|
||||
Scenarios int `json:"scenarios"`
|
||||
Projects int `json:"projects"`
|
||||
}
|
||||
|
||||
// handleBuilderSearch — GET /api/builder/search?q=<term>&limit=<n>
|
||||
//
|
||||
// Auth required. Returns 200 with empty groups when q is empty (matches
|
||||
// the fristenrechner search ergonomic — frontend can boot without a
|
||||
// pre-flight round trip).
|
||||
func handleBuilderSearch(w http.ResponseWriter, r *http.Request) {
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
q := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||
perGroupLimit := parseBuilderSearchLimit(r.URL.Query().Get("limit"))
|
||||
|
||||
resp := builderSearchResponse{
|
||||
Query: q,
|
||||
Events: []services.EventSearchHit{},
|
||||
Scenarios: []builderSearchScenarioHit{},
|
||||
Projects: []builderSearchProjectHit{},
|
||||
}
|
||||
|
||||
if q == "" {
|
||||
// Match fristenrechner search: empty query → empty groups, not 400.
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
|
||||
// Events: reuse the SearchEvents shape so anchor_rule_id +
|
||||
// proceeding_type travel with each hit. UPC v1 (PRD §0.4) — the
|
||||
// jurisdiction filter pins the corpus the builder serves today.
|
||||
if dbSvc != nil && dbSvc.deadlineSearch != nil {
|
||||
eventsResp, err := dbSvc.deadlineSearch.SearchEvents(ctx, q, services.EventSearchOptions{
|
||||
Jurisdiction: "UPC",
|
||||
Limit: perGroupLimit.events,
|
||||
})
|
||||
if err == nil && eventsResp != nil {
|
||||
resp.Events = eventsResp.Events
|
||||
}
|
||||
}
|
||||
|
||||
// Scenarios: caller's own scenarios filtered by ILIKE on name.
|
||||
// Borrows ListMyScenarios + filters in-memory; the list endpoint
|
||||
// already caps at the small per-user fan-out and there's no index
|
||||
// on (owner_id, name) yet — in-memory filter is cheap at 10s-of-
|
||||
// rows scale.
|
||||
if dbSvc != nil && dbSvc.scenarioBuilder != nil {
|
||||
scenarios, err := dbSvc.scenarioBuilder.ListMyScenarios(ctx, uid, "active")
|
||||
if err == nil {
|
||||
needle := strings.ToLower(q)
|
||||
hits := []builderSearchScenarioHit{}
|
||||
for _, sc := range scenarios {
|
||||
if !strings.Contains(strings.ToLower(sc.Name), needle) {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, builderSearchScenarioHit{
|
||||
ID: sc.ID,
|
||||
Name: sc.Name,
|
||||
Status: sc.Status,
|
||||
UpdatedAt: sc.UpdatedAt.Format("2006-01-02T15:04:05Z07:00"),
|
||||
})
|
||||
if len(hits) >= perGroupLimit.scenarios {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Scenarios = hits
|
||||
}
|
||||
}
|
||||
|
||||
// Projects (Akten): visible projects filtered by trigram/ILIKE on
|
||||
// title, reference, client_number, matter_number. ProjectService.List
|
||||
// already applies team-based RLS via visibilityPredicate.
|
||||
if dbSvc != nil && dbSvc.projects != nil {
|
||||
projects, err := dbSvc.projects.List(ctx, uid, services.ProjectFilter{
|
||||
Search: q,
|
||||
})
|
||||
if err == nil {
|
||||
hits := make([]builderSearchProjectHit, 0, len(projects))
|
||||
for _, p := range projects {
|
||||
hits = append(hits, builderSearchProjectHit{
|
||||
ID: p.ID,
|
||||
Type: p.Type,
|
||||
Title: p.Title,
|
||||
Reference: p.Reference,
|
||||
CaseNumber: p.CaseNumber,
|
||||
MatterNumber: p.MatterNumber,
|
||||
ClientNumber: p.ClientNumber,
|
||||
})
|
||||
if len(hits) >= perGroupLimit.projects {
|
||||
break
|
||||
}
|
||||
}
|
||||
resp.Projects = hits
|
||||
}
|
||||
}
|
||||
|
||||
resp.Counts = builderSearchCounts{
|
||||
Events: len(resp.Events),
|
||||
Scenarios: len(resp.Scenarios),
|
||||
Projects: len(resp.Projects),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type builderSearchPerGroup struct {
|
||||
events int
|
||||
scenarios int
|
||||
projects int
|
||||
}
|
||||
|
||||
// parseBuilderSearchLimit reads ?limit=<n> as a hint for the events
|
||||
// group (largest expected hit count). Scenarios + projects use smaller
|
||||
// caps because their drop-down rows are visually heavier. The shared
|
||||
// caller-supplied bound is interpreted as the events cap; scenarios
|
||||
// and projects are derived from it.
|
||||
func parseBuilderSearchLimit(raw string) builderSearchPerGroup {
|
||||
def := builderSearchPerGroup{events: 8, scenarios: 5, projects: 5}
|
||||
if raw == "" {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.Atoi(raw)
|
||||
if err != nil || n <= 0 {
|
||||
return def
|
||||
}
|
||||
if n > 30 {
|
||||
n = 30
|
||||
}
|
||||
return builderSearchPerGroup{
|
||||
events: n,
|
||||
scenarios: max(1, n/2),
|
||||
projects: max(1, n/2),
|
||||
}
|
||||
}
|
||||
48
internal/handlers/docforge_variables.go
Normal file
48
internal/handlers/docforge_variables.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package handlers
|
||||
|
||||
// docforge variable catalogue handler (t-paliad-349 slice 5).
|
||||
//
|
||||
// Endpoint: GET /api/docforge/variables → the full variable catalogue
|
||||
// (key + bilingual label + namespace group) the sidebar form and the
|
||||
// authoring palette render. The catalogue is the Go-side single source of
|
||||
// truth, built from the submission resolvers' Keys(); it replaces the
|
||||
// duplicated TS VARIABLE_LABELS table so labels can't drift between the
|
||||
// resolver that produces a value and the form that labels it.
|
||||
//
|
||||
// Static — no DB call, no per-user state. Auth-gated only (anonymous 401);
|
||||
// the catalogue is the same for every authenticated user.
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
type docforgeVariablesResponse struct {
|
||||
Variables []variableEntry `json:"variables"`
|
||||
}
|
||||
|
||||
type variableEntry struct {
|
||||
Key string `json:"key"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Group string `json:"group"`
|
||||
}
|
||||
|
||||
// handleDocforgeVariables backs GET /api/docforge/variables.
|
||||
func handleDocforgeVariables(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
cat := services.SubmissionVariableCatalogue()
|
||||
out := make([]variableEntry, 0, len(cat))
|
||||
for _, e := range cat {
|
||||
out = append(out, variableEntry{
|
||||
Key: e.Key,
|
||||
LabelDE: e.LabelDE,
|
||||
LabelEN: e.LabelEN,
|
||||
Group: e.Group,
|
||||
})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, docforgeVariablesResponse{Variables: out})
|
||||
}
|
||||
@@ -91,6 +91,19 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
// slugs are silently dropped (no filter) so a stale frontend
|
||||
// chip doesn't 400 the request.
|
||||
AppealTarget string `json:"appealTarget,omitempty"`
|
||||
// t-paliad-348 / yoUPC#178 — surface the engine's two new
|
||||
// CalcOptions axes to the HTTP boundary:
|
||||
//
|
||||
// IncludeOptional: when true, priority='optional' rules
|
||||
// surface on the timeline. Default false matches the
|
||||
// engine's default (mandatory backbone only).
|
||||
// TriggerEventAnchors: per-event-code anchor dates the
|
||||
// engine consults for rules carrying trigger_event_id.
|
||||
// When a rule's anchor is absent the engine renders the
|
||||
// rule as IsConditional rather than fabricating a date
|
||||
// off the proceeding's trigger date.
|
||||
IncludeOptional bool `json:"includeOptional,omitempty"`
|
||||
TriggerEventAnchors map[string]string `json:"triggerEventAnchors,omitempty"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "Ungültige Anfrage"})
|
||||
@@ -130,15 +143,17 @@ func handleFristenrechnerAPI(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
resp, err := dbSvc.fristenrechner.Calculate(r.Context(), req.ProceedingType, req.TriggerDate, services.CalcOptions{
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
PriorityDateStr: req.PriorityDate,
|
||||
Flags: req.Flags,
|
||||
AnchorOverrides: req.AnchorOverrides,
|
||||
CourtID: req.CourtID,
|
||||
PerCardAppellant: addendum.PerCardAppellant,
|
||||
SkipRules: addendum.SkipRules,
|
||||
IncludeCCRFor: addendum.IncludeCCRFor,
|
||||
IncludeHidden: req.IncludeHidden,
|
||||
AppealTarget: req.AppealTarget,
|
||||
IncludeOptional: req.IncludeOptional,
|
||||
TriggerEventAnchors: req.TriggerEventAnchors,
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrUnknownProceedingType) {
|
||||
|
||||
@@ -26,14 +26,20 @@ func noCacheAssets(h http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
// patentstyleDownload sets a Content-Disposition with the spaced filename
|
||||
// "HL Patents Style.dotm" for .dotm requests under /patentstyle/. The URL
|
||||
// path stays clean (dashes), browsers and download tools land the file
|
||||
// with the name PAs expect to see.
|
||||
// patentstyleDownload sets a Content-Disposition with a spaced filename
|
||||
// for .dotm requests under /patentstyle/, derived from the ACTUAL
|
||||
// requested file (dashes→spaces). The URL path stays clean (dashes),
|
||||
// browsers and download tools land the file with the spaced name PAs
|
||||
// expect to see — and, crucially, the name that the template's install
|
||||
// macro checks against (TEMPLATE_CHECK). Hardcoding the old "HL Patents
|
||||
// Style.dotm" made the rebranded HLC-Patents-Style.dotm save under the
|
||||
// wrong name, so its install macro rejected it (work/paliad 2026-06-01).
|
||||
func patentstyleDownload(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ".dotm") {
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="HL Patents Style.dotm"`)
|
||||
base := r.URL.Path[strings.LastIndex(r.URL.Path, "/")+1:]
|
||||
name := strings.ReplaceAll(base, "-", " ")
|
||||
w.Header().Set("Content-Disposition", `attachment; filename="`+name+`"`)
|
||||
}
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
@@ -105,6 +111,11 @@ type Services struct {
|
||||
// DashboardLayoutService.defaultLayout(). Nil-safe — falls back to
|
||||
// the code-resident FactoryDefaultLayout.
|
||||
FirmDashboardDefault *services.FirmDashboardDefaultService
|
||||
// FirmNameComposition is the firm-wide default name-composition map
|
||||
// (Slice 5). Admin-only writes; the render path reads it as the firm
|
||||
// tier below a per-user override. Nil-safe — falls back to the
|
||||
// code-resident system default.
|
||||
FirmNameComposition *services.FirmNameCompositionService
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
@@ -128,6 +139,10 @@ type Services struct {
|
||||
// editor. Per Q2: paste sources only, no lineage on sections.
|
||||
SubmissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store backing
|
||||
// the authoring surface.
|
||||
TemplateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-265 / m/paliad#96 — per-event-card optional choices on
|
||||
// the Verfahrensablauf timeline.
|
||||
EventChoice *services.EventChoiceService
|
||||
@@ -207,6 +222,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
cardLayout: svc.CardLayout,
|
||||
dashboardLayout: svc.DashboardLayout,
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
firmNameComposition: svc.FirmNameComposition,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
backup: svc.Backup,
|
||||
@@ -215,6 +231,8 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
submissionSection: svc.SubmissionSection,
|
||||
submissionComposer: svc.SubmissionComposer,
|
||||
submissionBuildingBlock: svc.SubmissionBuildingBlock,
|
||||
templateStore: svc.TemplateStore,
|
||||
previewImages: services.NewPreviewImageCacheFromEnv(),
|
||||
eventChoice: svc.EventChoice,
|
||||
scenario: svc.Scenario,
|
||||
scenarioFlags: svc.ScenarioFlags,
|
||||
@@ -296,15 +314,21 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
mux.Handle("GET /icons/", noCacheAssets(http.StripPrefix("/icons/", http.FileServer(http.Dir("dist/icons")))))
|
||||
mux.HandleFunc("GET /sw.js", servePWAServiceWorker)
|
||||
|
||||
// HL Patents Style auto-update endpoint. version.json is the manifest
|
||||
// the installed Word client polls; HL-Patents-Style.dotm is fetched on
|
||||
// version mismatch. Source files live in frontend/public/patentstyle/
|
||||
// (copied into dist/ at build time). noCacheAssets ensures the manifest
|
||||
// is never stale after a release. patentstyleDownload renames the .dotm
|
||||
// to "HL Patents Style.dotm" (with spaces) on download — the on-disk
|
||||
// filename has dashes so the URL is clean, but Word users expect the
|
||||
// spaced name in their downloads folder.
|
||||
mux.Handle("GET /patentstyle/", noCacheAssets(patentstyleDownload(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentstyle"))))))
|
||||
// HLC Patents Style auto-update endpoint. version.json is the manifest
|
||||
// the installed Word client polls; the .dotm is fetched on version
|
||||
// mismatch. Source files live in frontend/public/patentsstyle/ (copied
|
||||
// into dist/ at build time). noCacheAssets ensures the manifest is never
|
||||
// stale after a release. patentstyleDownload renames the .dotm to its
|
||||
// spaced name (derived from the requested file) on download.
|
||||
//
|
||||
// /patentsstyle/ (brand-consistent: patents+style) is the canonical path.
|
||||
// /patentstyle/ is a permanent ALIAS to the SAME dist dir: already-
|
||||
// installed templates hardcode paliad.msbls.de/patentstyle/version.json
|
||||
// as their sole auto-update URL (work/paliad 2026-06-01) — if it 404s,
|
||||
// every existing install silently stops auto-updating forever. Both
|
||||
// routes serve dist/patentsstyle.
|
||||
mux.Handle("GET /patentsstyle/", noCacheAssets(patentstyleDownload(http.StripPrefix("/patentsstyle/", http.FileServer(http.Dir("dist/patentsstyle"))))))
|
||||
mux.Handle("GET /patentstyle/", noCacheAssets(patentstyleDownload(http.StripPrefix("/patentstyle/", http.FileServer(http.Dir("dist/patentsstyle"))))))
|
||||
|
||||
// Protected routes
|
||||
protected := http.NewServeMux()
|
||||
@@ -455,6 +479,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// the sidebar picker. Wide-open SELECT (any authenticated user);
|
||||
// admin mutations are not exposed yet (Slice C).
|
||||
protected.HandleFunc("GET /api/submission-bases", handleListSubmissionBases)
|
||||
// t-paliad-370 S3 — structural HTML preview of a base/template for the
|
||||
// base-preview modal (draft-optional; S4 returns a truthful page image
|
||||
// behind the same query shape).
|
||||
protected.HandleFunc("GET /api/submission-preview", handleSubmissionPreview)
|
||||
// t-paliad-349 (m/paliad#157) docforge slice 5 — the variable
|
||||
// catalogue (Go-side SSOT) the sidebar form + authoring palette read.
|
||||
protected.HandleFunc("GET /api/docforge/variables", handleDocforgeVariables)
|
||||
// t-paliad-349 slice 7 — firm-shared template picker list for
|
||||
// generation (any authenticated lawyer; admin authoring stays gated).
|
||||
protected.HandleFunc("GET /api/templates", handlePickerTemplates)
|
||||
// t-paliad-313 (m/paliad#141) Composer Slice B — per-section PATCH
|
||||
// for inline editor autosave. URL keyed on draft_id + section_id;
|
||||
// owner-scoped via SubmissionDraftService.Get.
|
||||
@@ -491,6 +525,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("PUT /api/me/dashboard-layout", handlePutDashboardLayout)
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/reset", handleResetDashboardLayout)
|
||||
protected.HandleFunc("GET /api/dashboard-widget-catalog", handleGetWidgetCatalog)
|
||||
|
||||
// t-paliad-356 Slice 4 — per-user name-composition overrides (settings UX).
|
||||
// Token-template shorthand per wired artifact; parse/validate/preview run
|
||||
// server-side so the nomen engine stays the single source of truth.
|
||||
protected.HandleFunc("GET /api/me/name-compositions", handleGetNameCompositions)
|
||||
protected.HandleFunc("POST /api/me/name-compositions/preview", handlePreviewNameComposition)
|
||||
protected.HandleFunc("PUT /api/me/name-compositions/{artifact_id}", handlePutNameComposition)
|
||||
protected.HandleFunc("DELETE /api/me/name-compositions/{artifact_id}", handleDeleteNameComposition)
|
||||
|
||||
protected.HandleFunc("GET /api/projects/{id}/ancestors", handleListProjectAncestors)
|
||||
protected.HandleFunc("GET /api/projects/{id}/parties", handleListParties)
|
||||
protected.HandleFunc("POST /api/projects/{id}/parties", handleCreateParty)
|
||||
@@ -527,6 +570,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// retires the legacy routes.
|
||||
protected.HandleFunc("GET /api/builder/scenarios", handleBuilderScenariosList)
|
||||
protected.HandleFunc("POST /api/builder/scenarios", handleBuilderScenarioCreate)
|
||||
// m/paliad#153 B4 — Akte mode entry point. Creates a project-backed
|
||||
// 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)
|
||||
@@ -537,6 +587,13 @@ 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)
|
||||
// m/paliad#153 B3 — universal search (events + scenarios + projects).
|
||||
protected.HandleFunc("GET /api/builder/search", handleBuilderSearch)
|
||||
// Dev-only test route — gated to PaliadinOwnerEmail (m).
|
||||
protected.HandleFunc("GET /dev/scenario-builder", handleBuilderDevTestPage)
|
||||
|
||||
@@ -738,6 +795,15 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}", adminGate(users, handleAdminGetBackup))
|
||||
protected.HandleFunc("GET /api/admin/backups/{id}/file", adminGate(users, handleAdminDownloadBackup))
|
||||
|
||||
// t-paliad-349 docforge slice 6 — template authoring surface
|
||||
// (upload base .docx → place variable slots → save). Admin-only,
|
||||
// firm-shared catalog like submission_bases.
|
||||
protected.HandleFunc("GET /admin/templates", adminGate(users, gateOnboarded(handleTemplatesAuthoringPage)))
|
||||
protected.HandleFunc("GET /api/admin/templates", adminGate(users, handleListTemplates))
|
||||
protected.HandleFunc("POST /api/admin/templates", adminGate(users, handleUploadTemplate))
|
||||
protected.HandleFunc("GET /api/admin/templates/{id}", adminGate(users, handleGetTemplateAuthoring))
|
||||
protected.HandleFunc("POST /api/admin/templates/{id}/slots", adminGate(users, handlePlaceTemplateSlot))
|
||||
|
||||
protected.HandleFunc("GET /api/admin/users", adminGate(users, handleAdminListUsers))
|
||||
protected.HandleFunc("POST /api/admin/users", adminGate(users, handleAdminCreateUser))
|
||||
protected.HandleFunc("POST /api/admin/users/full", adminGate(users, handleAdminCreateFullUser))
|
||||
@@ -749,6 +815,13 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("GET /api/admin/firm-dashboard-default", adminGate(users, handleGetFirmDashboardDefault))
|
||||
protected.HandleFunc("PUT /api/admin/firm-dashboard-default", adminGate(users, handlePutFirmDashboardDefault))
|
||||
protected.HandleFunc("DELETE /api/admin/firm-dashboard-default", adminGate(users, handleDeleteFirmDashboardDefault))
|
||||
|
||||
// t-paliad-356 Slice 5 — firm-wide default name compositions. Admin
|
||||
// sets the house naming convention (the firm tier below per-user
|
||||
// overrides). Mirrors the firm-dashboard-default admin endpoints.
|
||||
protected.HandleFunc("GET /api/admin/name-compositions", adminGate(users, handleGetFirmNameCompositions))
|
||||
protected.HandleFunc("PUT /api/admin/name-compositions/{artifact_id}", adminGate(users, handlePutFirmNameComposition))
|
||||
protected.HandleFunc("DELETE /api/admin/name-compositions/{artifact_id}", adminGate(users, handleDeleteFirmNameComposition))
|
||||
protected.HandleFunc("POST /api/me/dashboard-layout/promote", adminGate(users, handlePromoteDashboardLayoutToFirmDefault))
|
||||
|
||||
// t-paliad-315 (m/paliad#141) Composer Slice C — admin building blocks editor.
|
||||
|
||||
325
internal/handlers/name_compositions.go
Normal file
325
internal/handlers/name_compositions.go
Normal file
@@ -0,0 +1,325 @@
|
||||
package handlers
|
||||
|
||||
// HTTP handlers for per-user name-composition overrides (t-paliad-356 Slice 4,
|
||||
// PRD §7). The /settings "Namensschemata" tab reads and writes a token-template
|
||||
// shorthand per wired artifact; these endpoints parse + validate + render
|
||||
// through the nomen engine (services), so the frontend never parses templates
|
||||
// itself.
|
||||
//
|
||||
// GET /api/me/name-compositions → all artifact cards
|
||||
// POST /api/me/name-compositions/preview → live preview + validation
|
||||
// PUT /api/me/name-compositions/{artifact_id} → store an override
|
||||
// DELETE /api/me/name-compositions/{artifact_id} → reset to system default
|
||||
//
|
||||
// Storage reuses the Slice-3 service surface
|
||||
// (SubmissionDraftService.UserNameCompositions / SetUserNameCompositions): the
|
||||
// PUT/DELETE handlers read the full spec, mutate one artifact key, and write it
|
||||
// back. No new column, no migration.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
)
|
||||
|
||||
// nameCompositionsService returns the wired SubmissionDraftService (the owner
|
||||
// of the name_compositions read/write path) or writes a 503 and returns nil.
|
||||
func nameCompositionsService(w http.ResponseWriter) *services.SubmissionDraftService {
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "name-composition service not configured"})
|
||||
return nil
|
||||
}
|
||||
return dbSvc.submissionDraft
|
||||
}
|
||||
|
||||
// firmNameCompositions loads the firm-wide default spec (empty when unset or
|
||||
// the firm service is unwired). Read on every card render so the effective
|
||||
// template reflects the firm tier.
|
||||
func firmNameCompositions(r *http.Request) services.NameCompositionSpec {
|
||||
if dbSvc.firmNameComposition == nil {
|
||||
return services.NameCompositionSpec{}
|
||||
}
|
||||
spec, _, err := dbSvc.firmNameComposition.Get(r.Context())
|
||||
if err != nil {
|
||||
return services.NameCompositionSpec{}
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
// GET /api/me/name-compositions — the caller's artifact cards with the
|
||||
// effective template (user override → firm default → system) per artifact,
|
||||
// palette, and live previews. is_admin tells the client whether to reveal the
|
||||
// firm-default admin controls.
|
||||
func handleGetNameCompositions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := nameCompositionsService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
overrides, err := svc.UserNameCompositions(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
firm := firmNameCompositions(r)
|
||||
isAdmin := false
|
||||
if dbSvc.users != nil {
|
||||
isAdmin, _ = dbSvc.users.IsAdmin(r.Context(), uid)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"artifacts": services.SettingsNameArtifacts(overrides, firm),
|
||||
"is_admin": isAdmin,
|
||||
})
|
||||
}
|
||||
|
||||
// POST /api/me/name-compositions/preview — render a candidate template against
|
||||
// the fixed sample without persisting it. Returns {ok:false, error} on a parse
|
||||
// or validation failure so the UI can show the error inline and disable Save.
|
||||
func handlePreviewNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
var in struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
full, empty, err := services.PreviewNameComposition(in.ArtifactID, in.Template)
|
||||
if err != nil {
|
||||
// A bad template is expected user input, not a server error — return
|
||||
// 200 with ok:false so the live-preview fetch path stays simple.
|
||||
writeJSON(w, http.StatusOK, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"ok": true,
|
||||
"preview_full": full,
|
||||
"preview_empty": empty,
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/me/name-compositions/{artifact_id} — validate the body template and
|
||||
// store it as the caller's override for that artifact. Returns the refreshed
|
||||
// card.
|
||||
func handlePutNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := nameCompositionsService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
var in struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
comp, err := services.ParseNameTemplate(artifactID, in.Template)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
spec, err := svc.UserNameCompositions(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if spec == nil {
|
||||
spec = services.NameCompositionSpec{}
|
||||
}
|
||||
spec[artifactID] = comp
|
||||
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, spec, firmNameCompositions(r))
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// DELETE /api/me/name-compositions/{artifact_id} — drop the caller's override
|
||||
// for that artifact; the artifact reverts to the system default. Returns the
|
||||
// refreshed card. Deleting an absent override is a no-op (still 200).
|
||||
func handleDeleteNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := nameCompositionsService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
if _, ok := services.NameArtifact(artifactID); !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown name artifact"})
|
||||
return
|
||||
}
|
||||
spec, err := svc.UserNameCompositions(r.Context(), uid)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if _, present := spec[artifactID]; present {
|
||||
delete(spec, artifactID)
|
||||
if err := svc.SetUserNameCompositions(r.Context(), uid, spec); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, spec, firmNameCompositions(r))
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Firm-wide default (admin) — t-paliad-356 Slice 5.
|
||||
//
|
||||
// Mirrors the firm_dashboard_default admin endpoints. All three sit behind the
|
||||
// adminGate in handlers.go. The firm default is the tier below a per-user
|
||||
// override and above the system default; setting/clearing it changes the
|
||||
// effective name for every user who has no personal override.
|
||||
//
|
||||
// GET /api/admin/name-compositions → firm-tier cards
|
||||
// PUT /api/admin/name-compositions/{artifact_id} → set firm default
|
||||
// DELETE /api/admin/name-compositions/{artifact_id} → clear firm default
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// firmAdminService returns the wired FirmNameCompositionService or writes 503.
|
||||
func firmAdminService(w http.ResponseWriter) *services.FirmNameCompositionService {
|
||||
if dbSvc.firmNameComposition == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "firm-name-composition service not configured"})
|
||||
return nil
|
||||
}
|
||||
return dbSvc.firmNameComposition
|
||||
}
|
||||
|
||||
// GET /api/admin/name-compositions — the firm-tier cards. Each card's
|
||||
// firm_is_set/firm_template reflects the firm default; the effective template
|
||||
// is computed with no user override (the admin views the firm tier, not their
|
||||
// personal one).
|
||||
func handleGetFirmNameCompositions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if firmAdminService(w) == nil {
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"artifacts": services.SettingsNameArtifacts(nil, firmNameCompositions(r)),
|
||||
})
|
||||
}
|
||||
|
||||
// PUT /api/admin/name-compositions/{artifact_id} — set the firm default for an
|
||||
// artifact from the body template. Returns the refreshed firm-tier card.
|
||||
func handlePutFirmNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := firmAdminService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
var in struct {
|
||||
Template string `json:"template"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
comp, err := services.ParseNameTemplate(artifactID, in.Template)
|
||||
if err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
spec, _, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if spec == nil {
|
||||
spec = services.NameCompositionSpec{}
|
||||
}
|
||||
spec[artifactID] = comp
|
||||
if _, err := svc.Set(r.Context(), spec, uid); err != nil {
|
||||
if errors.Is(err, services.ErrInvalidInput) {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, nil, spec)
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
|
||||
// DELETE /api/admin/name-compositions/{artifact_id} — drop the firm default
|
||||
// for an artifact; it reverts to the system default for everyone without a
|
||||
// personal override. Returns the refreshed firm-tier card. No-op when absent.
|
||||
func handleDeleteFirmNameComposition(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
svc := firmAdminService(w)
|
||||
if svc == nil {
|
||||
return
|
||||
}
|
||||
artifactID := r.PathValue("artifact_id")
|
||||
if _, ok := services.NameArtifact(artifactID); !ok {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "unknown name artifact"})
|
||||
return
|
||||
}
|
||||
spec, _, err := svc.Get(r.Context())
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
if _, present := spec[artifactID]; present {
|
||||
delete(spec, artifactID)
|
||||
if _, err := svc.Set(r.Context(), spec, uid); err != nil {
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
view, _ := services.SettingsNameArtifact(artifactID, nil, spec)
|
||||
writeJSON(w, http.StatusOK, view)
|
||||
}
|
||||
@@ -18,49 +18,53 @@ import (
|
||||
// dbServices bundles the Phase B services so handlers can stay thin.
|
||||
// Nil if DATABASE_URL was unset at startup.
|
||||
type dbServices struct {
|
||||
projects *services.ProjectService
|
||||
team *services.TeamService
|
||||
partnerUnit *services.PartnerUnitService
|
||||
parties *services.PartyService
|
||||
deadline *services.DeadlineService
|
||||
appointment *services.AppointmentService
|
||||
caldav *services.CalDAVService
|
||||
caldavBindings *services.CalendarBindingService
|
||||
rules *services.DeadlineRuleService
|
||||
calc *services.DeadlineCalculator
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
eventTrigger *services.EventTriggerService
|
||||
ruleEditor *services.RuleEditorService
|
||||
deadlineSearch *services.DeadlineSearchService
|
||||
eventCategory *services.EventCategoryService
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistShare *services.ChecklistShareService
|
||||
checklistPromotion *services.ChecklistPromotionService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
audit *services.AuditService
|
||||
emailTemplate *services.EmailTemplateService
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
courts *services.CourtService
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
projects *services.ProjectService
|
||||
team *services.TeamService
|
||||
partnerUnit *services.PartnerUnitService
|
||||
parties *services.PartyService
|
||||
deadline *services.DeadlineService
|
||||
appointment *services.AppointmentService
|
||||
caldav *services.CalDAVService
|
||||
caldavBindings *services.CalendarBindingService
|
||||
rules *services.DeadlineRuleService
|
||||
calc *services.DeadlineCalculator
|
||||
users *services.UserService
|
||||
fristenrechner *services.FristenrechnerService
|
||||
eventDeadline *services.EventDeadlineService
|
||||
eventTrigger *services.EventTriggerService
|
||||
ruleEditor *services.RuleEditorService
|
||||
deadlineSearch *services.DeadlineSearchService
|
||||
eventCategory *services.EventCategoryService
|
||||
eventType *services.EventTypeService
|
||||
dashboard *services.DashboardService
|
||||
note *services.NoteService
|
||||
checklistInst *services.ChecklistInstanceService
|
||||
checklistCatalog *services.ChecklistCatalogService
|
||||
checklistTemplate *services.ChecklistTemplateService
|
||||
checklistShare *services.ChecklistShareService
|
||||
checklistPromotion *services.ChecklistPromotionService
|
||||
mail *services.MailService
|
||||
invite *services.InviteService
|
||||
agenda *services.AgendaService
|
||||
audit *services.AuditService
|
||||
emailTemplate *services.EmailTemplateService
|
||||
link *services.LinkService
|
||||
event *services.EventService
|
||||
courts *services.CourtService
|
||||
approval *services.ApprovalService
|
||||
derivation *services.DerivationService
|
||||
userView *services.UserViewService
|
||||
broadcast *services.BroadcastService
|
||||
pin *services.PinService
|
||||
cardLayout *services.CardLayoutService
|
||||
dashboardLayout *services.DashboardLayoutService
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
// t-paliad-356 Slice 5 — firm-wide default name compositions (the firm
|
||||
// tier of the name-composition precedence chain). Nil-safe: the render
|
||||
// path falls through to user override / system default.
|
||||
firmNameComposition *services.FirmNameCompositionService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
|
||||
// t-paliad-246 — Backup Mode orchestrator. Nil when DATABASE_URL or
|
||||
// PALIAD_EXPORT_DIR is unset (the /admin/backups routes return 503).
|
||||
@@ -77,6 +81,14 @@ type dbServices struct {
|
||||
submissionComposer *services.SubmissionComposer
|
||||
submissionBuildingBlock *services.BuildingBlockService
|
||||
|
||||
// t-paliad-349 docforge slice 4/6 — uploaded-template store.
|
||||
templateStore *services.PgTemplateStore
|
||||
|
||||
// t-paliad-370 S4 — truthful base-preview render cache (.docx→PDF→PNG via
|
||||
// a Gotenberg sidecar + poppler). Available() is false until the sidecar
|
||||
// is provisioned, so the preview endpoint falls back to structural HTML.
|
||||
previewImages *services.PreviewImageCache
|
||||
|
||||
// t-paliad-265 — per-event-card optional choices.
|
||||
eventChoice *services.EventChoiceService
|
||||
|
||||
@@ -403,12 +415,13 @@ func handleListProjectChildren(w http.ResponseWriter, r *http.Request) {
|
||||
// render the full hierarchy in one round-trip. Visibility-scoped.
|
||||
//
|
||||
// Query parameters (all optional, additive):
|
||||
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
||||
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
||||
// ?type=client,litigation,patent,case,project,other — type whitelist
|
||||
// ?has_open_deadlines=true|false — narrow by deadline activity
|
||||
// ?q=<term> — search title / reference / clientmatter
|
||||
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
||||
//
|
||||
// ?scope=all|mine|pinned — chip-driven scope (default "all")
|
||||
// ?status=active,archived,closed — status whitelist (CSV; default = no narrowing)
|
||||
// ?type=client,litigation,patent,case,project,other — type whitelist
|
||||
// ?has_open_deadlines=true|false — narrow by deadline activity
|
||||
// ?q=<term> — search title / reference / clientmatter
|
||||
// ?subtree_counts=true|false — populate *_subtree fields (default true)
|
||||
//
|
||||
// Zero query string preserves the legacy behaviour for back-compat (existing
|
||||
// callers that just want every visible project).
|
||||
|
||||
@@ -52,6 +52,51 @@ func writeBuilderError(w http.ResponseWriter, err error) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Akte mode (B4, t-paliad-347)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderScenarioFromProject — POST /api/builder/scenarios/from-project
|
||||
//
|
||||
// Body: {"project_id": "<uuid>"}
|
||||
//
|
||||
// Creates a fresh project-backed scenario by snapshotting the project's
|
||||
// proceeding_type_id + our_side + scenario_flags into one top-level
|
||||
// triplet, and seeds scenario_events from every existing
|
||||
// paliad.deadlines row tied to a sequencing_rule. The new scenario's
|
||||
// origin_project_id pins the Akte link so subsequent edits dual-write
|
||||
// through to paliad.deadlines + paliad.projects.scenario_flags (PRD §2.3).
|
||||
//
|
||||
// Visibility: caller must be able to see the project. Bad input
|
||||
// (missing proceeding_type_id, invisible project) returns 400 / 404
|
||||
// via the standard service-error mapping.
|
||||
func handleBuilderScenarioFromProject(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireScenarioBuilderService(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "ungültige Anfrage"})
|
||||
return
|
||||
}
|
||||
if body.ProjectID == uuid.Nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "project_id ist erforderlich"})
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioBuilder.CreateScenarioFromProject(r.Context(), uid, body.ProjectID)
|
||||
if err != nil {
|
||||
writeBuilderError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Scenario CRUD
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -388,6 +433,100 @@ 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// handleBuilderFlagCatalog — GET /api/builder/scenario-flag-catalog
|
||||
//
|
||||
// Returns every row of paliad.scenario_flag_catalog so the Litigation
|
||||
// Builder can render per-triplet flag toggles without a per-project
|
||||
// round-trip. The catalog itself is global (no jurisdiction or
|
||||
// proceeding scope baked into the table); which flags actually apply
|
||||
// to a given proceeding type is decided by the calc engine via
|
||||
// condition_expr at calculation time. The client renders every catalog
|
||||
// flag and lets the user toggle them — flags with no effect on the
|
||||
// active proceeding's rules simply have no condition_expr referencing
|
||||
// them, so toggling is a no-op.
|
||||
//
|
||||
// 503 when ScenarioFlagsService is nil (DATABASE_URL unset); per-row
|
||||
// visibility checks aren't needed because the catalog is global.
|
||||
func handleBuilderFlagCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if dbSvc == nil || dbSvc.scenarioFlags == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "Flag-Katalog vorübergehend nicht verfügbar (keine Datenbank).",
|
||||
})
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
out, err := dbSvc.scenarioFlags.ListCatalog(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "Flag-Katalog konnte nicht geladen werden",
|
||||
})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-only test route
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -30,6 +30,9 @@ package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/base64"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
@@ -44,6 +47,8 @@ import (
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/internal/services"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// submissionDraftPreviewTimeout caps a single preview round-trip.
|
||||
@@ -115,10 +120,14 @@ type submissionDraftJSON struct {
|
||||
// pre-Composer drafts; the editor sidebar surfaces this in the
|
||||
// base picker. PATCH accepts {"base_id": "<uuid>"} or
|
||||
// {"base_id": null} to set or clear.
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
BaseID *uuid.UUID `json:"base_id"`
|
||||
// TemplateVersionID — pinned uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). NULL = base_id/v1 path. The editor's picker
|
||||
// surfaces this; PATCH accepts {"template_version_id": "<uuid>"} | null.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id"`
|
||||
ComposerMeta map[string]any `json:"composer_meta"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// submissionSectionJSON is the on-the-wire row for each per-draft
|
||||
@@ -126,15 +135,15 @@ type submissionDraftJSON struct {
|
||||
// section stack but doesn't yet edit prose. Slice B makes content_md_*
|
||||
// editable + adds the PATCH endpoint.
|
||||
type submissionSectionJSON struct {
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
ID uuid.UUID `json:"id"`
|
||||
SectionKey string `json:"section_key"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
Kind string `json:"kind"`
|
||||
LabelDE string `json:"label_de"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Included bool `json:"included"`
|
||||
ContentMDDE string `json:"content_md_de"`
|
||||
ContentMDEN string `json:"content_md_en"`
|
||||
}
|
||||
|
||||
type submissionRuleSummary struct {
|
||||
@@ -170,6 +179,16 @@ type submissionDraftPatchInput struct {
|
||||
// admin-recovery flows).
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
BaseIDSet bool `json:"-"`
|
||||
// TemplateVersionID pins an uploaded docforge template version
|
||||
// (t-paliad-349 slice 7). Same three-state presence contract as
|
||||
// base_id: absent = no change, uuid = pin, null = clear.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
TemplateVersionIDSet bool `json:"-"`
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear back
|
||||
// to the auto-derived rule name; "x" = set. Persisted in
|
||||
// composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON on submissionDraftPatchInput sets BaseIDSet=true if
|
||||
@@ -193,6 +212,9 @@ func (p *submissionDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
if _, ok := raw["base_id"]; ok {
|
||||
p.BaseIDSet = true
|
||||
}
|
||||
if _, ok := raw["template_version_id"]; ok {
|
||||
p.TemplateVersionIDSet = true
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -433,10 +455,17 @@ func handlePatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: input.Variables,
|
||||
SelectedParties: input.SelectedParties,
|
||||
Language: input.Language,
|
||||
FilenameKeyword: input.FilenameKeyword,
|
||||
}
|
||||
if input.BaseIDSet {
|
||||
patch.BaseID = &input.BaseID
|
||||
}
|
||||
if input.TemplateVersionIDSet {
|
||||
if !validateTemplateVersionPin(w, r.Context(), input.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
patch.TemplateVersionID = &input.TemplateVersionID
|
||||
}
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
@@ -517,7 +546,7 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
tplBytes, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
tplBytes, err := previewTemplateBytes(ctx, d)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch (draft=%s): %v", draftID, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
@@ -532,6 +561,279 @@ func handlePreviewSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]string{"preview_html": html})
|
||||
}
|
||||
|
||||
// handleSubmissionPreview renders a STRUCTURAL HTML preview of a chosen base
|
||||
// (or uploaded template) for the base-preview modal (t-paliad-370 S3). It is
|
||||
// the cheap-rails counterpart of the truthful .docx→image render added in S4 —
|
||||
// same query shape, so S4 can return a real page image behind the same modal.
|
||||
//
|
||||
// GET /api/submission-preview
|
||||
// ?base=<base_id uuid | "tpl:"+version_id | ""> which template to render
|
||||
// &code=<submission_code> caption + fallback template
|
||||
// &lang=de|en
|
||||
// &data=mine|sample missing-value rendering
|
||||
// &draft=<draft uuid> optional: use the draft's bag
|
||||
// &project=<project uuid> optional (no draft): project data
|
||||
//
|
||||
// No persistence. With a draft + data=mine the draft's resolved bag is used, so
|
||||
// the modal previews a base the draft has NOT committed to. Otherwise a fresh
|
||||
// context bag is built for (user, project?, code, lang). data=sample swaps the
|
||||
// missing-marker so unresolved placeholders render readable sample text instead
|
||||
// of [KEIN WERT] (a fresh/project-less draft then previews a full page).
|
||||
func handleSubmissionPreview(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
|
||||
return
|
||||
}
|
||||
|
||||
q := r.URL.Query()
|
||||
baseRef := strings.TrimSpace(q.Get("base"))
|
||||
code := strings.TrimSpace(q.Get("code"))
|
||||
lang := normalizePreviewLang(q.Get("lang"))
|
||||
sample := strings.EqualFold(strings.TrimSpace(q.Get("data")), "sample")
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionDraftPreviewTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Editor context: load the draft so we can use its resolved bag and
|
||||
// default code/lang from it.
|
||||
var draft *services.SubmissionDraft
|
||||
if draftRef := strings.TrimSpace(q.Get("draft")); draftRef != "" {
|
||||
id, perr := uuid.Parse(draftRef)
|
||||
if perr != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid draft id"})
|
||||
return
|
||||
}
|
||||
d, derr := dbSvc.submissionDraft.Get(ctx, uid, id)
|
||||
if derr != nil {
|
||||
writeSubmissionDraftServiceError(w, derr)
|
||||
return
|
||||
}
|
||||
draft = d
|
||||
if code == "" {
|
||||
code = draft.SubmissionCode
|
||||
}
|
||||
if q.Get("lang") == "" {
|
||||
lang = normalizePreviewLang(draft.Language)
|
||||
}
|
||||
}
|
||||
if code == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "code required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Catalog (no-draft) context: an optional project scopes the data.
|
||||
var projectID *uuid.UUID
|
||||
if draft == nil {
|
||||
if projectRef := strings.TrimSpace(q.Get("project")); projectRef != "" {
|
||||
if pid, perr := uuid.Parse(projectRef); perr == nil {
|
||||
projectID = &pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var missing docforge.MissingPlaceholderFn
|
||||
if sample {
|
||||
missing = sampleMissingMarker(lang)
|
||||
}
|
||||
|
||||
// Truthful path (S4): the real .docx → page images, when the Gotenberg
|
||||
// sidecar + poppler are provisioned. Any failure falls through to the
|
||||
// structural HTML render below, so the modal always shows something.
|
||||
if strings.EqualFold(strings.TrimSpace(q.Get("fidelity")), "truthful") && dbSvc.previewImages.Available() {
|
||||
key := previewCacheKey(draft, baseRef, code, lang, sample, projectID)
|
||||
pages, perr := dbSvc.previewImages.Pages(ctx, key, func() ([]byte, error) {
|
||||
return buildPreviewDocx(ctx, uid, draft, baseRef, code, lang, projectID, missing)
|
||||
})
|
||||
if perr == nil && len(pages) > 0 {
|
||||
uris := make([]string, len(pages))
|
||||
for i, p := range pages {
|
||||
uris[i] = "data:image/png;base64," + base64.StdEncoding.EncodeToString(p)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"truthful": true, "pages": uris})
|
||||
return
|
||||
}
|
||||
if perr != nil {
|
||||
log.Printf("submission_preview: truthful render (base=%q code=%q) failed: %v — structural fallback", baseRef, code, perr)
|
||||
}
|
||||
}
|
||||
|
||||
// Structural path (S3): RenderHTML on the resolved template bytes.
|
||||
tplBytes, err := resolvePreviewTemplateBytes(ctx, baseRef, code, lang)
|
||||
if err != nil {
|
||||
log.Printf("submission_preview: template resolve (base=%q code=%q): %v", baseRef, code, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
if draft != nil {
|
||||
html, rerr := dbSvc.submissionDraft.RenderPreviewWithMarker(ctx, draft, tplBytes, missing)
|
||||
if rerr != nil {
|
||||
log.Printf("submission_preview: render draft=%s: %v", draft.ID, rerr)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"truthful": false, "preview_html": html})
|
||||
return
|
||||
}
|
||||
html, rerr := dbSvc.submissionDraft.RenderContextPreviewHTML(ctx, uid, projectID, code, lang, tplBytes, missing)
|
||||
if rerr != nil {
|
||||
log.Printf("submission_preview: render context (code=%q project=%v): %v", code, projectID, rerr)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"truthful": false, "preview_html": html})
|
||||
}
|
||||
|
||||
// buildPreviewDocx produces the truthful .docx for the base-preview render
|
||||
// (t-paliad-370 S4) using the SAME export pipeline a real export uses, so the
|
||||
// preview is byte-faithful. Editor (draft) context clones the draft with the
|
||||
// base override and runs the full pipeline — including the Composer for
|
||||
// anchors-only bases, which resolves their real letterhead/Rubrum styling
|
||||
// (the thing S3's structural render could not show). Catalog (no-draft) context
|
||||
// renders the resolved template bytes with a fresh context bag. No persistence.
|
||||
func buildPreviewDocx(ctx context.Context, uid uuid.UUID, draft *services.SubmissionDraft, baseRef, code, lang string, projectID *uuid.UUID, missing docforge.MissingPlaceholderFn) ([]byte, error) {
|
||||
if draft != nil {
|
||||
clone := *draft
|
||||
applyPreviewBaseOverride(&clone, baseRef)
|
||||
docx, _, _, _, err := exportSubmissionDraft(ctx, &clone, missing)
|
||||
return docx, err
|
||||
}
|
||||
tplBytes, err := resolvePreviewTemplateBytes(ctx, baseRef, code, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dbSvc.submissionDraft.RenderContextPreviewDocx(ctx, uid, projectID, code, lang, tplBytes, missing)
|
||||
}
|
||||
|
||||
// applyPreviewBaseOverride points a draft clone at the chosen base WITHOUT
|
||||
// persisting, so the preview can show a base the draft has not committed to.
|
||||
func applyPreviewBaseOverride(d *services.SubmissionDraft, baseRef string) {
|
||||
switch {
|
||||
case strings.HasPrefix(baseRef, "tpl:"):
|
||||
if vid, err := uuid.Parse(strings.TrimPrefix(baseRef, "tpl:")); err == nil {
|
||||
d.TemplateVersionID = &vid
|
||||
d.BaseID = nil
|
||||
}
|
||||
case baseRef != "":
|
||||
if bid, err := uuid.Parse(baseRef); err == nil {
|
||||
d.BaseID = &bid
|
||||
d.TemplateVersionID = nil
|
||||
}
|
||||
default:
|
||||
d.BaseID = nil
|
||||
d.TemplateVersionID = nil
|
||||
}
|
||||
}
|
||||
|
||||
// previewCacheKey keys the truthful-render cache. For a draft it folds in the
|
||||
// draft id + updated_at (a cheap proxy for hash(resolved-bag): the draft's
|
||||
// content/data bumps updated_at); for the catalog path it folds in code +
|
||||
// project. Both add base + lang + data-mode.
|
||||
func previewCacheKey(draft *services.SubmissionDraft, baseRef, code, lang string, sample bool, projectID *uuid.UUID) string {
|
||||
mode := "mine"
|
||||
if sample {
|
||||
mode = "sample"
|
||||
}
|
||||
var raw string
|
||||
if draft != nil {
|
||||
raw = "d|" + draft.ID.String() + "|" + draft.UpdatedAt.Format(time.RFC3339Nano) + "|" + baseRef + "|" + lang + "|" + mode
|
||||
} else {
|
||||
pid := ""
|
||||
if projectID != nil {
|
||||
pid = projectID.String()
|
||||
}
|
||||
raw = "c|" + code + "|" + pid + "|" + baseRef + "|" + lang + "|" + mode
|
||||
}
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// normalizePreviewLang clamps the preview lang query to "de" (default) or "en".
|
||||
func normalizePreviewLang(s string) string {
|
||||
if strings.EqualFold(strings.TrimSpace(s), "en") {
|
||||
return "en"
|
||||
}
|
||||
return "de"
|
||||
}
|
||||
|
||||
// resolvePreviewTemplateBytes resolves the template bytes the base-preview modal
|
||||
// should render for a base reference. Uploaded templates ("tpl:<v>") render
|
||||
// their carrier directly; a Gitea base_id renders its bytes only if they carry
|
||||
// {{merge placeholders}} — anchors-only Composer bases (the styled HL skeletons)
|
||||
// can't be shown as structural HTML, so they fall back to the submission_code's
|
||||
// merge template (matching today's preview-pane semantics). The per-base
|
||||
// letterhead/styling becomes visible in S4's truthful .docx render.
|
||||
func resolvePreviewTemplateBytes(ctx context.Context, baseRef, code, lang string) ([]byte, error) {
|
||||
switch {
|
||||
case strings.HasPrefix(baseRef, "tpl:"):
|
||||
if dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, strings.TrimPrefix(baseRef, "tpl:"))
|
||||
switch {
|
||||
case err == nil:
|
||||
return tmpl.CarrierBytes, nil
|
||||
case !errors.Is(err, docforge.ErrTemplateNotFound):
|
||||
return nil, err
|
||||
}
|
||||
// missing pinned version → fall through to the code template
|
||||
}
|
||||
case baseRef != "":
|
||||
if dbSvc.submissionBase != nil {
|
||||
if id, perr := uuid.Parse(baseRef); perr == nil {
|
||||
if base, berr := dbSvc.submissionBase.GetByID(ctx, id); berr == nil {
|
||||
if b, _, ferr := fetchComposerBaseBytes(ctx, base); ferr == nil && docx.HasMergePlaceholders(b) {
|
||||
return b, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
b, _, _, err := resolveSubmissionTemplate(ctx, code, lang)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// sampleMissingMarker renders unresolved placeholders as readable sample text
|
||||
// (the modal's "Beispiel" data-mode) instead of [KEIN WERT], so a fresh or
|
||||
// project-less draft previews a full page. Matching is by key suffix/substring
|
||||
// so it is robust to the exact namespace; unknown keys get a generic sample
|
||||
// token. Cosmetic only — S4's truthful render fills sample data end-to-end.
|
||||
func sampleMissingMarker(lang string) docforge.MissingPlaceholderFn {
|
||||
en := strings.EqualFold(lang, "en")
|
||||
return func(key string) string {
|
||||
k := strings.ToLower(key)
|
||||
switch {
|
||||
case strings.Contains(k, "case_number"):
|
||||
return "4c O 12/23"
|
||||
case strings.Contains(k, "claimant") && strings.Contains(k, "name"):
|
||||
if en {
|
||||
return "Sample Claimant Ltd."
|
||||
}
|
||||
return "Mustermandant GmbH"
|
||||
case strings.Contains(k, "defendant") && strings.Contains(k, "name"):
|
||||
if en {
|
||||
return "Sample Defendant Inc."
|
||||
}
|
||||
return "Musterbeklagte AG"
|
||||
case strings.Contains(k, "court"):
|
||||
if en {
|
||||
return "Düsseldorf Regional Court"
|
||||
}
|
||||
return "Landgericht Düsseldorf"
|
||||
default:
|
||||
if en {
|
||||
return "Sample"
|
||||
}
|
||||
return "Beispiel"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// handleExportSubmissionDraft merges the draft into the .docx template
|
||||
// and streams the result. Writes one system_audit_log row and one
|
||||
// project_events row per successful export.
|
||||
@@ -566,14 +868,14 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d, nil)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionDownloadFilename(ctx, uid, resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
// Audit + provenance updates are best-effort on a background
|
||||
// context so the download still succeeds if the DB races.
|
||||
@@ -597,6 +899,48 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// validateTemplateVersionPin checks that a non-nil template-version pin
|
||||
// refers to an existing version (404 otherwise), so a PATCH can't bind a
|
||||
// draft to a vanished template. A nil pin (clear) is always valid. Returns
|
||||
// true when the patch may proceed; writes the error response otherwise.
|
||||
func validateTemplateVersionPin(w http.ResponseWriter, ctx context.Context, pin *uuid.UUID) bool {
|
||||
if pin == nil {
|
||||
return true
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
if _, err := dbSvc.templateStore.GetVersion(ctx, pin.String()); err != nil {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template version not found"})
|
||||
} else {
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// previewTemplateBytes returns the carrier bytes to render a draft's
|
||||
// preview: the pinned uploaded-template version's carrier when set
|
||||
// (t-paliad-349 slice 7), otherwise the resolved upstream submission
|
||||
// template (v1/legacy path). A missing pinned version falls through to the
|
||||
// upstream resolution rather than failing.
|
||||
func previewTemplateBytes(ctx context.Context, d *services.SubmissionDraft) ([]byte, error) {
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
if err == nil {
|
||||
return tmpl.CarrierBytes, nil
|
||||
}
|
||||
if !errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
b, _, _, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
return b, err
|
||||
}
|
||||
|
||||
// exportSubmissionDraft is the shared render entry point used by both
|
||||
// the project-scoped and global export handlers (t-paliad-313 Slice B).
|
||||
// Branches on draft.BaseID: if set AND the base + bytes resolve, the
|
||||
@@ -606,7 +950,31 @@ func handleExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
// can tell the two paths apart in the feed.
|
||||
//
|
||||
// Returns (bytes, resolved-bag, templateSHA, composerUsed, err).
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
// exportSubmissionDraft takes an optional missing-value marker (nil == the
|
||||
// default DE/EN marker, so the export handlers are unchanged). The truthful
|
||||
// base-preview (t-paliad-370 S4) passes a sample marker for "Beispiel" mode.
|
||||
func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft, missing docforge.MissingPlaceholderFn) ([]byte, *services.SubmissionVarsResult, string, bool, error) {
|
||||
// t-paliad-349 slice 7 — uploaded-template path, checked first. The
|
||||
// pinned version's carrier already carries {{slots}}; Export resolves
|
||||
// the bag + substitutes them via the same renderer the v1 path uses
|
||||
// (no Composer/sections — the uploaded doc IS the document). A missing
|
||||
// pinned version falls through to the base_id / v1 paths.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
tmpl, err := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String())
|
||||
switch {
|
||||
case err == nil:
|
||||
docx, resolved, rerr := dbSvc.submissionDraft.ExportWithMarker(ctx, d, tmpl.CarrierBytes, missing)
|
||||
if rerr != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", rerr)
|
||||
}
|
||||
return docx, resolved, "", false, nil
|
||||
case errors.Is(err, docforge.ErrTemplateNotFound):
|
||||
log.Printf("submission_drafts: pinned template version missing (draft=%s version=%s) — falling back", d.ID, *d.TemplateVersionID)
|
||||
default:
|
||||
return nil, nil, "", false, fmt.Errorf("template version lookup: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if d.BaseID != nil && dbSvc.submissionBase != nil && dbSvc.submissionSection != nil && dbSvc.submissionComposer != nil {
|
||||
base, err := dbSvc.submissionBase.GetByID(ctx, *d.BaseID)
|
||||
switch {
|
||||
@@ -621,13 +989,17 @@ func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]
|
||||
if err != nil {
|
||||
return nil, nil, "", false, err
|
||||
}
|
||||
composeMissing := missing
|
||||
if composeMissing == nil {
|
||||
composeMissing = services.DefaultMissingMarker(resolved.Lang)
|
||||
}
|
||||
docx, err := dbSvc.submissionComposer.Compose(ctx, services.ComposeOptions{
|
||||
Sections: sections,
|
||||
Base: base,
|
||||
BaseBytes: baseBytes,
|
||||
Lang: resolved.Lang,
|
||||
Vars: bag,
|
||||
Missing: services.DefaultMissingMarker(resolved.Lang),
|
||||
Missing: composeMissing,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("composer: %w", err)
|
||||
@@ -649,7 +1021,7 @@ func exportSubmissionDraft(ctx context.Context, d *services.SubmissionDraft) ([]
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("template upstream: %w", err)
|
||||
}
|
||||
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
|
||||
docx, resolved, err := dbSvc.submissionDraft.ExportWithMarker(ctx, d, tplBytes, missing)
|
||||
if err != nil {
|
||||
return nil, nil, "", false, fmt.Errorf("render: %w", err)
|
||||
}
|
||||
@@ -853,16 +1225,26 @@ type globalDraftPatchInput struct {
|
||||
// by UnmarshalJSON. t-paliad-313 Composer Slice A.
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
baseIDProvided bool
|
||||
// TemplateVersionID + provided flag — uploaded-template pin
|
||||
// (t-paliad-349 slice 7), same present/absent contract as base_id.
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
templateVersionIDProvided bool
|
||||
// FilenameKeyword overrides the leading keyword of the exported
|
||||
// document name (t-paliad-354). Absent = no change; "" = clear; "x" =
|
||||
// set. Persisted in composer_meta.filename_keyword.
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
|
||||
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
type alias struct {
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Variables *services.PlaceholderMap `json:"variables,omitempty"`
|
||||
Language *string `json:"language,omitempty"`
|
||||
ProjectID *uuid.UUID `json:"project_id,omitempty"`
|
||||
SelectedParties *[]uuid.UUID `json:"selected_parties,omitempty"`
|
||||
BaseID *uuid.UUID `json:"base_id,omitempty"`
|
||||
TemplateVersionID *uuid.UUID `json:"template_version_id,omitempty"`
|
||||
FilenameKeyword *string `json:"filename_keyword,omitempty"`
|
||||
}
|
||||
var a alias
|
||||
if err := json.Unmarshal(data, &a); err != nil {
|
||||
@@ -874,14 +1256,17 @@ func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
|
||||
g.ProjectID = a.ProjectID
|
||||
g.SelectedParties = a.SelectedParties
|
||||
g.BaseID = a.BaseID
|
||||
// Detect whether "project_id" / "base_id" were present in the JSON
|
||||
// object.
|
||||
g.TemplateVersionID = a.TemplateVersionID
|
||||
g.FilenameKeyword = a.FilenameKeyword
|
||||
// Detect whether "project_id" / "base_id" / "template_version_id" were
|
||||
// present in the JSON object.
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
_, g.projectIDProvided = raw["project_id"]
|
||||
_, g.baseIDProvided = raw["base_id"]
|
||||
_, g.templateVersionIDProvided = raw["template_version_id"]
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -917,6 +1302,7 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
Variables: in.Variables,
|
||||
SelectedParties: in.SelectedParties,
|
||||
Language: in.Language,
|
||||
FilenameKeyword: in.FilenameKeyword,
|
||||
}
|
||||
if in.projectIDProvided {
|
||||
pid := in.ProjectID // may be nil → detach
|
||||
@@ -926,6 +1312,13 @@ func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
bid := in.BaseID // may be nil → clear
|
||||
patch.BaseID = &bid
|
||||
}
|
||||
if in.templateVersionIDProvided {
|
||||
if !validateTemplateVersionPin(w, r.Context(), in.TemplateVersionID) {
|
||||
return
|
||||
}
|
||||
tv := in.TemplateVersionID // may be nil → clear
|
||||
patch.TemplateVersionID = &tv
|
||||
}
|
||||
|
||||
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
|
||||
if err != nil {
|
||||
@@ -1038,14 +1431,14 @@ func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
|
||||
writeSubmissionDraftServiceError(w, err)
|
||||
return
|
||||
}
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d)
|
||||
docx, resolved, tplSHA, composerUsed, err := exportSubmissionDraft(ctx, d, nil)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: export (draft=%s): %v", draftID, err)
|
||||
writeSubmissionExportError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
filename := submissionDownloadFilename(ctx, uid, resolved.Rule, resolved.Project, resolved.Lang, submissionFilenameKeyword(d))
|
||||
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
@@ -1155,6 +1548,23 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
view.Rule.LegalSourcePretty = merged["rule.legal_source_pretty"]
|
||||
}
|
||||
|
||||
// t-paliad-349 slice 7 — uploaded-template draft: render the pinned
|
||||
// carrier. The Gitea tier / language-fallback notions don't apply (they
|
||||
// describe the upstream fallback chain), so they stay at their zero
|
||||
// values. A missing pinned version falls through to upstream resolution.
|
||||
if d.TemplateVersionID != nil && dbSvc.templateStore != nil {
|
||||
if tmpl, terr := dbSvc.templateStore.GetVersion(ctx, d.TemplateVersionID.String()); terr == nil {
|
||||
html, rerr := dbSvc.submissionDraft.RenderPreview(ctx, d, tmpl.CarrierBytes)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
view.PreviewHTML = html
|
||||
return view, nil
|
||||
} else if !errors.Is(terr, docforge.ErrTemplateNotFound) {
|
||||
return nil, terr
|
||||
}
|
||||
}
|
||||
|
||||
tplBytes, _, tier, err := resolveSubmissionTemplate(ctx, d.SubmissionCode, d.Language)
|
||||
if err != nil {
|
||||
log.Printf("submission_drafts: template fetch for view (draft=%s): %v", d.ID, err)
|
||||
@@ -1184,16 +1594,21 @@ func buildSubmissionDraftView(ctx context.Context, d *services.SubmissionDraft,
|
||||
type submissionTemplateTier string
|
||||
|
||||
const (
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
tplTierPerCodeLang submissionTemplateTier = "per_code_lang" // {firm}/{code}.{lang}.docx
|
||||
tplTierPerCode submissionTemplateTier = "per_code" // {firm}/{code}.docx (unsuffixed)
|
||||
tplTierSkeletonLang submissionTemplateTier = "skeleton_lang" // _skeleton.{lang}.docx
|
||||
tplTierSkeleton submissionTemplateTier = "skeleton" // _skeleton.docx
|
||||
tplTierFallback submissionTemplateTier = "fallback" // embedded merge-safe basic-Rubrum skeleton
|
||||
tplTierLetterhead submissionTemplateTier = "letterhead" // HL Patents Style .dotm
|
||||
)
|
||||
|
||||
// resolveSubmissionTemplate returns the .docx bytes for the given
|
||||
// (submission_code, language). Merges t-paliad-275 (firm-skeleton tier)
|
||||
// and t-paliad-276 (language-selector + EN skeleton tier). Lookup order:
|
||||
// (submission_code, language). This is the *merge-path* resolver: every
|
||||
// caller feeds the result into SubmissionRenderer (merge.go), which fills
|
||||
// {{key}} tokens. The result must therefore be merge-safe — it must carry
|
||||
// real {{key}} placeholders. Merges t-paliad-275 (firm-skeleton tier),
|
||||
// t-paliad-276 (language-selector + EN skeleton tier), t-paliad-358 A-S1
|
||||
// (merge-safe guard + embedded fallback). Lookup order:
|
||||
//
|
||||
// 1. per-firm per-(code, lang) template — most specific. e.g.
|
||||
// `de.inf.lg.erwidg.en.docx` for EN drafts. t-paliad-276.
|
||||
@@ -1202,12 +1617,22 @@ const (
|
||||
// 3. universal language-matched skeleton — `_skeleton.en.docx` for EN
|
||||
// drafts. Skipped for DE drafts (steps 4+5 already cover DE).
|
||||
// 4. firm-formatted skeleton — `_firm-skeleton.docx` (t-paliad-275).
|
||||
// HL paragraph + character styles + letterhead, full placeholder
|
||||
// bag. DE-flavored: counts as language_fallback=true for EN drafts.
|
||||
// 5. universal _skeleton.docx — plain DE skeleton, no firm styles.
|
||||
// Backstop when the firm skeleton is unreachable.
|
||||
// 6. universal HL Patents Style .dotm — macro-only letterhead, no
|
||||
// placeholders. Last-ditch when every skeleton tier is unreachable.
|
||||
// 5. universal _skeleton.docx.
|
||||
// 6. embedded merge-safe fallback — a lang-aware basic-Rubrum skeleton
|
||||
// built in-process (docx.BuildFallbackSkeleton). Always available, no
|
||||
// Gitea round-trip. This is what makes one-click /generate produce a
|
||||
// real merged document for ANY submission_code.
|
||||
// 7. HL Patents Style .dotm — placeholder-free letterhead, the pre-358
|
||||
// last-ditch. Reached only if the in-process build (6) fails.
|
||||
//
|
||||
// Tiers 3/4/5 are GUARDED by docx.HasMergePlaceholders: the firm and
|
||||
// universal skeletons were repurposed into anchors-only Composer bases
|
||||
// (t-paliad-313 Slice B) — their bodies hold only {{#section:KEY}} markers
|
||||
// the merge engine can't fill, so feeding them to merge.go produced literal
|
||||
// "{{#section:…}}" junk (kepler audit §1 Path 3 / §2). The guard skips any
|
||||
// fetched skeleton that lacks real placeholders, so today they fall through
|
||||
// to the embedded fallback (6); should a merge-safe firm-skeleton (with
|
||||
// letterhead) be restored later it is preferred again automatically.
|
||||
//
|
||||
// The returned SHA pins the audit row's template provenance. The tier
|
||||
// tells the editor whether the result language-matches the request so
|
||||
@@ -1231,25 +1656,30 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string)
|
||||
// 3. language-matched skeleton — only meaningful for EN drafts; DE
|
||||
// drafts fall through to the firm/universal DE skeletons below.
|
||||
if lang == "en" {
|
||||
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched {
|
||||
if data, sha, langMatched, err := fetchSubmissionSkeletonBytesForLang(ctx, lang); err == nil && langMatched && docx.HasMergePlaceholders(data) {
|
||||
return data, sha, tplTierSkeletonLang, nil
|
||||
}
|
||||
}
|
||||
// 4. firm-formatted skeleton (HL styles, DE prose). For DE drafts
|
||||
// this is a first-class match; for EN drafts it counts as a
|
||||
// language fallback (handled by languageFallback()).
|
||||
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil {
|
||||
// 4. firm-formatted skeleton — used only if it is merge-safe (carries
|
||||
// real {{key}} placeholders, not anchors-only Composer markers).
|
||||
if data, sha, err := fetchFirmSkeletonBytes(ctx); err == nil && docx.HasMergePlaceholders(data) {
|
||||
return data, sha, tplTierSkeleton, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: firm-skeleton fetch failed for code=%s lang=%s, falling back to universal skeleton: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 5. universal plain DE skeleton.
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil {
|
||||
// 5. universal plain DE skeleton — same merge-safe guard.
|
||||
if data, sha, err := fetchSubmissionSkeletonBytes(ctx); err == nil && docx.HasMergePlaceholders(data) {
|
||||
return data, sha, tplTierSkeleton, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: skeleton fetch failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 6. HL Patents Style letterhead (no placeholders, last-ditch).
|
||||
// 6. embedded merge-safe fallback — lang-aware basic Rubrum, always
|
||||
// available. Supersedes the placeholder-free .dotm so /generate on
|
||||
// any code yields a real merged document (basic Rubrum), never the
|
||||
// {{#section:…}} junk an anchors-only base produced (t-paliad-358 A-S1).
|
||||
if data, err := docx.BuildFallbackSkeleton(lang); err == nil {
|
||||
sum := sha256.Sum256(data)
|
||||
return data, hex.EncodeToString(sum[:]), tplTierFallback, nil
|
||||
} else {
|
||||
log.Printf("submission_drafts: embedded fallback skeleton build failed for code=%s lang=%s, falling back to HL Patents Style: %v", submissionCode, lang, err)
|
||||
}
|
||||
// 7. HL Patents Style letterhead (no placeholders, last-ditch).
|
||||
bytes, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
return nil, "", "", err
|
||||
@@ -1260,16 +1690,19 @@ func resolveSubmissionTemplate(ctx context.Context, submissionCode, lang string)
|
||||
|
||||
// languageFallback reports whether the resolved template tier failed
|
||||
// to match the requested draft language. For an EN draft, anything
|
||||
// other than per_code_lang or skeleton_lang is a fallback (per_code is
|
||||
// the legacy DE-baked template, skeleton is the DE skeleton). For a DE
|
||||
// draft, only `letterhead` counts as a fallback — the DE skeleton and
|
||||
// per-code template are both first-class DE outputs. t-paliad-276.
|
||||
// other than per_code_lang, skeleton_lang or the lang-aware embedded
|
||||
// fallback is a fallback (per_code is the legacy DE-baked template,
|
||||
// skeleton is the DE skeleton). For a DE draft, only `letterhead` counts
|
||||
// as a fallback — the DE skeleton, per-code template, and the embedded
|
||||
// fallback are all first-class DE outputs. t-paliad-276 / t-paliad-358 A-S1.
|
||||
func languageFallback(lang string, tier submissionTemplateTier) bool {
|
||||
if tier == tplTierLetterhead {
|
||||
return true
|
||||
}
|
||||
if strings.EqualFold(lang, "en") {
|
||||
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang
|
||||
// tplTierFallback is built per-language (English labels for EN), so
|
||||
// it is NOT a language fallback.
|
||||
return tier != tplTierPerCodeLang && tier != tplTierSkeletonLang && tier != tplTierFallback
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -1306,21 +1739,22 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
|
||||
meta = map[string]any{}
|
||||
}
|
||||
return submissionDraftJSON{
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
ID: d.ID,
|
||||
ProjectID: d.ProjectID,
|
||||
SubmissionCode: d.SubmissionCode,
|
||||
UserID: d.UserID,
|
||||
Name: d.Name,
|
||||
Language: lang,
|
||||
Variables: vars,
|
||||
SelectedParties: selected,
|
||||
LastExportedAt: d.LastExportedAt,
|
||||
LastExportedSHA: d.LastExportedSHA,
|
||||
LastImportedAt: d.LastImportedAt,
|
||||
BaseID: d.BaseID,
|
||||
TemplateVersionID: d.TemplateVersionID,
|
||||
ComposerMeta: meta,
|
||||
CreatedAt: d.CreatedAt,
|
||||
UpdatedAt: d.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
157
internal/handlers/submission_filename_test.go
Normal file
157
internal/handlers/submission_filename_test.go
Normal file
@@ -0,0 +1,157 @@
|
||||
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 := services.RenderSubmissionFilename(tc.rule, tc.project, tc.lang, tc.keyword)
|
||||
if got != tc.want {
|
||||
t.Errorf("RenderSubmissionFilename() = %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"}},
|
||||
"",
|
||||
},
|
||||
{
|
||||
"legacy filename_keyword reads back-compat",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"filename_keyword": "Replik"}},
|
||||
"Replik",
|
||||
},
|
||||
{
|
||||
"new name_overrides.keyword shape",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{"name_overrides": map[string]any{"keyword": "Duplik"}}},
|
||||
"Duplik",
|
||||
},
|
||||
{
|
||||
"name_overrides.keyword wins over legacy filename_keyword",
|
||||
&services.SubmissionDraft{ComposerMeta: map[string]any{
|
||||
"name_overrides": map[string]any{"keyword": "Duplik"},
|
||||
"filename_keyword": "Replik",
|
||||
}},
|
||||
"Duplik",
|
||||
},
|
||||
{
|
||||
"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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
50
internal/handlers/submission_preview_test.go
Normal file
50
internal/handlers/submission_preview_test.go
Normal file
@@ -0,0 +1,50 @@
|
||||
package handlers
|
||||
|
||||
import "testing"
|
||||
|
||||
// t-paliad-370 S3 — the base-preview modal's "Beispiel" data-mode renders
|
||||
// unresolved placeholders as readable sample text instead of [KEIN WERT].
|
||||
// Matching is by key suffix/substring so it survives namespace changes;
|
||||
// unknown keys fall back to a generic token.
|
||||
func TestSampleMissingMarker(t *testing.T) {
|
||||
de := sampleMissingMarker("de")
|
||||
en := sampleMissingMarker("en")
|
||||
|
||||
cases := []struct {
|
||||
key string
|
||||
wantDE string
|
||||
wantEN string
|
||||
}{
|
||||
{"project.case_number", "4c O 12/23", "4c O 12/23"},
|
||||
{"parties.claimant.0.name", "Mustermandant GmbH", "Sample Claimant Ltd."},
|
||||
{"parties.defendant.0.name", "Musterbeklagte AG", "Sample Defendant Inc."},
|
||||
{"project.court", "Landgericht Düsseldorf", "Düsseldorf Regional Court"},
|
||||
{"some.unknown.key", "Beispiel", "Sample"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := de(c.key); got != c.wantDE {
|
||||
t.Errorf("de marker for %q = %q, want %q", c.key, got, c.wantDE)
|
||||
}
|
||||
if got := en(c.key); got != c.wantEN {
|
||||
t.Errorf("en marker for %q = %q, want %q", c.key, got, c.wantEN)
|
||||
}
|
||||
}
|
||||
|
||||
// A sample marker must never emit the [KEIN WERT] / [NO VALUE] form —
|
||||
// that's the whole point of the "Beispiel" mode.
|
||||
if got := de("anything.at.all"); got == "" {
|
||||
t.Error("de marker returned empty string")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreviewLang(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"de": "de", "DE": "de", "": "de", " fr ": "de", "garbage": "de",
|
||||
"en": "en", "EN": "en", " en ": "en",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizePreviewLang(in); got != want {
|
||||
t.Errorf("normalizePreviewLang(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ func TestLanguageFallback(t *testing.T) {
|
||||
{"de_per_code", "de", tplTierPerCode, false},
|
||||
{"de_skeleton_lang", "de", tplTierSkeletonLang, false},
|
||||
{"de_skeleton", "de", tplTierSkeleton, false},
|
||||
{"de_fallback", "de", tplTierFallback, false},
|
||||
{"de_letterhead", "de", tplTierLetterhead, true},
|
||||
|
||||
// EN drafts: per_code (DE-baked) and skeleton (DE-baked) both
|
||||
@@ -30,6 +31,9 @@ func TestLanguageFallback(t *testing.T) {
|
||||
{"en_per_code", "en", tplTierPerCode, true},
|
||||
{"en_skeleton_lang", "en", tplTierSkeletonLang, false},
|
||||
{"en_skeleton", "en", tplTierSkeleton, true},
|
||||
// The embedded fallback is built per-language (EN labels for EN),
|
||||
// so it is NOT a language fallback (t-paliad-358 A-S1).
|
||||
{"en_fallback", "en", tplTierFallback, false},
|
||||
{"en_letterhead", "en", tplTierLetterhead, true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
|
||||
@@ -205,6 +205,12 @@ func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([
|
||||
WHERE dr.is_active = true
|
||||
AND dr.lifecycle_state = 'published'
|
||||
AND dr.event_type = 'filing'
|
||||
-- Defensive guard (t-paliad-365 P1): even if a court/trigger act is
|
||||
-- ever mis-kinded event_type='filing' again, primary_party='court'
|
||||
-- keeps it out of the draftable-submission picker. mig 164 re-kinded
|
||||
-- the 8 known offenders to event_type='decision'+primary_party='court';
|
||||
-- this is belt-and-braces against future drift.
|
||||
AND dr.primary_party IS DISTINCT FROM 'court'
|
||||
AND dr.submission_code IS NOT NULL
|
||||
AND dr.submission_code <> ''
|
||||
AND pt.is_active = true
|
||||
@@ -336,7 +342,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 per-document keyword
|
||||
// override, but the user's composition override still applies.
|
||||
filename := submissionDownloadFilename(ctx, uid, 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 +363,29 @@ 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 {
|
||||
day := time.Now()
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
day = day.In(loc)
|
||||
// submissionDownloadFilename produces the user-facing download name
|
||||
// (t-paliad-354): "<YYYY-MM-DD> <keyword> (<case number>).docx", rendered
|
||||
// through the submission_docx_filename artifact and honouring the user's
|
||||
// per-user composition override (Slice 3). A failed override load is
|
||||
// non-fatal — it falls back to the system default. keyword is the
|
||||
// per-document value override (name_overrides.keyword).
|
||||
func submissionDownloadFilename(ctx context.Context, uid uuid.UUID, rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
var overrides, firm services.NameCompositionSpec
|
||||
if dbSvc.submissionDraft != nil {
|
||||
overrides, _ = dbSvc.submissionDraft.UserNameCompositions(ctx, uid)
|
||||
}
|
||||
ruleName := strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
ruleName = strings.TrimSpace(rule.NameEN)
|
||||
if dbSvc.firmNameComposition != nil {
|
||||
firm, _, _ = dbSvc.firmNameComposition.Get(ctx)
|
||||
}
|
||||
if ruleName == "" {
|
||||
ruleName = "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))
|
||||
}
|
||||
parts = append(parts, day.Format("2006-01-02"))
|
||||
return strings.Join(parts, "-") + ".docx"
|
||||
return services.RenderSubmissionFilenameFor(overrides, firm, rule, project, lang, keyword)
|
||||
}
|
||||
|
||||
// submissionFilenameKeyword delegates to services.SubmissionFilenameKeyword
|
||||
// (the back-compat read of the per-document keyword override). Kept as a
|
||||
// package-local alias so the existing call-sites and unit test read
|
||||
// unchanged.
|
||||
func submissionFilenameKeyword(d *services.SubmissionDraft) string {
|
||||
return services.SubmissionFilenameKeyword(d)
|
||||
}
|
||||
|
||||
// writeSubmissionAuditRow files one row in paliad.system_audit_log per
|
||||
|
||||
306
internal/handlers/templates.go
Normal file
306
internal/handlers/templates.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package handlers
|
||||
|
||||
// docforge template authoring handlers (t-paliad-349 slice 6).
|
||||
//
|
||||
// The admin-only authoring surface: upload a base .docx, see it rendered as
|
||||
// run-addressable text, place {{variable}} slots into it, and save the
|
||||
// result as a reusable template. Backed by docforge.TemplateStore
|
||||
// (Postgres bytea carrier) + the docx authoring engine
|
||||
// (ImportForAuthoring / InjectSlot).
|
||||
//
|
||||
// Endpoints (all under adminGate — templates are firm-shared, admin-
|
||||
// authored, like submission_bases):
|
||||
// GET /api/admin/templates — catalog list
|
||||
// POST /api/admin/templates — multipart upload → create v1
|
||||
// GET /api/admin/templates/{id} — authoring view (preview+slots)
|
||||
// POST /api/admin/templates/{id}/slots — place a slot → new version
|
||||
//
|
||||
// Slot placement creates a new template version (immutable snapshot) per
|
||||
// placement. That keeps the snapshot guarantee simple; batching a whole
|
||||
// authoring session into one version on an explicit "save" is a documented
|
||||
// future refinement (it trades the version-per-slot churn for a client- or
|
||||
// session-held draft carrier).
|
||||
//
|
||||
// VERIFICATION CEILING: the live upload→render→select→inject→save flow
|
||||
// needs the app running with DATABASE_URL + Supabase auth + Playwright; it
|
||||
// is verified post-merge. The docx surgery (ImportForAuthoring/InjectSlot)
|
||||
// and the store are unit/live-tested independently.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// maxTemplateUpload bounds an uploaded .docx. Templates are firm letterhead
|
||||
// + chrome — tens of KB in practice; 10 MB is a generous ceiling.
|
||||
const maxTemplateUpload = 10 << 20
|
||||
|
||||
type templateMetaJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug,omitempty"`
|
||||
NameDE string `json:"name_de"`
|
||||
NameEN string `json:"name_en"`
|
||||
Kind string `json:"kind"`
|
||||
SourceFormat string `json:"source_format"`
|
||||
Firm string `json:"firm,omitempty"`
|
||||
IsActive bool `json:"is_active"`
|
||||
Version int `json:"version"`
|
||||
VersionID string `json:"version_id,omitempty"`
|
||||
}
|
||||
|
||||
type templateSlotJSON struct {
|
||||
Key string `json:"key"`
|
||||
Anchor string `json:"anchor"`
|
||||
Label string `json:"label,omitempty"`
|
||||
OrderIndex int `json:"order_index"`
|
||||
}
|
||||
|
||||
type authoringViewJSON struct {
|
||||
Template templateMetaJSON `json:"template"`
|
||||
PreviewHTML string `json:"preview_html"`
|
||||
Slots []templateSlotJSON `json:"slots"`
|
||||
}
|
||||
|
||||
func metaJSON(m docforge.TemplateMeta) templateMetaJSON {
|
||||
return templateMetaJSON{
|
||||
ID: m.ID, Slug: m.Slug, NameDE: m.NameDE, NameEN: m.NameEN,
|
||||
Kind: m.Kind, SourceFormat: m.SourceFormat, Firm: m.Firm,
|
||||
IsActive: m.IsActive, Version: m.Version, VersionID: m.VersionID,
|
||||
}
|
||||
}
|
||||
|
||||
func slotsJSON(slots []docforge.TemplateSlot) []templateSlotJSON {
|
||||
out := make([]templateSlotJSON, 0, len(slots))
|
||||
for _, s := range slots {
|
||||
out = append(out, templateSlotJSON{Key: s.Key, Anchor: s.Anchor, Label: s.Label, OrderIndex: s.OrderIndex})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// writeTemplateError maps docforge's not-found sentinel to 404 and falls
|
||||
// back to the shared service-error mapper.
|
||||
func writeTemplateError(w http.ResponseWriter, err error) {
|
||||
if errors.Is(err, docforge.ErrTemplateNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{"error": "template not found"})
|
||||
return
|
||||
}
|
||||
writeServiceError(w, err)
|
||||
}
|
||||
|
||||
func requireTemplateStore(w http.ResponseWriter) bool {
|
||||
if !requireDB(w) {
|
||||
return false
|
||||
}
|
||||
if dbSvc.templateStore == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "template store not configured"})
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// handleTemplatesAuthoringPage serves the authoring page shell. The client
|
||||
// bundle hydrates the list, upload, preview, palette, and slots.
|
||||
func handleTemplatesAuthoringPage(w http.ResponseWriter, r *http.Request) {
|
||||
http.ServeFile(w, r, "dist/templates-authoring.html")
|
||||
}
|
||||
|
||||
// handleListTemplates backs GET /api/admin/templates.
|
||||
func handleListTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(), docforge.TemplateFilter{ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handlePickerTemplates backs GET /api/templates — the firm-shared catalog
|
||||
// any authenticated lawyer reads to pick an uploaded template for
|
||||
// generation (t-paliad-349 slice 7). Unlike the admin list it filters by
|
||||
// firm (the deployment's branding firm + firm-agnostic templates), matching
|
||||
// the submission_bases picker contract. Metadata only — no carrier bytes.
|
||||
func handlePickerTemplates(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
metas, err := dbSvc.templateStore.List(r.Context(),
|
||||
docforge.TemplateFilter{Firm: branding.Name, ActiveOnly: true})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
out := make([]templateMetaJSON, 0, len(metas))
|
||||
for _, m := range metas {
|
||||
out = append(out, metaJSON(m))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"templates": out})
|
||||
}
|
||||
|
||||
// handleUploadTemplate backs POST /api/admin/templates (multipart). Reads
|
||||
// the uploaded .docx, validates it parses, detects any slots already in it,
|
||||
// and creates the template at version 1.
|
||||
func handleUploadTemplate(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if err := r.ParseMultipartForm(maxTemplateUpload); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid multipart form"})
|
||||
return
|
||||
}
|
||||
file, _, err := r.FormFile("file")
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "file field required"})
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
carrier, err := io.ReadAll(io.LimitReader(file, maxTemplateUpload))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "could not read uploaded file"})
|
||||
return
|
||||
}
|
||||
nameDE := r.FormValue("name_de")
|
||||
nameEN := r.FormValue("name_en")
|
||||
if nameDE == "" || nameEN == "" {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name_de and name_en required"})
|
||||
return
|
||||
}
|
||||
|
||||
// Validate + detect existing slots before persisting.
|
||||
view, err := docx.ImportForAuthoring(carrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "not a parseable .docx: " + err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
tmpl, err := dbSvc.templateStore.Create(r.Context(),
|
||||
docforge.TemplateMetaInput{
|
||||
Slug: r.FormValue("slug"),
|
||||
NameDE: nameDE,
|
||||
NameEN: nameEN,
|
||||
Firm: r.FormValue("firm"),
|
||||
CreatedBy: uid.String(),
|
||||
},
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: carrier,
|
||||
Slots: view.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(tmpl.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
// handleGetTemplateAuthoring backs GET /api/admin/templates/{id} — the
|
||||
// authoring view: current carrier rendered run-addressable + its slots.
|
||||
func handleGetTemplateAuthoring(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
view, err := docx.ImportForAuthoring(tmpl.CarrierBytes)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "stored carrier failed to parse: " + err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(tmpl.TemplateMeta),
|
||||
PreviewHTML: view.PreviewHTML,
|
||||
Slots: slotsJSON(view.Slots),
|
||||
})
|
||||
}
|
||||
|
||||
type placeSlotInput struct {
|
||||
RunIndex int `json:"run_index"`
|
||||
SelectedText string `json:"selected_text"`
|
||||
SlotKey string `json:"slot_key"`
|
||||
}
|
||||
|
||||
// handlePlaceTemplateSlot backs POST /api/admin/templates/{id}/slots —
|
||||
// inject a slot at the selection and persist as a new version.
|
||||
func handlePlaceTemplateSlot(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireTemplateStore(w) {
|
||||
return
|
||||
}
|
||||
uid, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var in placeSlotInput
|
||||
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
|
||||
return
|
||||
}
|
||||
id := r.PathValue("id")
|
||||
tmpl, err := dbSvc.templateStore.Get(r.Context(), id)
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
newCarrier, err := docx.InjectSlot(tmpl.CarrierBytes, in.RunIndex, in.SelectedText, in.SlotKey)
|
||||
if err != nil {
|
||||
// Injection failures are client-fixable (bad selection / key).
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Re-detect slots from the new carrier so template_slots mirrors the
|
||||
// carrier's actual {{tokens}} (single source of truth).
|
||||
newView, err := docx.ImportForAuthoring(newCarrier)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("post-inject parse: %v", err)})
|
||||
return
|
||||
}
|
||||
updated, err := dbSvc.templateStore.AddVersion(r.Context(), id,
|
||||
docforge.TemplateVersionInput{
|
||||
CarrierBytes: newCarrier,
|
||||
Stylemap: tmpl.Stylemap,
|
||||
Slots: newView.Slots,
|
||||
CreatedBy: uid.String(),
|
||||
})
|
||||
if err != nil {
|
||||
writeTemplateError(w, err)
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, authoringViewJSON{
|
||||
Template: metaJSON(updated.TemplateMeta),
|
||||
PreviewHTML: newView.PreviewHTML,
|
||||
Slots: slotsJSON(updated.Slots),
|
||||
})
|
||||
}
|
||||
@@ -265,7 +265,7 @@ func (s *DeadlineService) ListVisibleForUser(ctx context.Context, userID uuid.UU
|
||||
|
||||
query := `
|
||||
SELECT f.id, f.project_id, f.title, f.description, f.due_date, f.original_due_date,
|
||||
f.warning_date, f.source, f.rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.warning_date, f.source, f.sequencing_rule_id, f.rule_code, f.custom_rule_text, f.status, f.completed_at,
|
||||
f.caldav_uid, f.caldav_etag, f.notes, f.created_by,
|
||||
f.created_at, f.updated_at,
|
||||
f.approval_status, f.pending_request_id, f.approved_by, f.approved_at,
|
||||
|
||||
66
internal/services/docforge_shims.go
Normal file
66
internal/services/docforge_shims.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package services
|
||||
|
||||
// Shims bridging the submission generator to the extracted docforge .docx
|
||||
// adapter (pkg/docforge/docx). Slice 1 of the docforge train
|
||||
// (t-paliad-349 / m/paliad#157) relocated the Markdown→OOXML walker, the
|
||||
// placeholder substitution engine, and the .dotm→.docx converter into
|
||||
// pkg/docforge/docx with no behaviour change. These type aliases and
|
||||
// forwarders keep every existing caller in internal/services and
|
||||
// internal/handlers compiling and behaving identically — the names,
|
||||
// signatures, and semantics are unchanged; only the implementation moved.
|
||||
//
|
||||
// Later slices retire these shims as the submission services are
|
||||
// refactored to call docforge directly through the neutral model and the
|
||||
// VariableResolver interface.
|
||||
|
||||
import (
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge"
|
||||
"mgit.msbls.de/m/paliad/pkg/docforge/docx"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag (dotted-key → substituted value),
|
||||
// built by SubmissionVarsService and consumed by the renderer. The
|
||||
// canonical type lives in the docforge root (the format-neutral
|
||||
// variable-bag contract).
|
||||
type PlaceholderMap = docforge.PlaceholderMap
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token.
|
||||
type MissingPlaceholderFn = docforge.MissingPlaceholderFn
|
||||
|
||||
// SubmissionRenderer renders a .docx template by substituting
|
||||
// {{placeholder}} tokens. Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer = docx.SubmissionRenderer
|
||||
|
||||
// HyperlinkAllocator hands the Markdown walker a rId for each external
|
||||
// URL it encounters in [label](url) inline links.
|
||||
type HyperlinkAllocator = docx.HyperlinkAllocator
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer { return docx.NewSubmissionRenderer() }
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for the
|
||||
// given UI language ("[KEIN WERT: <key>]" / "[NO VALUE: <key>]").
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
return docforge.DefaultMissingMarker(lang)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXML renders Markdown source into OOXML paragraph
|
||||
// elements using a single paragraph style.
|
||||
func RenderMarkdownToOOXML(md, paragraphStyle string) string {
|
||||
return docx.RenderMarkdownToOOXML(md, paragraphStyle)
|
||||
}
|
||||
|
||||
// RenderMarkdownToOOXMLWithStyles is the full rich-prose entry point
|
||||
// (headings, lists, blockquote, inline hyperlinks via the allocator).
|
||||
func RenderMarkdownToOOXMLWithStyles(md string, stylemap map[string]string, links HyperlinkAllocator) string {
|
||||
return docx.RenderMarkdownToOOXMLWithStyles(md, stylemap, links)
|
||||
}
|
||||
|
||||
// ConvertDotmToDocx rewrites a .dotm/.docm/.dotx zip into a clean .docx
|
||||
// zip. Idempotent on a zip that is already a plain .docx.
|
||||
func ConvertDotmToDocx(dotmBytes []byte) ([]byte, error) { return docx.ConvertDotmToDocx(dotmBytes) }
|
||||
|
||||
// SanitiseSubmissionFileName cleans a string for use inside a download
|
||||
// filename (strips path separators / quotes, ASCII-folds DE umlauts).
|
||||
func SanitiseSubmissionFileName(s string) string { return docx.SanitiseSubmissionFileName(s) }
|
||||
122
internal/services/firm_name_composition_live_test.go
Normal file
122
internal/services/firm_name_composition_live_test.go
Normal file
@@ -0,0 +1,122 @@
|
||||
package services
|
||||
|
||||
// Live-DB tests for FirmNameCompositionService (t-paliad-356 Slice 5) — gated
|
||||
// on TEST_DATABASE_URL like the rest of the integration suite. Covers the
|
||||
// round-trip (Set → Get → Clear → Get), the Validate rejection on write, and
|
||||
// that a stored firm default flows through the render path below a per-user
|
||||
// override and above the system default. Pure-function precedence is pinned in
|
||||
// name_template_test.go (TestResolveComposition_Precedence).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
func openTestDBForFirmNameComp(t *testing.T) *sqlx.DB {
|
||||
t.Helper()
|
||||
url := os.Getenv("TEST_DATABASE_URL")
|
||||
if url == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set — skipping firm-name-composition live test")
|
||||
}
|
||||
// Apply embedded migrations (incl. 162 which creates the table) so the
|
||||
// test is self-sufficient regardless of run order — mirrors the Slice-3
|
||||
// live test (TestNameCompositions_Precedence_Live).
|
||||
if err := db.ApplyMigrations(url); err != nil {
|
||||
t.Fatalf("apply migrations: %v", err)
|
||||
}
|
||||
conn, err := sqlx.Connect("postgres", url)
|
||||
if err != nil {
|
||||
t.Fatalf("connect: %v", err)
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
func TestFirmNameComposition_RoundTripAndRender(t *testing.T) {
|
||||
db := openTestDBForFirmNameComp(t)
|
||||
defer db.Close()
|
||||
svc := NewFirmNameCompositionService(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// Start clean — a prior test may have left a row.
|
||||
if err := svc.Clear(ctx); err != nil {
|
||||
t.Fatalf("pre-clear: %v", err)
|
||||
}
|
||||
if _, ok, err := svc.Get(ctx); err != nil || ok {
|
||||
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
|
||||
}
|
||||
|
||||
// A firm default that drops the case-number segment from the filename:
|
||||
// "<date> <keyword>".
|
||||
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
|
||||
if _, err := svc.Set(ctx, spec, uuid.Nil); err != nil {
|
||||
t.Fatalf("Set: %v", err)
|
||||
}
|
||||
|
||||
got, ok, err := svc.Get(ctx)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Get after Set: ok=%v err=%v; want true/nil", ok, err)
|
||||
}
|
||||
if c := got[ArtifactSubmissionDocxFilename]; c.Template() != "{date} {keyword}" {
|
||||
t.Errorf("stored firm composition = %q, want '{date} {keyword}'", c.Template())
|
||||
}
|
||||
|
||||
// Render path: with the firm default and no user override, the filename
|
||||
// loses the "(Az. folgt)" case segment.
|
||||
rule := &models.DeadlineRule{Name: "Klageerwiderung"}
|
||||
proj := &models.Project{}
|
||||
firm, _, _ := svc.Get(ctx)
|
||||
if name := RenderSubmissionFilenameFor(nil, firm, rule, proj, "de", ""); name != nomenDateBerlin(time.Now())+" Klageerwiderung.docx" {
|
||||
t.Errorf("firm-tier filename = %q, want '<date> Klageerwiderung.docx'", name)
|
||||
}
|
||||
|
||||
// A per-user override still wins over the firm default.
|
||||
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
|
||||
if name := RenderSubmissionFilenameFor(user, firm, rule, proj, "de", ""); name != "Klageerwiderung.docx" {
|
||||
t.Errorf("user override should beat firm: got %q, want 'Klageerwiderung.docx'", name)
|
||||
}
|
||||
|
||||
// Clear is idempotent and reverts the render to the system default.
|
||||
if err := svc.Clear(ctx); err != nil {
|
||||
t.Fatalf("clear: %v", err)
|
||||
}
|
||||
if err := svc.Clear(ctx); err != nil {
|
||||
t.Fatalf("second clear: %v", err)
|
||||
}
|
||||
if _, ok, err := svc.Get(ctx); err != nil || ok {
|
||||
t.Fatalf("Get after Clear: ok=%v err=%v; want false/nil", ok, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirmNameComposition_RejectsInvalid(t *testing.T) {
|
||||
db := openTestDBForFirmNameComp(t)
|
||||
defer db.Close()
|
||||
svc := NewFirmNameCompositionService(db)
|
||||
ctx := context.Background()
|
||||
|
||||
// A composition referencing a variable the artifact catalog does not know
|
||||
// must be rejected on write (Validate), never persisted.
|
||||
bad := NameCompositionSpec{ArtifactSubmissionDocxFilename: nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{{Var: "client", Missing: nomen.Omit()}}, // client not in filename catalog
|
||||
}}
|
||||
if _, err := svc.Set(ctx, bad, uuid.Nil); err == nil {
|
||||
t.Fatal("Set with unknown variable: err=nil; want ErrInvalidInput")
|
||||
}
|
||||
}
|
||||
61
internal/services/firm_name_composition_service.go
Normal file
61
internal/services/firm_name_composition_service.go
Normal file
@@ -0,0 +1,61 @@
|
||||
package services
|
||||
|
||||
// FirmNameCompositionService manages paliad.firm_name_compositions — the
|
||||
// optional firm-wide default name-composition map that the render path prefers
|
||||
// over the code-resident system default (but below a per-user override) when
|
||||
// composing draft titles and export filenames.
|
||||
//
|
||||
// PRD §3.1/§3.2 of docs/plans/prd-filename-generator-2026-06-01.md (Slice 5).
|
||||
// Mirrors FirmDashboardDefaultService exactly: a single optional row (id=1).
|
||||
// Get returns (spec, true, nil) when set, (empty, false, nil) when never set.
|
||||
// Set validates + upserts; Clear deletes (so resolution reverts to system).
|
||||
//
|
||||
// The HTTP layer (handlers/name_compositions.go admin endpoints) enforces
|
||||
// admin-only via auth.RequireAdmin. The service takes no admin parameter — the
|
||||
// only writer is the admin handler; the read path is used by the render path
|
||||
// on every name composition.
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
)
|
||||
|
||||
// FirmNameCompositionService manages paliad.firm_name_compositions.
|
||||
type FirmNameCompositionService struct {
|
||||
db *sqlx.DB
|
||||
}
|
||||
|
||||
// NewFirmNameCompositionService wires the service.
|
||||
func NewFirmNameCompositionService(db *sqlx.DB) *FirmNameCompositionService {
|
||||
return &FirmNameCompositionService{db: db}
|
||||
}
|
||||
|
||||
// Get returns (spec, true, nil) when a firm default is set, (empty, false,
|
||||
// nil) otherwise. The spec is SanitizeForRead'd so callers always get a
|
||||
// version-coherent map. "Set" means the singleton row exists AND carries at
|
||||
// least one artifact override — an empty stored map reads as "not set" so the
|
||||
// admin UI and the render fall-through treat it the same as absent.
|
||||
func (s *FirmNameCompositionService) Get(ctx context.Context) (NameCompositionSpec, bool, error) {
|
||||
spec, err := getFirmNameCompositions(ctx, s.db)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return spec, len(spec) > 0, nil
|
||||
}
|
||||
|
||||
// Set validates and persists the firm-wide default. updatedBy is recorded for
|
||||
// audit; uuid.Nil clears the column.
|
||||
func (s *FirmNameCompositionService) Set(ctx context.Context, spec NameCompositionSpec, updatedBy uuid.UUID) (NameCompositionSpec, error) {
|
||||
if err := setFirmNameCompositions(ctx, s.db, spec, updatedBy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// Clear deletes the firm default so resolution reverts to the system default.
|
||||
// Idempotent.
|
||||
func (s *FirmNameCompositionService) Clear(ctx context.Context) error {
|
||||
return clearFirmNameCompositions(ctx, s.db)
|
||||
}
|
||||
@@ -145,7 +145,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
AND pe.event_kind = $%d
|
||||
)`, opts.EventKind)
|
||||
}
|
||||
query := `SELECT code, name, name_en, jurisdiction
|
||||
query := `SELECT id, code, name, name_en, jurisdiction
|
||||
FROM paliad.proceeding_types
|
||||
WHERE ` + strings.Join(where, " AND ") + `
|
||||
ORDER BY sort_order`
|
||||
@@ -160,7 +160,7 @@ func (s *FristenrechnerService) ListProceedings(ctx context.Context, opts Procee
|
||||
for rows.Next() {
|
||||
var t lp.FristenrechnerType
|
||||
var juris sql.NullString
|
||||
if err := rows.Scan(&t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
if err := rows.Scan(&t.ID, &t.Code, &t.Name, &t.NameEN, &juris); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if juris.Valid {
|
||||
|
||||
167
internal/services/name_composition_live_test.go
Normal file
167
internal/services/name_composition_live_test.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package services
|
||||
|
||||
// Live-DB gate for the system→user name-composition precedence
|
||||
// (t-paliad-356 Slice 3, PRD §3). Skipped without TEST_DATABASE_URL.
|
||||
//
|
||||
// Covers: (a) users.name_compositions round-trip via Set/Get + write-time
|
||||
// Validate rejection; (b) a user override beating the system default for both
|
||||
// the draft-title artifact (through Create) and the .docx-filename artifact
|
||||
// (through RenderSubmissionFilenameFor); (c) the legacy
|
||||
// composer_meta.filename_keyword reading cleanly as name_overrides.keyword.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
_ "github.com/lib/pq"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/db"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
func TestNameCompositions_Precedence_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 := "nc-" + 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, 'NameComp 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)
|
||||
|
||||
date := todayBerlinDate()
|
||||
|
||||
// (a) Round-trip + Validate ------------------------------------------
|
||||
validSpec := NameCompositionSpec{
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, validSpec); err != nil {
|
||||
t.Fatalf("set valid spec: %v", err)
|
||||
}
|
||||
got, err := drafts.UserNameCompositions(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("get spec: %v", err)
|
||||
}
|
||||
if comp, ok := got[ArtifactSubmissionDocxFilename]; !ok || len(comp.Segments) != 1 || comp.Segments[0].Var != "keyword" {
|
||||
t.Fatalf("round-trip mismatch: %+v", got)
|
||||
}
|
||||
|
||||
// An override referencing a variable outside the artifact catalog is
|
||||
// rejected on write.
|
||||
badSpec := NameCompositionSpec{
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{{Var: "opponent"}}, // not a filename variable
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, badSpec); err == nil {
|
||||
t.Fatalf("invalid spec was accepted on write")
|
||||
}
|
||||
|
||||
// (b1) Title override beats system default (through Create) ----------
|
||||
titleOverride := NameCompositionSpec{
|
||||
ArtifactSubmissionDraftTitle: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "keyword", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "date", Sep: "", Missing: nomen.Omit()},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, titleOverride); err != nil {
|
||||
t.Fatalf("set title override: %v", err)
|
||||
}
|
||||
d, err := drafts.Create(ctx, userID, nil, "de.inf.lg.erwidg", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create with title override: %v", err)
|
||||
}
|
||||
// System default would be "<date> Klageerwiderung"; the override flips
|
||||
// the order to "<keyword> <date>".
|
||||
if want := "Klageerwiderung " + date; d.Name != want {
|
||||
t.Errorf("title override not applied: name = %q, want %q", d.Name, want)
|
||||
}
|
||||
|
||||
// (b2) Filename override beats system default ------------------------
|
||||
fnOverride := NameCompositionSpec{
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := drafts.SetUserNameCompositions(ctx, userID, fnOverride); err != nil {
|
||||
t.Fatalf("set filename override: %v", err)
|
||||
}
|
||||
overrides, err := drafts.UserNameCompositions(ctx, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("load overrides: %v", err)
|
||||
}
|
||||
rule := &models.DeadlineRule{Name: "Klageerwiderung"}
|
||||
proj := &models.Project{CaseNumber: strPtr("UPC_CFI_1_2026")}
|
||||
// System default would be "<date> Klageerwiderung (UPC_CFI_1_2026).docx";
|
||||
// the override reduces it to just the keyword.
|
||||
if got := RenderSubmissionFilenameFor(overrides, nil, rule, proj, "de", ""); got != "Klageerwiderung.docx" {
|
||||
t.Errorf("filename override not applied: %q, want %q", got, "Klageerwiderung.docx")
|
||||
}
|
||||
// And the system default (nil overrides) is unchanged.
|
||||
if got := RenderSubmissionFilename(rule, proj, "de", ""); got != date+" Klageerwiderung (UPC_CFI_1_2026).docx" {
|
||||
t.Errorf("system default filename drifted: %q", got)
|
||||
}
|
||||
|
||||
// (c) Legacy filename_keyword reads back-compat ----------------------
|
||||
dLegacy, err := drafts.Create(ctx, userID, nil, "de.inf.lg.duplik", "de")
|
||||
if err != nil {
|
||||
t.Fatalf("create legacy draft: %v", err)
|
||||
}
|
||||
if _, err := pool.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts SET composer_meta = '{"filename_keyword":"LegacyKW"}'::jsonb WHERE id = $1`,
|
||||
dLegacy.ID); err != nil {
|
||||
t.Fatalf("seed legacy composer_meta: %v", err)
|
||||
}
|
||||
reloaded, err := drafts.Get(ctx, userID, dLegacy.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get legacy draft: %v", err)
|
||||
}
|
||||
if kw := SubmissionFilenameKeyword(reloaded); kw != "LegacyKW" {
|
||||
t.Errorf("legacy filename_keyword back-compat read = %q, want %q", kw, "LegacyKW")
|
||||
}
|
||||
}
|
||||
225
internal/services/name_composition_spec.go
Normal file
225
internal/services/name_composition_spec.go
Normal file
@@ -0,0 +1,225 @@
|
||||
package services
|
||||
|
||||
// Per-user name-composition overrides (t-paliad-356 Slice 3, PRD §3).
|
||||
//
|
||||
// users.name_compositions is a JSONB map { artifact_id: Composition } that
|
||||
// overrides the code-resident system default for an artifact. The validation
|
||||
// surface mirrors DashboardLayoutSpec exactly: Validate on write (known
|
||||
// artifact, segments reference known variables, version + segment cap),
|
||||
// SanitizeForRead on read (drop unknown artifacts and segments referencing
|
||||
// variables the catalog no longer has, clamp version). Resolution prefers a
|
||||
// valid user override over the system default; the firm slot (PRD §3.1) is
|
||||
// reserved for Slice 5 and not wired yet, so the system default is the
|
||||
// fallback directly below the user level in Slice 3.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// NameCompositionSpec is the parsed users.name_compositions jsonb: a map of
|
||||
// artifact_id -> overriding Composition. It marshals as the bare map.
|
||||
type NameCompositionSpec map[string]nomen.Composition
|
||||
|
||||
// Validate enforces the write-time invariants: every key is a known artifact
|
||||
// and every composition is valid against that artifact's variable catalog.
|
||||
func (s NameCompositionSpec) Validate() error {
|
||||
for id, comp := range s {
|
||||
art, ok := NameArtifact(id)
|
||||
if !ok {
|
||||
return fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, id)
|
||||
}
|
||||
if err := comp.Validate(art.Catalog); err != nil {
|
||||
return fmt.Errorf("%w: artifact %q: %v", ErrInvalidInput, id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SanitizeForRead applies the forgiving read-path rules: drop overrides for
|
||||
// artifacts that no longer exist, and within each surviving override drop
|
||||
// segments referencing unknown variables and clamp the version. Mutates the
|
||||
// receiver; returns true if anything changed so the caller can persist the
|
||||
// cleaned value.
|
||||
func (s NameCompositionSpec) SanitizeForRead() bool {
|
||||
changed := false
|
||||
for id, comp := range s {
|
||||
art, ok := NameArtifact(id)
|
||||
if !ok {
|
||||
delete(s, id)
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
if comp.SanitizeForRead(art.Catalog) {
|
||||
changed = true
|
||||
}
|
||||
s[id] = comp
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
// ParseNameCompositionSpec decodes and validates a name_compositions payload.
|
||||
// Used on writes (API/test). An empty/NULL payload yields an empty spec.
|
||||
func ParseNameCompositionSpec(b []byte) (NameCompositionSpec, error) {
|
||||
spec := NameCompositionSpec{}
|
||||
if len(b) > 0 {
|
||||
if err := json.Unmarshal(b, &spec); err != nil {
|
||||
return nil, fmt.Errorf("%w: name_compositions JSON decode: %v", ErrInvalidInput, err)
|
||||
}
|
||||
}
|
||||
if err := spec.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// resolveComposition returns the first valid override for an artifact from the
|
||||
// supplied specs (highest precedence first), else the artifact's system
|
||||
// default. The precedence chain is per-document → user → firm → system (PRD
|
||||
// §3.1); the per-document layer is a variable-value override resolved in the
|
||||
// VarResolver, not here, so the specs passed are [user, firm] in that order
|
||||
// (Slice 5). A stored override is sanitised then validated; anything that
|
||||
// fails validation is skipped so a broken stored value can never render — the
|
||||
// next valid tier (or the system default) wins.
|
||||
func resolveComposition(artifactID string, specs ...NameCompositionSpec) nomen.Composition {
|
||||
art := nameArtifacts[artifactID]
|
||||
for _, spec := range specs {
|
||||
if spec == nil {
|
||||
continue
|
||||
}
|
||||
comp, ok := spec[artifactID]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
comp.SanitizeForRead(art.Catalog)
|
||||
if len(comp.Segments) > 0 && comp.Validate(art.Catalog) == nil {
|
||||
return comp
|
||||
}
|
||||
}
|
||||
return art.SystemDefault
|
||||
}
|
||||
|
||||
// getUserNameCompositions loads a user's name_compositions, sanitised for
|
||||
// read. A missing user or NULL column yields an empty (nil-safe) spec — the
|
||||
// caller then renders with system defaults. Shared by the title create path
|
||||
// and the filename download path so the SELECT lives in one place.
|
||||
func getUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID) (NameCompositionSpec, error) {
|
||||
var raw []byte
|
||||
err := db.GetContext(ctx, &raw,
|
||||
`SELECT name_compositions FROM paliad.users WHERE id = $1`, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load name_compositions: %w", err)
|
||||
}
|
||||
spec := NameCompositionSpec{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
// A corrupt stored value must not break draft creation — treat
|
||||
// it as "no overrides" and let the next write replace it.
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
}
|
||||
spec.SanitizeForRead()
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// getFirmNameCompositions loads the firm-wide default name_compositions
|
||||
// (Slice 5), sanitised for read. A missing singleton row yields an empty
|
||||
// (nil-safe) spec — the caller then renders with the user override or the
|
||||
// system default. Shared by the render path and the admin service so the
|
||||
// SELECT lives in one place; mirrors getUserNameCompositions.
|
||||
func getFirmNameCompositions(ctx context.Context, db *sqlx.DB) (NameCompositionSpec, error) {
|
||||
var raw []byte
|
||||
err := db.GetContext(ctx, &raw,
|
||||
`SELECT compositions_json FROM paliad.firm_name_compositions WHERE id = 1`)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load firm_name_compositions: %w", err)
|
||||
}
|
||||
spec := NameCompositionSpec{}
|
||||
if len(raw) > 0 {
|
||||
if err := json.Unmarshal(raw, &spec); err != nil {
|
||||
// A corrupt stored value must not break name rendering — treat it
|
||||
// as "no firm default" and let the next admin write replace it.
|
||||
return NameCompositionSpec{}, nil
|
||||
}
|
||||
}
|
||||
spec.SanitizeForRead()
|
||||
return spec, nil
|
||||
}
|
||||
|
||||
// setFirmNameCompositions validates and upserts the firm-wide default map into
|
||||
// the id=1 singleton, recording updatedBy (uuid.Nil clears the column). The
|
||||
// admin API is the only writer.
|
||||
func setFirmNameCompositions(ctx context.Context, db *sqlx.DB, spec NameCompositionSpec, updatedBy uuid.UUID) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec == nil {
|
||||
spec = NameCompositionSpec{}
|
||||
}
|
||||
b, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal firm_name_compositions: %w", err)
|
||||
}
|
||||
var updaterArg any
|
||||
if updatedBy != uuid.Nil {
|
||||
updaterArg = updatedBy
|
||||
}
|
||||
_, err = db.ExecContext(ctx, `
|
||||
INSERT INTO paliad.firm_name_compositions (id, compositions_json, updated_by, updated_at)
|
||||
VALUES (1, $1::jsonb, $2, now())
|
||||
ON CONFLICT (id) DO UPDATE
|
||||
SET compositions_json = EXCLUDED.compositions_json,
|
||||
updated_by = EXCLUDED.updated_by,
|
||||
updated_at = now()
|
||||
`, json.RawMessage(b), updaterArg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persist firm_name_compositions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// clearFirmNameCompositions deletes the firm default so resolution falls
|
||||
// through to the system default. Idempotent.
|
||||
func clearFirmNameCompositions(ctx context.Context, db *sqlx.DB) error {
|
||||
if _, err := db.ExecContext(ctx, `DELETE FROM paliad.firm_name_compositions WHERE id = 1`); err != nil {
|
||||
return fmt.Errorf("clear firm_name_compositions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// setUserNameCompositions validates and persists a user's full
|
||||
// name_compositions map. The S4 settings API and the Slice-3 live tests call
|
||||
// this; it is the single write path.
|
||||
func setUserNameCompositions(ctx context.Context, db *sqlx.DB, userID uuid.UUID, spec NameCompositionSpec) error {
|
||||
if err := spec.Validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
if spec == nil {
|
||||
spec = NameCompositionSpec{}
|
||||
}
|
||||
b, err := json.Marshal(spec)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal name_compositions: %w", err)
|
||||
}
|
||||
_, err = db.ExecContext(ctx,
|
||||
`UPDATE paliad.users SET name_compositions = $1::jsonb WHERE id = $2`,
|
||||
json.RawMessage(b), userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("persist name_compositions: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
81
internal/services/name_composition_spec_test.go
Normal file
81
internal/services/name_composition_spec_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// A minimal valid override for the filename artifact: date + keyword only.
|
||||
func sampleFilenameOverride() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Literal("submission")},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameCompositionSpec_Validate(t *testing.T) {
|
||||
ok := NameCompositionSpec{ArtifactSubmissionDocxFilename: sampleFilenameOverride()}
|
||||
if err := ok.Validate(); err != nil {
|
||||
t.Fatalf("valid spec rejected: %v", err)
|
||||
}
|
||||
|
||||
unknownArtifact := NameCompositionSpec{"no_such_artifact": sampleFilenameOverride()}
|
||||
if err := unknownArtifact.Validate(); err == nil {
|
||||
t.Errorf("unknown artifact accepted")
|
||||
}
|
||||
|
||||
unknownVar := NameCompositionSpec{ArtifactSubmissionDocxFilename: {
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{{Var: "opponent"}}, // not in the filename catalog
|
||||
}}
|
||||
if err := unknownVar.Validate(); err == nil {
|
||||
t.Errorf("override referencing a variable outside the artifact catalog accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNameCompositionSpec_SanitizeForRead(t *testing.T) {
|
||||
spec := NameCompositionSpec{
|
||||
"no_such_artifact": sampleFilenameOverride(),
|
||||
ArtifactSubmissionDocxFilename: {Version: 0, Segments: []nomen.Segment{{Var: "date"}, {Var: "ghost"}}},
|
||||
}
|
||||
changed := spec.SanitizeForRead()
|
||||
if !changed {
|
||||
t.Fatalf("SanitizeForRead reported no change")
|
||||
}
|
||||
if _, ok := spec["no_such_artifact"]; ok {
|
||||
t.Errorf("unknown artifact survived sanitisation")
|
||||
}
|
||||
got := spec[ArtifactSubmissionDocxFilename]
|
||||
if got.Version != nomen.Version {
|
||||
t.Errorf("version not clamped: %d", got.Version)
|
||||
}
|
||||
if len(got.Segments) != 1 || got.Segments[0].Var != "date" {
|
||||
t.Errorf("ghost segment survived: %+v", got.Segments)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveComposition(t *testing.T) {
|
||||
// nil overrides → system default.
|
||||
sys := resolveComposition(ArtifactSubmissionDocxFilename, nil)
|
||||
if len(sys.Segments) != 3 {
|
||||
t.Errorf("system default filename composition = %d segments, want 3", len(sys.Segments))
|
||||
}
|
||||
|
||||
// A valid user override wins.
|
||||
override := sampleFilenameOverride()
|
||||
got := resolveComposition(ArtifactSubmissionDocxFilename, NameCompositionSpec{ArtifactSubmissionDocxFilename: override})
|
||||
if len(got.Segments) != 2 {
|
||||
t.Errorf("override not applied: got %d segments, want 2", len(got.Segments))
|
||||
}
|
||||
|
||||
// An override that sanitises down to zero segments falls back to system.
|
||||
empty := NameCompositionSpec{ArtifactSubmissionDocxFilename: {Version: nomen.Version, Segments: []nomen.Segment{{Var: "ghost"}}}}
|
||||
fb := resolveComposition(ArtifactSubmissionDocxFilename, empty)
|
||||
if len(fb.Segments) != 3 {
|
||||
t.Errorf("invalid override should fall back to system default; got %d segments", len(fb.Segments))
|
||||
}
|
||||
}
|
||||
241
internal/services/name_template.go
Normal file
241
internal/services/name_template.go
Normal file
@@ -0,0 +1,241 @@
|
||||
package services
|
||||
|
||||
// Paliad-side glue for the nomen token-template shorthand (t-paliad-356 Slice 4,
|
||||
// PRD §7). The settings UI edits a single-line "{var}" template per artifact;
|
||||
// this file is the single authority that turns that string into a validated
|
||||
// nomen.Composition and renders the live previews. The frontend never parses
|
||||
// templates itself — it round-trips through these functions so the engine stays
|
||||
// the one source of truth (no duplicated parser to drift out of sync).
|
||||
//
|
||||
// - ParseNameTemplate: shorthand -> Composition. The shorthand carries Var,
|
||||
// separators and paren Wraps (nomen.ParseTemplate); MissingRules are NOT in
|
||||
// the shorthand (PRD §7), so they are overlaid here from the artifact's
|
||||
// system default. The result is validated against the artifact catalog.
|
||||
// - PreviewNameComposition: renders a parsed template against a fixed sample
|
||||
// (all project vars present) and an empties resolver (only the always-on
|
||||
// date), so the user sees both the normal result and the missing-rule
|
||||
// behaviour.
|
||||
// - SettingsNameArtifacts: the ordered, localised view the settings page
|
||||
// reads to build its per-artifact cards.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// settingsNameArtifactOrder fixes the order the two wired artifacts appear in
|
||||
// the settings UI (title before filename). New wired artifacts append here.
|
||||
var settingsNameArtifactOrder = []string{
|
||||
ArtifactSubmissionDraftTitle,
|
||||
ArtifactSubmissionDocxFilename,
|
||||
}
|
||||
|
||||
// canonicalVarOrder fixes the palette chip order so it is deterministic across
|
||||
// requests (catalogs are maps). Vars absent from this list sort after the known
|
||||
// ones, alphabetically — a safety net for future catalog additions.
|
||||
var canonicalVarOrder = []string{"date", "client", "forum", "opponent", "keyword", "case_number"}
|
||||
|
||||
// ParseNameTemplate compiles a token-template shorthand into a validated
|
||||
// Composition for an artifact. MissingRules come from the artifact's system
|
||||
// default (a var the default does not carry keeps the parser's KindOmit); the
|
||||
// shorthand never sets them (PRD §7). Returns an ErrInvalidInput-wrapped error
|
||||
// for an unknown artifact, a malformed template, or an unknown variable.
|
||||
func ParseNameTemplate(artifactID, template string) (nomen.Composition, error) {
|
||||
art, ok := NameArtifact(artifactID)
|
||||
if !ok {
|
||||
return nomen.Composition{}, fmt.Errorf("%w: unknown name artifact %q", ErrInvalidInput, artifactID)
|
||||
}
|
||||
comp, err := nomen.ParseTemplate(template)
|
||||
if err != nil {
|
||||
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
missing := make(map[string]nomen.MissingRule, len(art.SystemDefault.Segments))
|
||||
for _, seg := range art.SystemDefault.Segments {
|
||||
missing[seg.Var] = seg.Missing
|
||||
}
|
||||
for i := range comp.Segments {
|
||||
if m, ok := missing[comp.Segments[i].Var]; ok {
|
||||
comp.Segments[i].Missing = m
|
||||
}
|
||||
}
|
||||
if err := comp.Validate(art.Catalog); err != nil {
|
||||
return nomen.Composition{}, fmt.Errorf("%w: %v", ErrInvalidInput, err)
|
||||
}
|
||||
return comp, nil
|
||||
}
|
||||
|
||||
// nameSampleResolver is the fixed preview fixture (PRD §7): client "Bayer AG",
|
||||
// forum "UPC", opponent "Sandoz", case "UPC_CFI_123/2026", render-time today.
|
||||
// keyword is intentionally absent so it exercises its missing rule (the title
|
||||
// omits it — matching a real project draft; the filename falls back to the
|
||||
// "submission" literal). When full is false only the always-on date resolves,
|
||||
// so the preview shows the missing-rule behaviour for every project-derived
|
||||
// variable.
|
||||
func nameSampleResolver(full bool) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
if key == "date" {
|
||||
return nomenDateBerlin(time.Now()), true
|
||||
}
|
||||
if !full {
|
||||
return "", false
|
||||
}
|
||||
switch key {
|
||||
case "client":
|
||||
return "Bayer AG", true
|
||||
case "forum":
|
||||
return "UPC", true
|
||||
case "opponent":
|
||||
return "Sandoz", true
|
||||
case "case_number":
|
||||
return "UPC_CFI_123/2026", true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// PreviewNameComposition parses a template for an artifact and renders it twice:
|
||||
// full (the fixed sample with all project vars present) and empty (only the
|
||||
// always-on date, so missing rules show). A parse/validation error is returned
|
||||
// instead — the caller surfaces it inline and disables Save.
|
||||
func PreviewNameComposition(artifactID, template string) (full, empty string, err error) {
|
||||
comp, err := ParseNameTemplate(artifactID, template)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
art, _ := NameArtifact(artifactID)
|
||||
full = comp.Render(nameSampleResolver(true), art.Target)
|
||||
empty = comp.Render(nameSampleResolver(false), art.Target)
|
||||
return full, empty, nil
|
||||
}
|
||||
|
||||
// NameVarView is one palette chip: a variable's key plus its localised labels.
|
||||
type NameVarView struct {
|
||||
Var string `json:"var"`
|
||||
Label string `json:"label"`
|
||||
LabelEN string `json:"label_en"`
|
||||
}
|
||||
|
||||
// NameCompositionView is one artifact's settings card. Template is the
|
||||
// effective composition shown to the user (user override → firm default →
|
||||
// system, first present wins); previews render that effective template.
|
||||
// IsOverride flags a per-user override; FirmIsSet/FirmTemplate expose the firm
|
||||
// tier (for the admin firm controls and the "firm default" badge);
|
||||
// SystemTemplate is the code-resident default (the ultimate fallback and the
|
||||
// admin "reset firm to system" reference).
|
||||
type NameCompositionView struct {
|
||||
ArtifactID string `json:"artifact_id"`
|
||||
Label string `json:"label"`
|
||||
LabelEN string `json:"label_en"`
|
||||
Template string `json:"template"`
|
||||
SystemTemplate string `json:"system_template"`
|
||||
IsOverride bool `json:"is_override"`
|
||||
FirmIsSet bool `json:"firm_is_set"`
|
||||
FirmTemplate string `json:"firm_template"`
|
||||
Palette []NameVarView `json:"palette"`
|
||||
PreviewFull string `json:"preview_full"`
|
||||
PreviewEmpty string `json:"preview_empty"`
|
||||
}
|
||||
|
||||
// orderedPalette returns an artifact catalog's variables as palette chips in
|
||||
// canonicalVarOrder (unknown vars alphabetical, last).
|
||||
func orderedPalette(catalog nomen.VarCatalog) []NameVarView {
|
||||
rank := make(map[string]int, len(canonicalVarOrder))
|
||||
for i, v := range canonicalVarOrder {
|
||||
rank[v] = i
|
||||
}
|
||||
out := make([]NameVarView, 0, len(catalog))
|
||||
for key, def := range catalog {
|
||||
out = append(out, NameVarView{Var: key, Label: def.Label, LabelEN: def.LabelEN})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool {
|
||||
ri, oki := rank[out[i].Var]
|
||||
rj, okj := rank[out[j].Var]
|
||||
switch {
|
||||
case oki && okj:
|
||||
return ri < rj
|
||||
case oki != okj:
|
||||
return oki // known vars before unknown
|
||||
default:
|
||||
return out[i].Var < out[j].Var
|
||||
}
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
// SettingsNameArtifacts builds the per-artifact views for the settings page,
|
||||
// applying the precedence chain user → firm → system per artifact. Both spec
|
||||
// maps are already SanitizeForRead'd by their loaders; either may be nil.
|
||||
// Order is fixed by settingsNameArtifactOrder.
|
||||
func SettingsNameArtifacts(user, firm NameCompositionSpec) []NameCompositionView {
|
||||
views := make([]NameCompositionView, 0, len(settingsNameArtifactOrder))
|
||||
for _, id := range settingsNameArtifactOrder {
|
||||
if v, ok := SettingsNameArtifact(id, user, firm); ok {
|
||||
views = append(views, v)
|
||||
}
|
||||
}
|
||||
return views
|
||||
}
|
||||
|
||||
// SettingsNameArtifact builds one artifact's settings view, resolving the
|
||||
// effective template via user → firm → system. Returns (zero, false) for an
|
||||
// unknown artifact id. Used by the per-artifact PUT/DELETE responses so the
|
||||
// client refreshes only the touched card.
|
||||
func SettingsNameArtifact(id string, user, firm NameCompositionSpec) (NameCompositionView, bool) {
|
||||
art, ok := NameArtifact(id)
|
||||
if !ok {
|
||||
return NameCompositionView{}, false
|
||||
}
|
||||
systemTemplate := art.SystemDefault.Template()
|
||||
|
||||
firmComp, firmIsSet := storedComposition(firm, id)
|
||||
firmTemplate := ""
|
||||
if firmIsSet {
|
||||
firmTemplate = firmComp.Template()
|
||||
}
|
||||
|
||||
// Effective template: user override wins, else the firm default, else
|
||||
// system. IsOverride flags only the per-user tier (the "you customised
|
||||
// this" badge); the firm tier surfaces via FirmIsSet/FirmTemplate.
|
||||
template := systemTemplate
|
||||
isOverride := false
|
||||
if userComp, ok := storedComposition(user, id); ok {
|
||||
template = userComp.Template()
|
||||
isOverride = true
|
||||
} else if firmIsSet {
|
||||
template = firmTemplate
|
||||
}
|
||||
|
||||
// Previews reflect the effective template; a parse error here would mean a
|
||||
// stored composition we already validated is somehow unparseable — fall
|
||||
// back to empty previews rather than failing the page.
|
||||
full, empty, _ := PreviewNameComposition(id, template)
|
||||
return NameCompositionView{
|
||||
ArtifactID: id,
|
||||
Label: art.Label,
|
||||
LabelEN: art.LabelEN,
|
||||
Template: template,
|
||||
SystemTemplate: systemTemplate,
|
||||
IsOverride: isOverride,
|
||||
FirmIsSet: firmIsSet,
|
||||
FirmTemplate: firmTemplate,
|
||||
Palette: orderedPalette(art.Catalog),
|
||||
PreviewFull: full,
|
||||
PreviewEmpty: empty,
|
||||
}, true
|
||||
}
|
||||
|
||||
// storedComposition returns (comp, true) when spec carries a non-empty
|
||||
// composition for the artifact, else (zero, false).
|
||||
func storedComposition(spec NameCompositionSpec, id string) (nomen.Composition, bool) {
|
||||
if spec == nil {
|
||||
return nomen.Composition{}, false
|
||||
}
|
||||
comp, ok := spec[id]
|
||||
if !ok || len(comp.Segments) == 0 {
|
||||
return nomen.Composition{}, false
|
||||
}
|
||||
return comp, true
|
||||
}
|
||||
174
internal/services/name_template_test.go
Normal file
174
internal/services/name_template_test.go
Normal file
@@ -0,0 +1,174 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
var datePrefix = regexp.MustCompile(`^\d{4}-\d{2}-\d{2}`)
|
||||
|
||||
// TestParseNameTemplate_RoundTripsSystemDefaults asserts the system-default
|
||||
// compositions survive Template() -> ParseNameTemplate unchanged in
|
||||
// Var/Sep/Wrap, with MissingRules re-overlaid from the default. This is the
|
||||
// guard that the settings shorthand is a faithful authoring view of the seed.
|
||||
func TestParseNameTemplate_RoundTripsSystemDefaults(t *testing.T) {
|
||||
for _, id := range []string{ArtifactSubmissionDraftTitle, ArtifactSubmissionDocxFilename} {
|
||||
art, _ := NameArtifact(id)
|
||||
tmpl := art.SystemDefault.Template()
|
||||
got, err := ParseNameTemplate(id, tmpl)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: ParseNameTemplate(%q): %v", id, tmpl, err)
|
||||
}
|
||||
want := art.SystemDefault
|
||||
if len(got.Segments) != len(want.Segments) {
|
||||
t.Fatalf("%s: %d segments, want %d", id, len(got.Segments), len(want.Segments))
|
||||
}
|
||||
for i, seg := range got.Segments {
|
||||
w := want.Segments[i]
|
||||
if seg.Var != w.Var || seg.Sep != w.Sep || seg.Wrap != w.Wrap || seg.Missing != w.Missing {
|
||||
t.Errorf("%s seg %d = %+v, want %+v", id, i, seg, w)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseNameTemplate_Errors(t *testing.T) {
|
||||
cases := []struct {
|
||||
name, artifact, template string
|
||||
}{
|
||||
{"unknown artifact", "nope", "{date}"},
|
||||
{"unknown variable", ArtifactSubmissionDocxFilename, "{date} {client}"}, // client not in filename catalog
|
||||
{"malformed", ArtifactSubmissionDraftTitle, "{date"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.name, func(t *testing.T) {
|
||||
if _, err := ParseNameTemplate(c.artifact, c.template); err == nil {
|
||||
t.Errorf("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPreviewNameComposition_SystemDefaults asserts the fixed-sample previews
|
||||
// match the two shipped schemes. The date is render-time today, so only its
|
||||
// shape is checked; the rest is byte-exact.
|
||||
func TestPreviewNameComposition_SystemDefaults(t *testing.T) {
|
||||
titleTmpl, _ := NameArtifact(ArtifactSubmissionDraftTitle)
|
||||
full, empty, err := PreviewNameComposition(ArtifactSubmissionDraftTitle, titleTmpl.SystemDefault.Template())
|
||||
if err != nil {
|
||||
t.Fatalf("title preview: %v", err)
|
||||
}
|
||||
if !datePrefix.MatchString(full) {
|
||||
t.Errorf("title full preview %q has no leading date", full)
|
||||
}
|
||||
if !strings.HasSuffix(full, " Bayer AG ./. UPC ./. Sandoz") {
|
||||
t.Errorf("title full preview = %q, want date + ' Bayer AG ./. UPC ./. Sandoz'", full)
|
||||
}
|
||||
if !datePrefix.MatchString(empty) || strings.ContainsAny(empty, " ") {
|
||||
t.Errorf("title empty preview = %q, want bare date (all party segments omitted)", empty)
|
||||
}
|
||||
|
||||
fnTmpl, _ := NameArtifact(ArtifactSubmissionDocxFilename)
|
||||
full, empty, err = PreviewNameComposition(ArtifactSubmissionDocxFilename, fnTmpl.SystemDefault.Template())
|
||||
if err != nil {
|
||||
t.Fatalf("filename preview: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(full, " submission (UPC_CFI_123_2026).docx") {
|
||||
// '/' in the sample case number is sanitised to '_' by the filename target.
|
||||
t.Errorf("filename full preview = %q, want date + ' submission (UPC_CFI_123_2026).docx'", full)
|
||||
}
|
||||
if !strings.HasSuffix(empty, " submission (Az. folgt).docx") {
|
||||
t.Errorf("filename empty preview = %q, want date + ' submission (Az. folgt).docx'", empty)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsNameArtifacts_OverrideShown asserts a stored override surfaces as
|
||||
// IsOverride with its own template, while the untouched artifact stays system.
|
||||
func TestSettingsNameArtifacts_OverrideShown(t *testing.T) {
|
||||
override := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
spec := NameCompositionSpec{ArtifactSubmissionDocxFilename: override}
|
||||
|
||||
views := SettingsNameArtifacts(spec, nil)
|
||||
if len(views) != 2 {
|
||||
t.Fatalf("got %d views, want 2", len(views))
|
||||
}
|
||||
byID := map[string]NameCompositionView{}
|
||||
for _, v := range views {
|
||||
byID[v.ArtifactID] = v
|
||||
}
|
||||
if v := byID[ArtifactSubmissionDocxFilename]; !v.IsOverride || v.Template != "{date} {keyword}" {
|
||||
t.Errorf("filename view = %+v, want IsOverride + template '{date} {keyword}'", v)
|
||||
}
|
||||
if v := byID[ArtifactSubmissionDraftTitle]; v.IsOverride {
|
||||
t.Errorf("title view should be system default (no override), got IsOverride")
|
||||
}
|
||||
// Order is fixed: title first, filename second.
|
||||
if views[0].ArtifactID != ArtifactSubmissionDraftTitle || views[1].ArtifactID != ArtifactSubmissionDocxFilename {
|
||||
t.Errorf("artifact order = [%s %s], want [title filename]", views[0].ArtifactID, views[1].ArtifactID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSettingsNameArtifact_FirmTier asserts the firm tier shows through when
|
||||
// the user has no override, and that a user override still wins over the firm
|
||||
// default. Mirrors the precedence user → firm → system.
|
||||
func TestSettingsNameArtifact_FirmTier(t *testing.T) {
|
||||
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
}}
|
||||
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
|
||||
|
||||
// No user override → effective template is the firm default; FirmIsSet set.
|
||||
v, ok := SettingsNameArtifact(ArtifactSubmissionDocxFilename, nil, firm)
|
||||
if !ok {
|
||||
t.Fatal("artifact not found")
|
||||
}
|
||||
if v.IsOverride {
|
||||
t.Errorf("IsOverride should be false (no user override), got true")
|
||||
}
|
||||
if !v.FirmIsSet || v.FirmTemplate != "{date} {keyword}" {
|
||||
t.Errorf("firm tier = (set=%v, tmpl=%q), want (true, '{date} {keyword}')", v.FirmIsSet, v.FirmTemplate)
|
||||
}
|
||||
if v.Template != "{date} {keyword}" {
|
||||
t.Errorf("effective template = %q, want firm default '{date} {keyword}'", v.Template)
|
||||
}
|
||||
|
||||
// A user override beats the firm default in the effective template.
|
||||
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{
|
||||
{Var: "date", Missing: nomen.Omit()},
|
||||
}}
|
||||
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
|
||||
v, _ = SettingsNameArtifact(ArtifactSubmissionDocxFilename, user, firm)
|
||||
if !v.IsOverride || v.Template != "{date}" {
|
||||
t.Errorf("user override should win: IsOverride=%v template=%q, want true '{date}'", v.IsOverride, v.Template)
|
||||
}
|
||||
if !v.FirmIsSet {
|
||||
t.Errorf("FirmIsSet should remain true even when user override wins")
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveComposition_Precedence pins the render-path precedence: user beats
|
||||
// firm beats system; nil/empty tiers are skipped.
|
||||
func TestResolveComposition_Precedence(t *testing.T) {
|
||||
sys := nameArtifacts[ArtifactSubmissionDocxFilename].SystemDefault
|
||||
firmComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "date", Missing: nomen.Omit()}}}
|
||||
userComp := nomen.Composition{Version: nomen.Version, Segments: []nomen.Segment{{Var: "keyword", Missing: nomen.Literal("x")}}}
|
||||
firm := NameCompositionSpec{ArtifactSubmissionDocxFilename: firmComp}
|
||||
user := NameCompositionSpec{ArtifactSubmissionDocxFilename: userComp}
|
||||
|
||||
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, nil); len(got.Segments) != len(sys.Segments) {
|
||||
t.Errorf("no overrides → system default, got %d segments", len(got.Segments))
|
||||
}
|
||||
if got := resolveComposition(ArtifactSubmissionDocxFilename, nil, firm); got.Template() != firmComp.Template() {
|
||||
t.Errorf("firm beats system: got %q", got.Template())
|
||||
}
|
||||
if got := resolveComposition(ArtifactSubmissionDocxFilename, user, firm); got.Template() != userComp.Template() {
|
||||
t.Errorf("user beats firm: got %q", got.Template())
|
||||
}
|
||||
}
|
||||
302
internal/services/namegen.go
Normal file
302
internal/services/namegen.go
Normal file
@@ -0,0 +1,302 @@
|
||||
package services
|
||||
|
||||
// Paliad-side wiring for the pkg/nomen composition engine
|
||||
// (docs/plans/prd-filename-generator-2026-06-01.md, Slice 1).
|
||||
//
|
||||
// pkg/nomen stays pure; this file holds the paliad-specific pieces:
|
||||
// - the variable catalogs (which variables each artifact exposes),
|
||||
// - the seed system-default Compositions that reproduce the two shipped
|
||||
// naming schemes byte-for-byte (#155 draft title, t-paliad-354 .docx
|
||||
// filename),
|
||||
// - the per-render VarResolvers built from the existing submission_autoname
|
||||
// helpers (submissionForumShort / submissionOpponentName / derefString),
|
||||
// - and the artifact registry binding artifact -> catalog -> target ->
|
||||
// default.
|
||||
//
|
||||
// The two public entry points (AutoSubmissionTitle here-adjacent, and
|
||||
// RenderSubmissionFilename) render through the registry so the engine is the
|
||||
// single source of truth. Folding the two schemes in as DATA (compositions)
|
||||
// rather than code is the whole point: future levels (user/firm overrides,
|
||||
// non-project degradation) layer on without re-deriving the assembly logic.
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
"mgit.msbls.de/m/paliad/pkg/nomen"
|
||||
)
|
||||
|
||||
// Artifact identifiers. v1 wires the two submission artifacts; further
|
||||
// artifacts (docforge export, data-zip, projection slug — PRD §4) register
|
||||
// alongside their own slice, with their own catalog/resolver, when they opt
|
||||
// in. They are intentionally NOT registered here as placeholders: an
|
||||
// artifact with no resolver and no consumer would be dead code.
|
||||
const (
|
||||
ArtifactSubmissionDraftTitle = "submission_draft_title"
|
||||
ArtifactSubmissionDocxFilename = "submission_docx_filename"
|
||||
)
|
||||
|
||||
// submissionFilenamePlaceholder fills the bracketed case-number slot when the
|
||||
// project has no Aktenzeichen yet (t-paliad-354). Kept as a named const so
|
||||
// the wording stays one-line changeable (m left the exact text open).
|
||||
const submissionFilenamePlaceholder = "Az. folgt"
|
||||
|
||||
// submissionKeywordFallback is the keyword used when neither a user override
|
||||
// nor a rule name resolves (t-paliad-354).
|
||||
const submissionKeywordFallback = "submission"
|
||||
|
||||
// Artifact binds a named output to its variable catalog, render target, and
|
||||
// system-default composition. The catalog drives validation + the settings
|
||||
// palette; the default is the seed used when no override exists.
|
||||
type Artifact struct {
|
||||
ID string
|
||||
Label string
|
||||
LabelEN string
|
||||
Catalog nomen.VarCatalog
|
||||
Target nomen.RenderTarget
|
||||
SystemDefault nomen.Composition
|
||||
}
|
||||
|
||||
// nameArtifacts is the v1 registry. Lookup via NameArtifact.
|
||||
var nameArtifacts = map[string]Artifact{
|
||||
ArtifactSubmissionDraftTitle: {
|
||||
ID: ArtifactSubmissionDraftTitle,
|
||||
Label: "Entwurfstitel",
|
||||
LabelEN: "Draft title",
|
||||
Catalog: submissionTitleCatalog(),
|
||||
Target: nomen.PlainTarget("title"),
|
||||
SystemDefault: submissionDraftTitleComposition(),
|
||||
},
|
||||
ArtifactSubmissionDocxFilename: {
|
||||
ID: ArtifactSubmissionDocxFilename,
|
||||
Label: "Dateiname (.docx)",
|
||||
LabelEN: "File name (.docx)",
|
||||
Catalog: submissionFilenameCatalog(),
|
||||
Target: nomen.FuncTarget{
|
||||
NameVal: "filename",
|
||||
Sanitiser: SanitiseSubmissionFileName,
|
||||
Suffix: ".docx",
|
||||
},
|
||||
SystemDefault: submissionDocxFilenameComposition(),
|
||||
},
|
||||
}
|
||||
|
||||
// NameArtifact returns the registered artifact for id, or (zero, false).
|
||||
func NameArtifact(id string) (Artifact, bool) {
|
||||
a, ok := nameArtifacts[id]
|
||||
return a, ok
|
||||
}
|
||||
|
||||
// SubmissionFilenameKeyword reads the per-document keyword override from a
|
||||
// draft's decoded composer_meta. The canonical shape is
|
||||
// composer_meta.name_overrides.keyword (Slice 3); the legacy
|
||||
// composer_meta.filename_keyword (t-paliad-354) is still honoured as
|
||||
// name_overrides.keyword (back-compat read). Returns "" when absent/blank —
|
||||
// the caller then falls back to the auto-derived rule name.
|
||||
func SubmissionFilenameKeyword(d *SubmissionDraft) string {
|
||||
if d == nil || d.ComposerMeta == nil {
|
||||
return ""
|
||||
}
|
||||
if no, ok := d.ComposerMeta["name_overrides"].(map[string]any); ok {
|
||||
if v, ok := no["keyword"].(string); ok {
|
||||
if t := strings.TrimSpace(v); t != "" {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
if v, ok := d.ComposerMeta["filename_keyword"].(string); ok {
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed compositions (the two shipped schemes, as data — PRD §5).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// submissionDraftTitleComposition reproduces AutoSubmissionTitle (#155) and
|
||||
// carries the non-project degradation (Slice 2, PRD §6):
|
||||
//
|
||||
// project draft: <date> <client> ./. <forum> ./. <opponent>
|
||||
// non-project draft: <date> <keyword>
|
||||
//
|
||||
// Trailing separators: the date joins the next segment with a space, the
|
||||
// identity segments join each other with " ./. ". Because separators are
|
||||
// owned by the left segment, dropping any identity segment (or all of them)
|
||||
// still yields the byte-exact original — e.g. client-absent renders
|
||||
// "<date> <forum> ./. <opponent>" with a single space after the date.
|
||||
//
|
||||
// The identity trio and the keyword are mutually exclusive by construction:
|
||||
// project drafts resolve client/forum/opponent and leave keyword empty;
|
||||
// non-project drafts have no project so the trio omits and the keyword
|
||||
// (document type, or an "Entwurf"/"Draft" fallback) carries the name. A
|
||||
// project draft therefore renders identically to #155 (keyword omits), which
|
||||
// is the Slice-2 regression guard. opponent.Sep is unused under this
|
||||
// invariant (it would only fire if both opponent and keyword emitted).
|
||||
func submissionDraftTitleComposition() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "client", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "forum", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "opponent", Sep: " ./. ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Sep: "", Missing: nomen.Omit()},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// submissionDocxFilenameComposition reproduces submissionFileName (354):
|
||||
//
|
||||
// <date> <keyword> (<case number>).docx
|
||||
//
|
||||
// keyword falls back to a fixed "submission" literal; the case number is
|
||||
// always rendered in parentheses, falling back to a placeholder when the
|
||||
// project has no Aktenzeichen. The .docx suffix and per-value sanitisation
|
||||
// come from the artifact's FuncTarget, not the composition.
|
||||
func submissionDocxFilenameComposition() nomen.Composition {
|
||||
return nomen.Composition{
|
||||
Version: nomen.Version,
|
||||
Segments: []nomen.Segment{
|
||||
{Var: "date", Sep: " ", Missing: nomen.Omit()},
|
||||
{Var: "keyword", Sep: " ", Missing: nomen.Literal(submissionKeywordFallback)},
|
||||
{Var: "case_number", Sep: "", Wrap: [2]string{"(", ")"}, Missing: nomen.Placeholder(submissionFilenamePlaceholder)},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Variable catalogs.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func submissionTitleCatalog() nomen.VarCatalog {
|
||||
return nomen.VarCatalog{
|
||||
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
|
||||
"client": {Key: "client", Label: "Mandant", LabelEN: "Client", Group: "parties", Description: "Name des Mandanten (Wurzel der Akte)"},
|
||||
"forum": {Key: "forum", Label: "Forum", LabelEN: "Forum", Group: "proceeding", Description: "Kurzbezeichnung des Forums (UPC, EPA, LG, …)"},
|
||||
"opponent": {Key: "opponent", Label: "Gegner", LabelEN: "Opponent", Group: "parties", Description: "Name der Gegenseite"},
|
||||
"keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokumenttyp — trägt den Namen projektloser Entwürfe"},
|
||||
}
|
||||
}
|
||||
|
||||
func submissionFilenameCatalog() nomen.VarCatalog {
|
||||
return nomen.VarCatalog{
|
||||
"date": {Key: "date", Label: "Datum", LabelEN: "Date", Group: "common", Description: "Aktuelles Datum (Europe/Berlin), JJJJ-MM-TT"},
|
||||
"keyword": {Key: "keyword", Label: "Stichwort", LabelEN: "Keyword", Group: "document", Description: "Dokument-/Schriftsatztyp; überschreibbar"},
|
||||
"case_number": {Key: "case_number", Label: "Aktenzeichen", LabelEN: "Case number", Group: "proceeding", Description: "Aktenzeichen des Verfahrens"},
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolvers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// nomenDateBerlin formats t as the JJJJ-MM-TT date in Europe/Berlin,
|
||||
// matching both shipped schemes. A failed zone load leaves t untouched
|
||||
// (same fallback the original code used).
|
||||
func nomenDateBerlin(t time.Time) string {
|
||||
if loc, err := time.LoadLocation("Europe/Berlin"); err == nil {
|
||||
t = t.In(loc)
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
// submissionTitleResolver yields the draft-title variables. now is injected
|
||||
// (tests pin a fixed instant); the three identity segments resolve from the
|
||||
// existing helpers and report absence so the composition's Omit rule drops
|
||||
// them. keyword is empty for project drafts (the trio carries the name) and
|
||||
// holds the document type — or an "Entwurf"/"Draft" fallback — for
|
||||
// project-less drafts (Slice 2); the caller resolves it (it needs a DB hop)
|
||||
// and passes the value in, keeping this resolver pure.
|
||||
func submissionTitleResolver(now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType, keyword string) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
switch key {
|
||||
case "date":
|
||||
return nomenDateBerlin(now), true
|
||||
case "client":
|
||||
c := strings.TrimSpace(clientName)
|
||||
return c, c != ""
|
||||
case "forum":
|
||||
f := submissionForumShort(pt)
|
||||
return f, f != ""
|
||||
case "opponent":
|
||||
ourSide := ""
|
||||
if project != nil {
|
||||
ourSide = derefString(project.OurSide)
|
||||
}
|
||||
o := submissionOpponentName(parties, ourSide)
|
||||
return o, o != ""
|
||||
case "keyword":
|
||||
k := strings.TrimSpace(keyword)
|
||||
return k, k != ""
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// renderSubmissionDraftTitle is the single render path for the
|
||||
// submission_draft_title artifact, shared by the project path
|
||||
// (AutoSubmissionTitle, keyword="") and the non-project path
|
||||
// (autoNameForNonProject, trio nil + keyword set). overrides may carry a
|
||||
// per-user composition override (Slice 3); nil renders the system default.
|
||||
func renderSubmissionDraftTitle(user, firm NameCompositionSpec, now time.Time, clientName string, project *models.Project, parties []models.Party, pt *models.ProceedingType, keyword string) string {
|
||||
comp := resolveComposition(ArtifactSubmissionDraftTitle, user, firm)
|
||||
resolve := submissionTitleResolver(now, clientName, project, parties, pt, keyword)
|
||||
return comp.Render(resolve, nameArtifacts[ArtifactSubmissionDraftTitle].Target)
|
||||
}
|
||||
|
||||
// submissionFilenameResolver yields the .docx-filename variables. The date is
|
||||
// render-time "today" (the original used time.Now()); keyword applies the
|
||||
// override -> lang-aware rule name precedence and reports absence so the
|
||||
// composition's "submission" literal kicks in; case_number reports absence so
|
||||
// the "(Az. folgt)" placeholder kicks in.
|
||||
func submissionFilenameResolver(rule *models.DeadlineRule, project *models.Project, lang, keyword string) nomen.VarResolver {
|
||||
return func(key string) (string, bool) {
|
||||
switch key {
|
||||
case "date":
|
||||
return nomenDateBerlin(time.Now()), true
|
||||
case "keyword":
|
||||
kw := strings.TrimSpace(keyword)
|
||||
if kw == "" && rule != nil {
|
||||
kw = strings.TrimSpace(rule.Name)
|
||||
if strings.EqualFold(lang, "en") && strings.TrimSpace(rule.NameEN) != "" {
|
||||
kw = strings.TrimSpace(rule.NameEN)
|
||||
}
|
||||
}
|
||||
return kw, kw != ""
|
||||
case "case_number":
|
||||
if project != nil && project.CaseNumber != nil {
|
||||
c := strings.TrimSpace(*project.CaseNumber)
|
||||
if c != "" {
|
||||
return c, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// RenderSubmissionFilename produces the user-facing download name for a
|
||||
// generated submission (t-paliad-354), rendered through the nomen engine:
|
||||
// "<JJJJ-MM-TT> <keyword> (<case number>).docx". keyword is the user override
|
||||
// when set, else the lang-aware rule name, else "submission"; the case number
|
||||
// falls back to "(Az. folgt)" when the project has no Aktenzeichen. Each
|
||||
// variable value is sanitised for SMB-safe filenames while the frame (spaces,
|
||||
// parentheses, .docx) is preserved.
|
||||
func RenderSubmissionFilename(rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
return RenderSubmissionFilenameFor(nil, nil, rule, project, lang, keyword)
|
||||
}
|
||||
|
||||
// RenderSubmissionFilenameFor renders the .docx filename honouring the
|
||||
// composition precedence chain user → firm → system (Slice 3 + Slice 5); pass
|
||||
// nil for a tier the caller hasn't loaded. keyword is still the per-document
|
||||
// value override (name_overrides.keyword); the value override and the
|
||||
// composition overrides are independent — one swaps a variable's value, the
|
||||
// other swaps the template.
|
||||
func RenderSubmissionFilenameFor(user, firm NameCompositionSpec, rule *models.DeadlineRule, project *models.Project, lang, keyword string) string {
|
||||
comp := resolveComposition(ArtifactSubmissionDocxFilename, user, firm)
|
||||
resolve := submissionFilenameResolver(rule, project, lang, keyword)
|
||||
return comp.Render(resolve, nameArtifacts[ArtifactSubmissionDocxFilename].Target)
|
||||
}
|
||||
34
internal/services/namegen_test.go
Normal file
34
internal/services/namegen_test.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package services
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestNameArtifactsValidate guards the seed system-default compositions
|
||||
// against their own catalogs — a typo'd variable in a seed composition (a key
|
||||
// the catalog doesn't declare) fails here rather than silently rendering
|
||||
// nothing in production.
|
||||
func TestNameArtifactsValidate(t *testing.T) {
|
||||
for id, art := range nameArtifacts {
|
||||
if art.ID != id {
|
||||
t.Errorf("artifact %q has mismatched ID %q", id, art.ID)
|
||||
}
|
||||
if art.Target == nil {
|
||||
t.Errorf("artifact %q has nil target", id)
|
||||
}
|
||||
if err := art.SystemDefault.Validate(art.Catalog); err != nil {
|
||||
t.Errorf("artifact %q system default invalid: %v", id, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNameArtifactLookup covers the registry accessor.
|
||||
func TestNameArtifactLookup(t *testing.T) {
|
||||
if _, ok := NameArtifact(ArtifactSubmissionDraftTitle); !ok {
|
||||
t.Errorf("draft-title artifact not registered")
|
||||
}
|
||||
if _, ok := NameArtifact(ArtifactSubmissionDocxFilename); !ok {
|
||||
t.Errorf("docx-filename artifact not registered")
|
||||
}
|
||||
if _, ok := NameArtifact("nonexistent"); ok {
|
||||
t.Errorf("lookup of unknown artifact returned ok")
|
||||
}
|
||||
}
|
||||
262
internal/services/preview_render.go
Normal file
262
internal/services/preview_render.go
Normal file
@@ -0,0 +1,262 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Truthful base-preview render pipeline (t-paliad-370 S4, PRD §2.3).
|
||||
//
|
||||
// A .docx (produced byte-faithfully by the real export pipeline) is converted
|
||||
// to a PDF by a Gotenberg sidecar (LibreOffice-as-HTTP-service — keeps the Go
|
||||
// image lean: no LibreOffice in-process) and rasterised to one PNG per page by
|
||||
// poppler's pdftoppm. Results are cached and conversions single-flighted +
|
||||
// serialised (LibreOffice is concurrency-touchy). The whole thing sits behind
|
||||
// an interface so the cache + endpoint are unit-testable with a fake, and so
|
||||
// the converter backend can be swapped without touching callers.
|
||||
//
|
||||
// When no sidecar is configured (GOTENBERG_URL unset) or poppler is absent,
|
||||
// Available() is false and the preview endpoint degrades to the structural
|
||||
// HTML render (S3) — so the feature ships before the infra is provisioned.
|
||||
|
||||
// PreviewImageRenderer turns .docx bytes into one PNG per page.
|
||||
type PreviewImageRenderer interface {
|
||||
RenderPages(ctx context.Context, docx []byte) ([][]byte, error)
|
||||
Available() bool
|
||||
}
|
||||
|
||||
// GotenbergRenderer is the production PreviewImageRenderer: .docx → PDF via a
|
||||
// Gotenberg sidecar, PDF → PNG-per-page via poppler pdftoppm.
|
||||
type GotenbergRenderer struct {
|
||||
URL string // sidecar root, e.g. http://gotenberg:3000 (no trailing slash)
|
||||
DPI int // raster resolution; 110 is a readable preview default
|
||||
PdftoppmPath string // resolved poppler binary; "" => unavailable
|
||||
HTTP *http.Client
|
||||
}
|
||||
|
||||
// NewGotenbergRenderer builds the renderer from the sidecar URL + DPI. It
|
||||
// resolves pdftoppm on PATH once; Available() reflects both dependencies.
|
||||
func NewGotenbergRenderer(url string, dpi int) *GotenbergRenderer {
|
||||
if dpi <= 0 {
|
||||
dpi = 110
|
||||
}
|
||||
path, _ := exec.LookPath("pdftoppm")
|
||||
return &GotenbergRenderer{
|
||||
URL: strings.TrimRight(strings.TrimSpace(url), "/"),
|
||||
DPI: dpi,
|
||||
PdftoppmPath: path,
|
||||
HTTP: &http.Client{Timeout: 60 * time.Second},
|
||||
}
|
||||
}
|
||||
|
||||
// Available reports whether both the sidecar URL and poppler are configured.
|
||||
func (g *GotenbergRenderer) Available() bool {
|
||||
return g != nil && g.URL != "" && g.PdftoppmPath != ""
|
||||
}
|
||||
|
||||
// RenderPages converts the .docx to a PDF then rasterises every page.
|
||||
func (g *GotenbergRenderer) RenderPages(ctx context.Context, docx []byte) ([][]byte, error) {
|
||||
pdf, err := g.docxToPDF(ctx, docx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("docx→pdf: %w", err)
|
||||
}
|
||||
pages, err := g.pdfToPNGs(ctx, pdf)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pdf→png: %w", err)
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// docxToPDF POSTs the .docx to Gotenberg's LibreOffice route and returns the
|
||||
// PDF bytes.
|
||||
func (g *GotenbergRenderer) docxToPDF(ctx context.Context, docx []byte) ([]byte, error) {
|
||||
var body bytes.Buffer
|
||||
mw := multipart.NewWriter(&body)
|
||||
fw, err := mw.CreateFormFile("files", "document.docx")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := fw.Write(docx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := mw.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, g.URL+"/forms/libreoffice/convert", &body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("Content-Type", mw.FormDataContentType())
|
||||
|
||||
resp, err := g.HTTP.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
out, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("gotenberg status %d: %s", resp.StatusCode, strings.TrimSpace(string(out)))
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// pdfToPNGs rasterises every PDF page with pdftoppm into a temp dir, then reads
|
||||
// the PNGs back in page order.
|
||||
func (g *GotenbergRenderer) pdfToPNGs(ctx context.Context, pdf []byte) ([][]byte, error) {
|
||||
dir, err := os.MkdirTemp("", "paliad-preview-*")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
|
||||
inPath := filepath.Join(dir, "in.pdf")
|
||||
if err := os.WriteFile(inPath, pdf, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
prefix := filepath.Join(dir, "page")
|
||||
cmd := exec.CommandContext(ctx, g.PdftoppmPath, "-png", "-r", fmt.Sprintf("%d", g.DPI), inPath, prefix)
|
||||
if out, err := cmd.CombinedOutput(); err != nil {
|
||||
return nil, fmt.Errorf("pdftoppm: %w (%s)", err, strings.TrimSpace(string(out)))
|
||||
}
|
||||
|
||||
matches, err := filepath.Glob(prefix + "-*.png")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Strings(matches) // page-1.png, page-2.png, … sort lexically in page order
|
||||
pages := make([][]byte, 0, len(matches))
|
||||
for _, m := range matches {
|
||||
b, rerr := os.ReadFile(m)
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
pages = append(pages, b)
|
||||
}
|
||||
if len(pages) == 0 {
|
||||
return nil, fmt.Errorf("pdftoppm produced no pages")
|
||||
}
|
||||
return pages, nil
|
||||
}
|
||||
|
||||
// PreviewImageCache wraps a PreviewImageRenderer with a bounded in-memory cache
|
||||
// + single-flight (concurrent callers for the same key share one render) +
|
||||
// global serialisation of conversions (LibreOffice is concurrency-touchy). The
|
||||
// cached PNGs are a regenerable cache, NOT a retained document — nothing is
|
||||
// persisted to disk or DB, and any entry can be rebuilt from its inputs.
|
||||
type PreviewImageCache struct {
|
||||
renderer PreviewImageRenderer
|
||||
capacity int
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string][][]byte
|
||||
order []string // insertion order for FIFO eviction
|
||||
inflight map[string]*inflightRender
|
||||
|
||||
renderMu sync.Mutex // serialises the actual conversions
|
||||
}
|
||||
|
||||
type inflightRender struct {
|
||||
wg sync.WaitGroup
|
||||
pages [][]byte
|
||||
err error
|
||||
}
|
||||
|
||||
// NewPreviewImageCacheFromEnv builds the cache from GOTENBERG_URL + PREVIEW_DPI
|
||||
// (env). When GOTENBERG_URL is unset (or poppler is absent) the renderer is
|
||||
// unavailable and the preview endpoint falls back to the structural HTML
|
||||
// render — so this is safe to construct on every boot, sidecar or not.
|
||||
func NewPreviewImageCacheFromEnv() *PreviewImageCache {
|
||||
dpi, _ := strconv.Atoi(strings.TrimSpace(os.Getenv("PREVIEW_DPI")))
|
||||
return NewPreviewImageCache(NewGotenbergRenderer(os.Getenv("GOTENBERG_URL"), dpi), 0)
|
||||
}
|
||||
|
||||
// NewPreviewImageCache builds the cache. capacity <= 0 defaults to 64 entries.
|
||||
func NewPreviewImageCache(renderer PreviewImageRenderer, capacity int) *PreviewImageCache {
|
||||
if capacity <= 0 {
|
||||
capacity = 64
|
||||
}
|
||||
return &PreviewImageCache{
|
||||
renderer: renderer,
|
||||
capacity: capacity,
|
||||
entries: make(map[string][][]byte),
|
||||
inflight: make(map[string]*inflightRender),
|
||||
}
|
||||
}
|
||||
|
||||
// Available reports whether truthful rendering can run at all.
|
||||
func (c *PreviewImageCache) Available() bool {
|
||||
return c != nil && c.renderer != nil && c.renderer.Available()
|
||||
}
|
||||
|
||||
// Pages returns the cached PNG pages for key, building the .docx via buildDocx
|
||||
// and rendering on a miss. Concurrent callers for the same key wait on the
|
||||
// in-flight render rather than re-converting. buildDocx is only invoked on a
|
||||
// genuine miss.
|
||||
func (c *PreviewImageCache) Pages(ctx context.Context, key string, buildDocx func() ([]byte, error)) ([][]byte, error) {
|
||||
c.mu.Lock()
|
||||
if pages, ok := c.entries[key]; ok {
|
||||
c.mu.Unlock()
|
||||
return pages, nil
|
||||
}
|
||||
if call, ok := c.inflight[key]; ok {
|
||||
c.mu.Unlock()
|
||||
call.wg.Wait()
|
||||
return call.pages, call.err
|
||||
}
|
||||
call := &inflightRender{}
|
||||
call.wg.Add(1)
|
||||
c.inflight[key] = call
|
||||
c.mu.Unlock()
|
||||
|
||||
pages, err := func() ([][]byte, error) {
|
||||
docx, berr := buildDocx()
|
||||
if berr != nil {
|
||||
return nil, berr
|
||||
}
|
||||
// Serialise the actual conversion — LibreOffice/gotenberg dislikes
|
||||
// concurrent jobs, and a preview is not latency-critical.
|
||||
c.renderMu.Lock()
|
||||
defer c.renderMu.Unlock()
|
||||
return c.renderer.RenderPages(ctx, docx)
|
||||
}()
|
||||
|
||||
call.pages, call.err = pages, err
|
||||
|
||||
c.mu.Lock()
|
||||
delete(c.inflight, key)
|
||||
if err == nil {
|
||||
c.put(key, pages)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
call.wg.Done()
|
||||
return pages, err
|
||||
}
|
||||
|
||||
// put inserts an entry, evicting the oldest when at capacity. Caller holds c.mu.
|
||||
func (c *PreviewImageCache) put(key string, pages [][]byte) {
|
||||
if _, exists := c.entries[key]; !exists {
|
||||
c.order = append(c.order, key)
|
||||
}
|
||||
c.entries[key] = pages
|
||||
for len(c.order) > c.capacity {
|
||||
oldest := c.order[0]
|
||||
c.order = c.order[1:]
|
||||
delete(c.entries, oldest)
|
||||
}
|
||||
}
|
||||
149
internal/services/preview_render_test.go
Normal file
149
internal/services/preview_render_test.go
Normal file
@@ -0,0 +1,149 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// fakeRenderer counts RenderPages calls so the cache + single-flight behaviour
|
||||
// can be asserted without gotenberg/poppler.
|
||||
type fakeRenderer struct {
|
||||
calls int32
|
||||
available bool
|
||||
block chan struct{} // when non-nil, RenderPages blocks until closed
|
||||
}
|
||||
|
||||
func (f *fakeRenderer) Available() bool { return f.available }
|
||||
|
||||
func (f *fakeRenderer) RenderPages(ctx context.Context, docx []byte) ([][]byte, error) {
|
||||
atomic.AddInt32(&f.calls, 1)
|
||||
if f.block != nil {
|
||||
<-f.block
|
||||
}
|
||||
return [][]byte{[]byte("png-page-1")}, nil
|
||||
}
|
||||
|
||||
func TestPreviewCache_HitMiss(t *testing.T) {
|
||||
f := &fakeRenderer{available: true}
|
||||
c := NewPreviewImageCache(f, 8)
|
||||
|
||||
build := func() ([]byte, error) { return []byte("docx"), nil }
|
||||
|
||||
if _, err := c.Pages(context.Background(), "k1", build); err != nil {
|
||||
t.Fatalf("first miss: %v", err)
|
||||
}
|
||||
if _, err := c.Pages(context.Background(), "k1", build); err != nil {
|
||||
t.Fatalf("second (cached): %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&f.calls); got != 1 {
|
||||
t.Fatalf("expected 1 render for a cached key, got %d", got)
|
||||
}
|
||||
if _, err := c.Pages(context.Background(), "k2", build); err != nil {
|
||||
t.Fatalf("new key: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&f.calls); got != 2 {
|
||||
t.Fatalf("expected 2 renders for two keys, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCache_SingleFlight(t *testing.T) {
|
||||
f := &fakeRenderer{available: true, block: make(chan struct{})}
|
||||
c := NewPreviewImageCache(f, 8)
|
||||
build := func() ([]byte, error) { return []byte("docx"), nil }
|
||||
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
for range n {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, _ = c.Pages(context.Background(), "same", build)
|
||||
}()
|
||||
}
|
||||
// Let the goroutines collapse onto one in-flight render, then release it.
|
||||
for atomic.LoadInt32(&f.calls) == 0 {
|
||||
}
|
||||
close(f.block)
|
||||
wg.Wait()
|
||||
|
||||
if got := atomic.LoadInt32(&f.calls); got != 1 {
|
||||
t.Fatalf("single-flight: expected 1 render for %d concurrent same-key calls, got %d", n, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCache_Eviction(t *testing.T) {
|
||||
f := &fakeRenderer{available: true}
|
||||
c := NewPreviewImageCache(f, 2)
|
||||
build := func() ([]byte, error) { return []byte("docx"), nil }
|
||||
|
||||
for _, k := range []string{"a", "b", "c"} { // "a" evicted by capacity 2
|
||||
if _, err := c.Pages(context.Background(), k, build); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// "a" was evicted → re-rendering it is a fresh call.
|
||||
if _, err := c.Pages(context.Background(), "a", build); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := atomic.LoadInt32(&f.calls); got != 4 {
|
||||
t.Fatalf("expected 4 renders (a,b,c, then a again after eviction), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPreviewCache_Available(t *testing.T) {
|
||||
if NewPreviewImageCache(&fakeRenderer{available: false}, 4).Available() {
|
||||
t.Error("cache should be unavailable when the renderer is")
|
||||
}
|
||||
if !NewPreviewImageCache(&fakeRenderer{available: true}, 4).Available() {
|
||||
t.Error("cache should be available when the renderer is")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGotenbergRenderer_DocxToPDF asserts the converter POSTs a multipart .docx
|
||||
// to the LibreOffice route and returns the body — no poppler needed.
|
||||
func TestGotenbergRenderer_DocxToPDF(t *testing.T) {
|
||||
var gotPath, gotFile string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotPath = r.URL.Path
|
||||
_, params, _ := mime.ParseMediaType(r.Header.Get("Content-Type"))
|
||||
mr, err := r.MultipartReader()
|
||||
if err == nil && params != nil {
|
||||
if p, perr := mr.NextPart(); perr == nil {
|
||||
gotFile = p.FileName()
|
||||
_, _ = io.Copy(io.Discard, p)
|
||||
}
|
||||
}
|
||||
_, _ = w.Write([]byte("%PDF-1.7 fake"))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
g := NewGotenbergRenderer(srv.URL, 110)
|
||||
g.HTTP = srv.Client()
|
||||
pdf, err := g.docxToPDF(context.Background(), []byte("PK\x03\x04 fake docx"))
|
||||
if err != nil {
|
||||
t.Fatalf("docxToPDF: %v", err)
|
||||
}
|
||||
if !strings.HasPrefix(string(pdf), "%PDF") {
|
||||
t.Errorf("expected PDF bytes, got %q", string(pdf))
|
||||
}
|
||||
if gotPath != "/forms/libreoffice/convert" {
|
||||
t.Errorf("posted to %q, want /forms/libreoffice/convert", gotPath)
|
||||
}
|
||||
if !strings.HasSuffix(gotFile, ".docx") {
|
||||
t.Errorf("uploaded file %q, want a .docx", gotFile)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGotenbergRenderer_AvailableNeedsURL(t *testing.T) {
|
||||
// No URL → unavailable regardless of poppler.
|
||||
if NewGotenbergRenderer("", 110).Available() {
|
||||
t.Error("renderer with empty URL must be unavailable")
|
||||
}
|
||||
}
|
||||
@@ -1247,7 +1247,7 @@ func (s *ProjectionService) collectActualsForOverrides(
|
||||
}
|
||||
var dRows []drow
|
||||
scopeFilter := scopeProjectIDFilter("d", "project_id", projectID, directOnly)
|
||||
q := `SELECT d.rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
q := `SELECT d.sequencing_rule_id AS rule_id, d.rule_code, d.due_date, d.completed_at, d.status
|
||||
FROM paliad.deadlines d
|
||||
WHERE ` + scopeFilter
|
||||
if err := s.db.SelectContext(ctx, &dRows, q, projectID); err != nil {
|
||||
|
||||
@@ -24,13 +24,37 @@ import (
|
||||
// fall-through) and at the row level via the migration-157 RLS policies.
|
||||
// The application-level check is the load-bearing one — the service
|
||||
// connects with the service-role credential, which bypasses RLS.
|
||||
//
|
||||
// B4 (t-paliad-347 / m/paliad#153) adds the Akte-mode dual-write:
|
||||
// project-backed scenarios (origin_project_id IS NOT NULL) write flag
|
||||
// toggles through to paliad.projects.scenario_flags and "filed" event
|
||||
// toggles through to paliad.deadlines, so the project's Verlauf / Frist
|
||||
// rail reflect builder activity without a separate sync step. The
|
||||
// scenario row itself records canvas view-state (ordinal, collapsed,
|
||||
// per-card horizon, notes); the SSoT for project-bound actuals stays
|
||||
// paliad.deadlines / paliad.projects.scenario_flags (PRD §2.3 + §10).
|
||||
type ScenarioBuilderService struct {
|
||||
db *sqlx.DB
|
||||
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.
|
||||
func NewScenarioBuilderService(db *sqlx.DB) *ScenarioBuilderService {
|
||||
return &ScenarioBuilderService{db: db}
|
||||
// NewScenarioBuilderService wires the service to the shared pool plus
|
||||
// the project + scenario-flags services it leans on for the Akte-mode
|
||||
// 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
|
||||
@@ -204,7 +228,15 @@ func (s *ScenarioBuilderService) GetScenarioDeep(ctx context.Context, userID, sc
|
||||
return nil, ErrScenarioBuilderNotVisible
|
||||
}
|
||||
|
||||
deep := &BuilderScenarioDeep{BuilderScenario: *sc}
|
||||
deep := &BuilderScenarioDeep{
|
||||
BuilderScenario: *sc,
|
||||
// Initialise to empty so the JSON response always carries arrays,
|
||||
// not null — the builder frontend's renderCanvas calls .filter on
|
||||
// proceedings/events unconditionally once state.active is set.
|
||||
Proceedings: []BuilderProceeding{},
|
||||
Events: []BuilderEvent{},
|
||||
Shares: []BuilderShare{},
|
||||
}
|
||||
|
||||
if err := s.db.SelectContext(ctx, &deep.Proceedings, `
|
||||
SELECT id, scenario_id, proceeding_type_id, primary_party, scenario_flags,
|
||||
@@ -419,8 +451,19 @@ type PatchProceedingInput struct {
|
||||
}
|
||||
|
||||
// PatchProceeding updates fields on one proceeding row.
|
||||
//
|
||||
// Dual-write (B4): when the parent scenario is project-backed
|
||||
// (scenarios.origin_project_id IS NOT NULL) and the patched proceeding
|
||||
// is the top-level triplet (parent_scenario_proceeding_id IS NULL) and
|
||||
// the patch includes scenario_flags, the merged flag delta also lands on
|
||||
// paliad.projects.scenario_flags via ScenarioFlagsService.Patch. Top-
|
||||
// level only because child triplets (CCR child etc.) represent spawned
|
||||
// sub-proceedings whose flags don't belong on the parent project row;
|
||||
// the spawned proceeding will get its own project record when (and if)
|
||||
// the scenario is promoted via the B5 wizard.
|
||||
func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID, input PatchProceedingInput) (*BuilderProceeding, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -483,7 +526,7 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
|
||||
created_at, updated_at`,
|
||||
strings.Join(sets, ", "), len(args)-1, len(args))
|
||||
var out BuilderProceeding
|
||||
err := s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
|
||||
err = s.withAuditTx(ctx, "scenario_builder: patch proceeding", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out, q, args...)
|
||||
})
|
||||
if err != nil {
|
||||
@@ -493,9 +536,55 @@ func (s *ScenarioBuilderService) PatchProceeding(ctx context.Context, userID, sc
|
||||
}
|
||||
return nil, fmt.Errorf("patch proceeding: %w", err)
|
||||
}
|
||||
|
||||
// B4 dual-write: if the scenario is Akte-backed and we just
|
||||
// changed scenario_flags on the top-level triplet, mirror the
|
||||
// merged delta onto paliad.projects.scenario_flags. The PATCH
|
||||
// fires after the scenario_proceedings UPDATE commits — a failure
|
||||
// here logs but doesn't roll back the builder write (the builder
|
||||
// state is the user-visible canvas; the project mirror is a
|
||||
// convenience).
|
||||
if sc.OriginProjectID != nil && out.ParentScenarioProceedingID == nil &&
|
||||
len(input.ScenarioFlags) > 0 && s.flags != nil {
|
||||
if delta, derr := flagDeltaFromBuilder(input.ScenarioFlags); derr == nil && len(delta) > 0 {
|
||||
if _, perr := s.flags.Patch(ctx, userID, *sc.OriginProjectID, delta); perr != nil {
|
||||
// Don't fail the builder PATCH — log via the audit
|
||||
// reason that landed in the tx and surface the
|
||||
// error through fmt so callers can still inspect.
|
||||
return nil, fmt.Errorf("dual-write to project scenario_flags: %w", perr)
|
||||
}
|
||||
}
|
||||
}
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// flagDeltaFromBuilder converts the builder's scenario_flags jsonb
|
||||
// (Record<string, unknown>) into the partial delta shape expected by
|
||||
// ScenarioFlagsService.Patch (map[string]*bool, where nil deletes the
|
||||
// key). Non-bool values are skipped; the builder only writes booleans
|
||||
// through its UI but defensive parsing keeps the dual-write honest if
|
||||
// a stray null sneaks in.
|
||||
func flagDeltaFromBuilder(raw json.RawMessage) (map[string]*bool, error) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var src map[string]any
|
||||
if err := json.Unmarshal(raw, &src); err != nil {
|
||||
return nil, fmt.Errorf("decode flag delta: %w", err)
|
||||
}
|
||||
out := make(map[string]*bool, len(src))
|
||||
for k, v := range src {
|
||||
switch val := v.(type) {
|
||||
case bool:
|
||||
b := val
|
||||
out[k] = &b
|
||||
case nil:
|
||||
out[k] = nil
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// DeleteProceeding removes a proceeding (and cascades to events + children).
|
||||
func (s *ScenarioBuilderService) DeleteProceeding(ctx context.Context, userID, scenarioID, proceedingID uuid.UUID) error {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
@@ -610,8 +699,20 @@ type PatchEventInput struct {
|
||||
|
||||
// PatchEvent updates fields on one event card. The card's parent
|
||||
// proceeding must belong to the addressed scenario.
|
||||
//
|
||||
// Dual-write (B4): when the parent scenario is project-backed
|
||||
// (scenarios.origin_project_id IS NOT NULL), the event's sequencing
|
||||
// rule is set, and the patch transitions the card to state='filed'
|
||||
// with an actual_date, the same fact lands on paliad.deadlines
|
||||
// (status='completed', completed_at=actual_date). If a deadline row
|
||||
// already exists for the (project_id, sequencing_rule_id) pair it's
|
||||
// updated in place; otherwise a fresh row is inserted carrying the
|
||||
// rule's display name + due_date=actual_date. The dual-write runs in
|
||||
// the same transaction as the scenario_events UPDATE so canvas and
|
||||
// project surfaces never diverge mid-flight.
|
||||
func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID, input PatchEventInput) (*BuilderEvent, error) {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
sc, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.assertEventInScenario(ctx, scenarioID, eventID); err != nil {
|
||||
@@ -659,8 +760,24 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
|
||||
horizon_optional, created_at, updated_at`,
|
||||
strings.Join(sets, ", "), len(args))
|
||||
var out BuilderEvent
|
||||
err := s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
|
||||
return tx.GetContext(ctx, &out, q, args...)
|
||||
err = s.withAuditTx(ctx, "scenario_builder: patch event", func(tx *sqlx.Tx) error {
|
||||
if err := tx.GetContext(ctx, &out, q, args...); err != nil {
|
||||
return err
|
||||
}
|
||||
// B4 dual-write: project-backed scenarios reflect "filed"
|
||||
// transitions on paliad.deadlines so the project's Verlauf /
|
||||
// Frist rail picks them up without a separate writer. We
|
||||
// only act when state explicitly flipped to 'filed' on this
|
||||
// patch — earlier rows that were already filed don't get
|
||||
// re-stamped.
|
||||
if sc.OriginProjectID != nil && input.State != nil && *input.State == "filed" &&
|
||||
out.SequencingRuleID != nil && out.ActualDate != nil {
|
||||
if err := s.dualWriteFiledDeadlineTx(ctx, tx, *sc.OriginProjectID,
|
||||
*out.SequencingRuleID, *out.ActualDate); err != nil {
|
||||
return fmt.Errorf("dual-write filed deadline: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("patch event: %w", err)
|
||||
@@ -668,6 +785,82 @@ func (s *ScenarioBuilderService) PatchEvent(ctx context.Context, userID, scenari
|
||||
return &out, nil
|
||||
}
|
||||
|
||||
// dualWriteFiledDeadlineTx upserts a paliad.deadlines row for the
|
||||
// (project_id, sequencing_rule_id) pair so a builder-filed event
|
||||
// surfaces on the project's deadline rail. If a row exists, it's
|
||||
// flipped to status='completed' + completed_at; otherwise a fresh row
|
||||
// is inserted with the rule's display name, due_date=actual_date, and
|
||||
// source='litigation_builder'. The whole thing runs inside the caller
|
||||
// transaction so the canvas event and the deadline never diverge.
|
||||
func (s *ScenarioBuilderService) dualWriteFiledDeadlineTx(ctx context.Context, tx *sqlx.Tx, projectID, ruleID uuid.UUID, actualDate time.Time) error {
|
||||
// Try update first — keeps any existing approval / event_type
|
||||
// hydration intact for deadlines created via the regular Akten
|
||||
// path. We touch only the columns the builder owns:
|
||||
// status / completed_at / updated_at.
|
||||
res, err := tx.ExecContext(ctx,
|
||||
`UPDATE paliad.deadlines
|
||||
SET status = 'completed',
|
||||
completed_at = $1,
|
||||
updated_at = now()
|
||||
WHERE project_id = $2
|
||||
AND sequencing_rule_id = $3
|
||||
AND status <> 'completed'`,
|
||||
actualDate, projectID, ruleID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update existing deadline: %w", err)
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Already-completed rows: leave them alone, the builder isn't
|
||||
// reopening anything. Detect via a count probe so we don't
|
||||
// double-insert.
|
||||
var existing int
|
||||
if err := tx.GetContext(ctx, &existing,
|
||||
`SELECT COUNT(*) FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND sequencing_rule_id = $2`,
|
||||
projectID, ruleID); err != nil {
|
||||
return fmt.Errorf("probe deadline row: %w", err)
|
||||
}
|
||||
if existing > 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// No existing row — insert a fresh deadline. The title comes from
|
||||
// paliad.procedural_events.name joined via sequencing_rules.
|
||||
// procedural_event_id (sequencing_rules itself doesn't carry a
|
||||
// display label — the name lives on the procedural_event row).
|
||||
// rule_code falls back when the event has no name; the literal
|
||||
// "Litigation-Builder Event" is the last resort for rules that
|
||||
// have no procedural_event_id either. source='rule' (already
|
||||
// allowed by deadlines_source_check) since the row is rule-backed
|
||||
// — the Litigation Builder doesn't get its own source bucket; the
|
||||
// audit_reason on the surrounding tx tells the audit log who
|
||||
// inserted it.
|
||||
var title string
|
||||
if err := tx.GetContext(ctx, &title,
|
||||
`SELECT COALESCE(NULLIF(pe.name, ''), NULLIF(sr.rule_code, ''), 'Litigation-Builder Event')
|
||||
FROM paliad.sequencing_rules sr
|
||||
LEFT JOIN paliad.procedural_events pe ON pe.id = sr.procedural_event_id
|
||||
WHERE sr.id = $1`, ruleID); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
title = "Litigation-Builder Event"
|
||||
} else {
|
||||
return fmt.Errorf("load rule name: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.ExecContext(ctx,
|
||||
`INSERT INTO paliad.deadlines
|
||||
(project_id, title, due_date, sequencing_rule_id, status, completed_at, source, approval_status)
|
||||
VALUES ($1, $2, $3::date, $4, 'completed', $5::timestamptz, 'rule', 'legacy')`,
|
||||
projectID, title, actualDate, ruleID, actualDate); err != nil {
|
||||
return fmt.Errorf("insert builder deadline: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteEvent removes one event card.
|
||||
func (s *ScenarioBuilderService) DeleteEvent(ctx context.Context, userID, scenarioID, eventID uuid.UUID) error {
|
||||
if _, err := s.requireOwnerOrLegacyEditor(ctx, userID, scenarioID); err != nil {
|
||||
@@ -743,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
|
||||
// -----------------------------------------------------------------------------
|
||||
@@ -908,6 +1533,189 @@ func (s *ScenarioBuilderService) requireOwnerOrLegacyEditor(ctx context.Context,
|
||||
return nil, ErrScenarioBuilderNotVisible
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
// Akte mode — project-backed scenarios (B4, t-paliad-347)
|
||||
// -----------------------------------------------------------------------------
|
||||
|
||||
// CreateScenarioFromProject builds a fresh project-backed scenario from
|
||||
// a paliad.projects row: the scenario's origin_project_id points at the
|
||||
// project, one top-level proceeding mirrors the project's
|
||||
// proceeding_type_id + our_side + scenario_flags, and every existing
|
||||
// paliad.deadlines row with a sequencing_rule_id surfaces as a
|
||||
// scenario_events row (state='filed' when the deadline is completed,
|
||||
// 'planned' otherwise).
|
||||
//
|
||||
// The scenario is the canvas view-state; paliad.projects.scenario_flags
|
||||
// + paliad.deadlines remain the SSoT for project-bound actuals (PRD
|
||||
// §2.3 + §10). Subsequent PatchProceeding / PatchEvent calls on this
|
||||
// scenario route their writes through to those SSoT tables via the
|
||||
// dual-write hooks below.
|
||||
//
|
||||
// Visibility: the caller must be able to see the project; the project's
|
||||
// type must be 'case' (it's the proceeding-bearing project rung) and
|
||||
// must have a proceeding_type_id set (otherwise there's nothing to seed
|
||||
// the builder with). Returns ErrInvalidInput when those preconditions
|
||||
// don't hold.
|
||||
func (s *ScenarioBuilderService) CreateScenarioFromProject(ctx context.Context, userID, projectID uuid.UUID) (*BuilderScenarioDeep, error) {
|
||||
if s.projects == nil {
|
||||
return nil, fmt.Errorf("%w: project service not wired", ErrInvalidInput)
|
||||
}
|
||||
proj, err := s.projects.GetByID(ctx, userID, projectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if proj == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
if proj.ProceedingTypeID == nil || *proj.ProceedingTypeID <= 0 {
|
||||
return nil, fmt.Errorf("%w: project %s has no proceeding_type_id — Akte-mode requires one", ErrInvalidInput, projectID)
|
||||
}
|
||||
|
||||
// Read the project's persisted scenario_flags. The column is jsonb
|
||||
// NOT NULL DEFAULT '{}' (mig 154) so an empty map is always safe.
|
||||
var rawFlags []byte
|
||||
if err := s.db.GetContext(ctx, &rawFlags,
|
||||
`SELECT scenario_flags FROM paliad.projects WHERE id = $1`, projectID); err != nil {
|
||||
return nil, fmt.Errorf("read project scenario_flags: %w", err)
|
||||
}
|
||||
if len(rawFlags) == 0 {
|
||||
rawFlags = []byte(`{}`)
|
||||
}
|
||||
|
||||
// Pull every active+published sequencing_rule deadline row on the
|
||||
// project so the canvas can render filed/planned actuals as event
|
||||
// cards from first paint. CCR sub-projects are reached separately
|
||||
// when the user toggles with_ccr; the seed only covers the addressed
|
||||
// project's deadlines.
|
||||
type deadlineRow struct {
|
||||
ID uuid.UUID `db:"id"`
|
||||
SequencingRuleID *uuid.UUID `db:"sequencing_rule_id"`
|
||||
Status string `db:"status"`
|
||||
DueDate time.Time `db:"due_date"`
|
||||
CompletedAt *time.Time `db:"completed_at"`
|
||||
}
|
||||
var deadlines []deadlineRow
|
||||
if err := s.db.SelectContext(ctx, &deadlines,
|
||||
`SELECT id, sequencing_rule_id, status, due_date, completed_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1 AND sequencing_rule_id IS NOT NULL`,
|
||||
projectID); err != nil {
|
||||
return nil, fmt.Errorf("read project deadlines: %w", err)
|
||||
}
|
||||
|
||||
// Derive the builder-side primary_party from the project's
|
||||
// our_side. The Project.OurSide column accepts the wider sub-role
|
||||
// set (claimant / applicant / appellant; defendant / respondent;
|
||||
// third_party / other) but the builder triplet has a binary
|
||||
// claimant|defendant axis per PRD §3.3 — fold the wider set down,
|
||||
// drop third_party / other to NULL (no perspective preselected).
|
||||
primaryParty := mapProjectOurSideToTripletParty(proj.OurSide)
|
||||
|
||||
name := strings.TrimSpace(proj.Title)
|
||||
if name == "" {
|
||||
name = "Akte"
|
||||
}
|
||||
|
||||
deep := &BuilderScenarioDeep{
|
||||
Proceedings: []BuilderProceeding{},
|
||||
Events: []BuilderEvent{},
|
||||
Shares: []BuilderShare{},
|
||||
}
|
||||
|
||||
err = s.withAuditTx(ctx, "scenario_builder: create from project", func(tx *sqlx.Tx) error {
|
||||
// 1. Insert the scenario header. origin_project_id pins the
|
||||
// Akte link; promotion later overwrites promoted_project_id
|
||||
// independently.
|
||||
if err := tx.GetContext(ctx, &deep.BuilderScenario,
|
||||
`INSERT INTO paliad.scenarios
|
||||
(owner_id, name, status, origin_project_id)
|
||||
VALUES ($1, $2, 'active', $3)
|
||||
RETURNING id, owner_id, name, status, origin_project_id, promoted_project_id,
|
||||
stichtag, notes,
|
||||
project_id, description, created_by,
|
||||
created_at, updated_at`,
|
||||
userID, name, projectID); err != nil {
|
||||
return fmt.Errorf("insert scenario row: %w", err)
|
||||
}
|
||||
|
||||
// 2. Insert one top-level proceeding mirroring the project's
|
||||
// procedural shape + flags. scenario_flags is copied
|
||||
// verbatim from the project — subsequent toggles on the
|
||||
// builder propagate back via PatchProceeding's dual-write.
|
||||
var proc BuilderProceeding
|
||||
if err := tx.GetContext(ctx, &proc,
|
||||
`INSERT INTO paliad.scenario_proceedings
|
||||
(scenario_id, proceeding_type_id, primary_party, scenario_flags, ordinal, detailgrad)
|
||||
VALUES ($1, $2, $3, $4::jsonb, 0, 'selected')
|
||||
RETURNING 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`,
|
||||
deep.BuilderScenario.ID, *proj.ProceedingTypeID, primaryParty, rawFlags); err != nil {
|
||||
return fmt.Errorf("insert seed proceeding: %w", err)
|
||||
}
|
||||
deep.Proceedings = append(deep.Proceedings, proc)
|
||||
|
||||
// 3. One scenario_events row per project deadline. Filed
|
||||
// deadlines render with state='filed' + actual_date =
|
||||
// completed_at (falling back to due_date when the column
|
||||
// was never set). Pending / approved deadlines render
|
||||
// planned. Skipped is not derivable from the deadline row
|
||||
// shape; users mark skip on the canvas via PatchEvent.
|
||||
for _, d := range deadlines {
|
||||
state := "planned"
|
||||
var actualDate *time.Time
|
||||
if d.Status == "completed" {
|
||||
state = "filed"
|
||||
if d.CompletedAt != nil {
|
||||
actualDate = d.CompletedAt
|
||||
} else {
|
||||
due := d.DueDate
|
||||
actualDate = &due
|
||||
}
|
||||
}
|
||||
var ev BuilderEvent
|
||||
if err := tx.GetContext(ctx, &ev,
|
||||
`INSERT INTO paliad.scenario_events
|
||||
(scenario_proceeding_id, sequencing_rule_id, state, actual_date)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING id, scenario_proceeding_id, sequencing_rule_id, procedural_event_id,
|
||||
custom_label, state, actual_date, skip_reason, notes,
|
||||
horizon_optional, created_at, updated_at`,
|
||||
proc.ID, *d.SequencingRuleID, state, actualDate); err != nil {
|
||||
return fmt.Errorf("insert seed event: %w", err)
|
||||
}
|
||||
deep.Events = append(deep.Events, ev)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create scenario from project: %w", err)
|
||||
}
|
||||
return deep, nil
|
||||
}
|
||||
|
||||
// mapProjectOurSideToTripletParty folds paliad.projects.our_side (which
|
||||
// allows the wider claimant/applicant/appellant + defendant/respondent
|
||||
// + third_party/other set, mig 112) down to the builder triplet's
|
||||
// binary claimant|defendant axis (PRD §3.3). Returns nil when the
|
||||
// project hasn't picked a side or the role doesn't map (third_party /
|
||||
// other) — the canvas shows both columns equally in that case.
|
||||
func mapProjectOurSideToTripletParty(side *string) *string {
|
||||
if side == nil {
|
||||
return nil
|
||||
}
|
||||
switch *side {
|
||||
case "claimant", "applicant", "appellant":
|
||||
s := "claimant"
|
||||
return &s
|
||||
case "defendant", "respondent":
|
||||
s := "defendant"
|
||||
return &s
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// withAuditTx opens a transaction, stamps paliad.audit_reason via
|
||||
// set_config(..., true) so the reason persists for the duration of the
|
||||
// tx (matching the mig-079 audit-trigger pattern used by event_choice_
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user