Compare commits
21 Commits
mai/knuth/
...
mai/demete
| Author | SHA1 | Date | |
|---|---|---|---|
| 716f6d7ece | |||
| 206f2917ea | |||
| 5df87f4129 | |||
| 898348a64a | |||
| 1714b788d2 | |||
| db8335253b | |||
| 5589cbb477 | |||
| 0059e3f15b | |||
| a911a2d0ee | |||
| b26f04ffe0 | |||
| 8e195cb497 | |||
| 1f7de99493 | |||
| 0adcc2c826 | |||
| 2c7ac6423f | |||
| 436c1b41bb | |||
| 2c5f85b802 | |||
| d3aade5aac | |||
| 17d2fff661 | |||
| c6a5416611 | |||
| d590be4bb7 | |||
| f7374a67cd |
@@ -151,6 +151,14 @@ func main() {
|
||||
|
||||
eventTypeSvc := services.NewEventTypeService(pool, users)
|
||||
deadlineSvc := services.NewDeadlineService(pool, projectSvc, eventTypeSvc)
|
||||
partySvc := services.NewPartyService(pool, projectSvc)
|
||||
// t-paliad-238 — dedicated submission draft editor. The variable
|
||||
// bag service is shared between the renderer (export) and the
|
||||
// preview HTML path. Resurrected from t-paliad-215 Slice 1 backend
|
||||
// (commits 3677c81 + 1765d5e + 8ea3509).
|
||||
submissionVarsSvc := services.NewSubmissionVarsService(pool, projectSvc, partySvc, users)
|
||||
submissionRenderer := services.NewSubmissionRenderer()
|
||||
submissionDraftSvc := services.NewSubmissionDraftService(pool, projectSvc, submissionVarsSvc, submissionRenderer)
|
||||
// t-paliad-225 Slice A — user-authored checklist templates.
|
||||
// Slice B adds checklist_shares grants + admin promotion.
|
||||
checklistCatalogSvc := services.NewChecklistCatalogService(pool)
|
||||
@@ -160,7 +168,8 @@ func main() {
|
||||
Project: projectSvc,
|
||||
Team: teamSvc,
|
||||
PartnerUnit: partnerUnitSvc,
|
||||
Party: services.NewPartyService(pool, projectSvc),
|
||||
Party: partySvc,
|
||||
SubmissionDraft: submissionDraftSvc,
|
||||
Deadline: deadlineSvc,
|
||||
Appointment: appointmentSvc,
|
||||
CalDAV: caldavSvc,
|
||||
|
||||
668
docs/design-submission-page-2026-05-22.md
Normal file
668
docs/design-submission-page-2026-05-22.md
Normal file
@@ -0,0 +1,668 @@
|
||||
# Design — Dedicated Submission/Schriftsätze page (t-paliad-238)
|
||||
|
||||
**Author:** cronus (inventor)
|
||||
**Date:** 2026-05-22
|
||||
**Issue:** m/paliad (mai task t-paliad-238)
|
||||
**Branch:** `mai/cronus/inventor-dedicated`
|
||||
**Status:** DESIGN READY FOR REVIEW
|
||||
**Prior art:** `docs/design-submission-generator-2026-05-19.md` (t-paliad-215). This doc deepens that design rather than replacing it — every section below references the corresponding §x there when the shape is reused.
|
||||
|
||||
---
|
||||
|
||||
## §0 TL;DR
|
||||
|
||||
Today's "Schriftsätze" tab on the project detail page lists each filing-type rule and offers a one-click [Generieren] that streams a clean (format-only) `.docx` of the universal HL Patents Style template. There is no customization — variables, parties, dates, optional passages: none of it is filled in. The lawyer downloads the firm style, opens it in Word, and types everything by hand.
|
||||
|
||||
This design adds a **dedicated submission page** at `/projects/{id}/submissions/{code}/draft` where the lawyer:
|
||||
|
||||
1. Picks (or creates) a named **draft** for one (project, submission_code).
|
||||
2. Sees a sidebar with every `{{placeholder}}` the merge engine knows, pre-filled from the project's data (parties, court, case number, dates, legal_source) — editable inline. Auto-saved.
|
||||
3. Sees a read-only preview pane showing the merged document body as HTML.
|
||||
4. Clicks **Export → .docx** to download a fully-merged Word file (template + project + lawyer overrides), ready to edit.
|
||||
|
||||
Old [Generieren] button stays as the one-click "quick export with empty placeholders" path; the new [Bearbeiten] button next to it deep-links to the draft editor. Drafts persist as `paliad.submission_drafts` rows so the lawyer can come back next week, multiple drafts per submission code, RLS through `paliad.can_see_project`.
|
||||
|
||||
**Reuses** the deleted Slice 1 backend (`SubmissionVarsService` from commit `3677c81`, in-house `SubmissionRenderer` from `8ea3509`, `patent_number_upc` helper from `1765d5e`) wholesale — those files are salvageable from git history and slot back in with one new service (`SubmissionDraftService`) and the new schema. **Reuses** the `internal/handlers/files.go` Gitea proxy pattern for per-submission_code templates in `m/mWorkRepo/templates/{FIRM_NAME}/{code}.docx` (chain: firm → base/code → base/family → skeleton) — same fallback chain m locked in the 2026-05-19 design (§5).
|
||||
|
||||
Three slices: **A** = schema + new page + variables-only export against the universal `.dotm` (one slice; ships the editor end-to-end); **B** = per-`submission_code` `.docx` templates with the fallback-chain registry (template authoring is the bottleneck, not code); **C** = toggleable passages (boilerplate sections the lawyer can include/exclude before export).
|
||||
|
||||
Read-only inventor design. Implementation gate is m's go/no-go on this doc through head.
|
||||
|
||||
---
|
||||
|
||||
## §1 Premises verified live (2026-05-22)
|
||||
|
||||
Anchored against the running paliad codebase + youpc Supabase, not against CLAUDE.md or memory. Every claim that load-bears the design was checked against the live system.
|
||||
|
||||
| Claim | Verification |
|
||||
|---|---|
|
||||
| Today's `/api/projects/{id}/submissions` is **format-only**; no variables. | `internal/handlers/submissions.go:155-245`: handler fetches the universal `hl-patents-style.dotm` from the in-process `fileRegistry` cache, calls `services.ConvertDotmToDocx`, writes one `system_audit_log` row, streams. No project data merged. `frontend/src/client/submissions.ts` confirms the client side: POST → blob → download. The richer engine (`SubmissionVarsService` + `TemplateRegistry` + `SubmissionRenderer`) was reverted to format-only in commit `d86cac0` (t-paliad-230). |
|
||||
| Original Slice 1 backend is preserved in git history and salvageable. | `git show 3677c81:internal/services/submission_vars.go` (484 LoC, 7-namespace placeholder bag), `git show 8ea3509:internal/services/submission_render.go` (in-house run-fragmentation-aware merger, ~315 LoC), `git show 3677c81:internal/services/submission_templates.go` (442 LoC fallback-chain registry), `git show 1765d5e:internal/services/submission_vars.go` (Slice 2 added `{{project.patent_number_upc}}` helper). All four files compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`) — no API drift since 2026-05-20. |
|
||||
| `paliad.projects` carries everything the variable bag needs. | `internal/models/project.go:123` — `Title, Reference, CaseNumber, Court, PatentNumber, FilingDate, GrantDate, OurSide, InstanceLevel, ProceedingTypeID, ClientNumber, MatterNumber`. Unchanged since the original design. |
|
||||
| `paliad.parties` carries party data scoped per project. | `internal/models/project.go:567` (`Party{ID, ProjectID, Name, Role, Representative, ContactInfo}`); `PartyService.ListForProject(ctx, userID, projectID)` already exists at `internal/services/party_service.go`. Visibility flows from `ProjectService.GetByID` → `can_see_project`. |
|
||||
| `paliad.deadline_rules` published rows resolve by `submission_code`. | The same query the format-only handler uses (`internal/handlers/submissions.go:255-280`) — `lifecycle_state='published' AND is_active=true ORDER BY sequence_order LIMIT 1`. Today the corpus carries ~254 rules, ~214 published; covers DE-inf-LG, DE-inf-OLG, DE-inf-BGH, UPC-inf-CFI, DE-PatG-DPMA, DE-PatG-BPatG, EPO oppositions. |
|
||||
| Migration tracker is at 106; file list extends to 118 (other-branch work) on the worktree. | `SELECT version FROM paliad.paliad_schema_migrations ORDER BY version DESC LIMIT 1` → 106. `ls internal/db/migrations/` → `…118_paliadin_aichat_conversation`. The next free number for *this* branch's migration is **119** (collisions only if another worktree commits 119 first, in which case the coder picks the next unused). |
|
||||
| `paliad.can_see_project(uuid)` is the canonical RLS predicate. | Mig 055; every other table that gates on project visibility uses it. The new `submission_drafts` table follows the same pattern. |
|
||||
| The Schriftsätze tab already exists on project detail. | `frontend/src/projects-detail.tsx:91` (`data-tab="submissions"`), section `#tab-submissions` at line 629. Empty / no-proceeding / table-of-rules states already wired. The page-level route `GET /projects/{id}/submissions` exists at `internal/handlers/handlers.go:472` and renders the same project detail page with the submissions tab pre-selected (`#tab-submissions` URL fragment + tab activation). **No new top-level route needed**; this design adds a *deeper* route `/projects/{id}/submissions/{code}/draft` and `/draft/{draftID}`. |
|
||||
| `internal/handlers/files.go` carries the Gitea proxy + SHA-cache pattern. | Same template the original Slice 1 design lifted (in `templates/_base/de.inf.lg.erwidg.docx @ SHA 7f97b7f9` per memory). 5-min refresh, in-process cache, single-replica deployment. Reusable wholesale. |
|
||||
| `lukasjarosch/go-docx` is NOT a deal we made. | The original 2026-05-19 design recommended it, but the shipped Slice 1 (commit `8ea3509`) went with an **in-house renderer** because the library refuses to replace sibling `{{a}} ./. {{b}}` placeholders in the same run. The in-house engine handles cross-run fragmentation in ~315 LoC. **This design reuses the in-house engine, no new Go dependency.** Memory entry `ca6de586` corroborates the engine decision verbatim. |
|
||||
| `FIRM_NAME` defaults to "HLC", overridable. | `internal/branding.Name` (read once at process start). Templates land under `templates/HLC/...` for the default; the fallback chain handles per-firm overrides without code change. |
|
||||
| The PoC Paliadin is owner-gated; the submission page is NOT. | `internal/services/paliadin.go:52` — `PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"`. This is the LLM-shell-out boundary, irrelevant to template-merge. Every paliad user who can see a project can edit its submission drafts. |
|
||||
| The `entity-table` row contract is enforced. | `.claude/CLAUDE.md` → "Whole-card / whole-row click → use a JS row handler". The new draft list (when a submission_code has multiple drafts) follows the same pattern. |
|
||||
|
||||
**Doc-vs-live conflicts found:** none material. `docs/project-status.md` doesn't mention t-paliad-215 or t-paliad-230 yet — that's a documentation lag, not a design risk.
|
||||
|
||||
---
|
||||
|
||||
## §2 m's decisions (2026-05-22)
|
||||
|
||||
The task brief (mai task t-paliad-238 description) carries inventor recommendations (R) for ten open questions. Per project CLAUDE.md inventor → head escalation policy: inventor defaults to (R) unless the pick is materially expensive or risk-bearing, in which case the head escalates to m. The matrix below records the (R) defaults this design adopts; the four genuinely-material picks are escalated to head in §11.
|
||||
|
||||
| # | Question (from task brief) | Default adopted | Source |
|
||||
|---|---|---|---|
|
||||
| Q1 | Page location | **Deep page under project — `/projects/{id}/submissions/{code}/draft` and `…/draft/{draftID}`** | (R) — keeps URL self-describing and shareable with the project. |
|
||||
| Q2 | State persistence | **Server-side draft, `paliad.submission_drafts` keyed on `(project_id, submission_code, user_id, name)` with autosave** | (R) — multiple drafts per code, named; resumable across sessions. |
|
||||
| Q3 | Variable layer | **Resurrect `submission_vars.go` from commit `3677c81` + `1765d5e` (incl. `patent_number_upc`); resolve at export time** | (R) — proven shape, ~30 placeholders, 7 namespaces. |
|
||||
| Q4 | Customization surface (UI) | **Structured sidebar (variable list, editable values) + read-only HTML preview pane** | (R) — sidebar drives the merge; preview reflects the result. |
|
||||
| Q5 | Template source | **(a) per-`submission_code` `.docx` in `m/mWorkRepo/templates/{FIRM_NAME}/...` via Gitea proxy + fallback chain** | (R) — Word is the authoring surface lawyers know; mWorkRepo is the existing vehicle. |
|
||||
| Q6 | Customization options beyond variables | **v1: variables only. Toggleable passages = Slice C** | (R) — citation insertion waits for the sources system. |
|
||||
| Q7 | Migration / data shape | **See §4** | (R) — followed task brief's column list, refined for RLS + cascade + index. |
|
||||
| Q8 | Export endpoint | **`POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export` → `.docx`** | (R) — reuses `ConvertDotmToDocx` strip-macros path but adds merge step. |
|
||||
| Q9 | Schriftsätze tab integration | **Keep list + existing "Generieren" (one-click format-only); add new "Bearbeiten" button per row that deep-links to `/projects/{id}/submissions/{code}/draft`** | (R) — additive, no churn for users who only want the firm style. |
|
||||
| Q10 | Variable-merge library | **In-house renderer from commit `8ea3509` (no new Go module)** | (R) — the 2026-05-19 design recommended `lukasjarosch/go-docx`, but the shipped Slice 1 reverted to an in-house engine because the library refused sibling placeholders. The in-house engine handles run-fragmentation in ~315 LoC and is already battle-tested against the corpus. |
|
||||
|
||||
Inventor-defaulted (not in the (R) matrix; clear right answer):
|
||||
|
||||
| # | Topic | Default | Reasoning |
|
||||
|---|---|---|---|
|
||||
| D1 | Authorization | `paliad.can_see_project(project_id)` only. No profession floor. | Matches every other write surface on a project. Draft is a Word doc; lawyer's substantive review happens downstream. |
|
||||
| D2 | Missing-placeholder behaviour | `[KEIN WERT: {key}]` / `[NO VALUE: {key}]` in the rendered preview AND in the exported .docx, per `DefaultMissingMarker(lang)` from `8ea3509` | Same call the original design made. Lawyer sees the gap in Word, fixes in paliad, regenerates. Better than 400ing. |
|
||||
| D3 | Editor surface for templates | Gitea-only for v1 (admin edits .docx in Word, commits to mWorkRepo). | Per the original design §5. A paliad-side uploader is a Slice C+ affordance only if Gitea round-trip is friction. |
|
||||
| D4 | Audit trail | One `paliad.system_audit_log` row per export (`event_type='submission.exported'`) + one `paliad.project_events` row (`event_type='submission_exported'`) so the export surfaces in Verlauf / SmartTimeline. **Draft create/update do NOT audit** — autosave noise would dominate the log. | Mirrors the existing `submission.generated` row from `internal/handlers/submissions.go:333-339`. The Verlauf entry is the user-visible footprint; the system_audit_log entry is the admin-visible audit footprint. |
|
||||
| D5 | Preview engine | Server-side merge → render to HTML for preview pane. Same `SubmissionRenderer` walks the .docx, but for the preview it strips `<w:r>` / `<w:p>` to plain HTML paragraphs (no styling beyond paragraph breaks + bold/italic carry-through). The export endpoint produces the real .docx with all formatting preserved. | Cheaper than client-side OOXML parsing; matches the read-only Q4 contract. The preview is a fidelity guide, not a WYSIWYG editor — final formatting comes from Word. |
|
||||
| D6 | Draft autosave cadence | Debounce 500ms after the lawyer stops typing in a variable field; PATCH `…/drafts/{draftID}` with the diff. No optimistic locking — last-write-wins per (project, submission_code, user) draft, and we never multi-user a single draft (one row per `user_id`). | Standard textarea autosave; the data is the lawyer's own draft, not a shared object. |
|
||||
| D7 | Variable contract surfacing | Each placeholder in the sidebar shows: dotted key (e.g. `project.case_number`), human label (DE/EN), current resolved value (from project state), and an editable override field. Override empty → fall back to project state at export. Override filled → carry the lawyer's value into the merge. | Lawyer never has to leave the page to fix a project-level field; AND lawyer can locally override (e.g. "Court is wrong on this draft, but I don't want to edit the project") without polluting project state. |
|
||||
| D8 | Draft naming | First draft per (project, submission_code, user) auto-named "Entwurf 1" (DE) / "Draft 1" (EN). Lawyer can rename inline. Subsequent drafts auto-name "Entwurf 2", etc. The (project_id, submission_code, user_id, name) tuple is the unique constraint — two drafts can't share a name for the same submission of the same project. | Lets the lawyer keep a "submitted version" and a "scratch" version side-by-side. |
|
||||
|
||||
---
|
||||
|
||||
## §3 Architecture overview
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Project detail (existing) — /projects/{id} with #tab-submissions │
|
||||
│ ┌───────────────────────────────────────────────────────────────────────┐ │
|
||||
│ │ Schriftsätze tab │ │
|
||||
│ │ Klageerwiderung [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Schriftsatz der Klägerin (SoC) [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ │ Replik [Bearbeiten ↗] [Generieren ↓] │ │
|
||||
│ └────────┬──────────────────────────────────────────┬─────────────────────┘ │
|
||||
│ │ "Bearbeiten" deep-link │ "Generieren" = │
|
||||
│ │ │ existing one-click │
|
||||
│ ▼ │ format-only export │
|
||||
└───────────┼───────────────────────────────────────────┼──────────────────────┘
|
||||
│ │
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ NEW Submission draft page — /projects/{id}/submissions/{code}/draft │
|
||||
│ (lands on most-recent draft for this user, or creates "Entwurf 1") │
|
||||
│ │
|
||||
│ ┌──── Sidebar (sticky left) ────────┐ ┌──── Preview pane (right) ───────┐ │
|
||||
│ │ Schriftsatz: Klageerwiderung │ │ [HTML-rendered merge of │ │
|
||||
│ │ Entwurf 1 ▼ [+ Neuer Entwurf] │ │ template + variables + │ │
|
||||
│ │ ───────────────────────────────── │ │ overrides] │ │
|
||||
│ │ project.case_number │ │ │ │
|
||||
│ │ 2 O 123/25 [override?] │ │ Klage gegen die │ │
|
||||
│ │ parties.claimant.name │ │ Beklagte BMW AG, vertreten │ │
|
||||
│ │ BMW AG [override?] │ │ durch … │ │
|
||||
│ │ deadline.due_date │ │ │ │
|
||||
│ │ 2026-06-12 [override?] │ │ Sehr geehrte Damen und Herren, │ │
|
||||
│ │ ... │ │ │ │
|
||||
│ │ │ │ [KEIN WERT: project.our_side] │ │
|
||||
│ │ [✎ Bearbeiten] inline │ │ │ │
|
||||
│ └───────────────────────────────────┘ └──────────────────────────────────┘ │
|
||||
│ │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
└──────────────────────────────────┬───────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
│
|
||||
▼
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ handlers/submission_drafts.go (NEW) │
|
||||
│ 1. Auth: ProjectService.GetByID → can_see_project │
|
||||
│ 2. Load draft row (RLS via project visibility) │
|
||||
│ 3. SubmissionVarsService.Build (project + parties + rule + next-Frist) │
|
||||
│ 4. Apply draft.overrides on top of bag │
|
||||
│ 5. TemplateRegistry.Resolve(code) — fallback chain → bytes + SHA │
|
||||
│ (Slice A: skips registry, fetches the universal .dotm directly) │
|
||||
│ 6. SubmissionRenderer.Render(bytes, bag, missingMarker) → .docx bytes │
|
||||
│ 7. Audit: system_audit_log + project_events │
|
||||
│ 8. Stream .docx with Content-Disposition: attachment │
|
||||
└──────────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
**No backend changes to today's Schriftsätze tab** — its list endpoint + one-click generate stay exactly as they are. The new page is additive.
|
||||
|
||||
---
|
||||
|
||||
## §4 Schema (`paliad.submission_drafts`)
|
||||
|
||||
Migration `119_submission_drafts.up.sql` (next free number on this branch; coder bumps if 119 is taken at write time).
|
||||
|
||||
```sql
|
||||
CREATE TABLE paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL, -- "Entwurf 1", lawyer-renameable
|
||||
|
||||
overrides jsonb NOT NULL DEFAULT '{}'::jsonb, -- { "project.case_number": "2 O 999/25", ... }
|
||||
-- empty value = "don't override, use bag"
|
||||
-- present key = "use this verbatim"
|
||||
|
||||
last_exported_at timestamptz, -- NULL until first export
|
||||
last_exported_sha text, -- template SHA at last export (audit aid)
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
CREATE POLICY submission_drafts_visible
|
||||
ON paliad.submission_drafts
|
||||
FOR ALL
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at trigger pattern (same shape as paliad.notizen, etc.).
|
||||
CREATE TRIGGER submission_drafts_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
```
|
||||
|
||||
**No changes to `paliad.deadline_rules`.** The task brief floats a `template_body_de/_en` Markdown column as alternative (b) — rejected per Q5 default. Templates stay in Gitea.
|
||||
|
||||
**No changes to `paliad.documents`.** The original Slice 1 design wrote an `audit-only` row (`file_path NULL`) per generation; this design **does not** — `system_audit_log` + `project_events` carry the audit trail, and `paliad.documents` is reserved for actually-uploaded documents (a Phase 2 affordance per §13.5 of the 2026-05-19 design). If m wants the `documents` row for symmetry with future "I uploaded my edited version" UX, the coder can land it in a follow-up migration; it's not load-bearing for this design.
|
||||
|
||||
### 4.1 RLS read-vs-write
|
||||
|
||||
`can_see_project` is the only gate — the policy applies to FOR ALL operations. Anyone who can see a project can create / read / update / export drafts under that project, for their own user_id. Inter-user draft visibility (paralegal sees associate's drafts) is **NOT** a requirement in v1 — the unique constraint includes `user_id` and we don't expose a "drafts by other users on this project" endpoint. Multi-user collaboration on a single draft is out of scope.
|
||||
|
||||
### 4.2 Down migration
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
```
|
||||
|
||||
No data loss concern at design time — feature ships without legacy drafts.
|
||||
|
||||
---
|
||||
|
||||
## §5 Service layer
|
||||
|
||||
### 5.1 Resurrect from git (no new code)
|
||||
|
||||
```
|
||||
internal/services/submission_vars.go RESURRECT from 3677c81 + Slice 2 patch from 1765d5e (patent_number_upc)
|
||||
internal/services/submission_render.go REPLACE the format-only convert with the in-house renderer from 8ea3509.
|
||||
KEEP the convert helper (ConvertDotmToDocx) — Slice A still needs it to
|
||||
strip macros from the universal .dotm before the merge step runs.
|
||||
internal/services/submission_templates.go RESURRECT from 3677c81 — Gitea-backed TemplateRegistry with fallback chain.
|
||||
NOT wired in Slice A (universal .dotm only); wired in Slice B.
|
||||
```
|
||||
|
||||
The three files were ~926 LoC + 350 LoC + 35 LoC patch when shipped. They compile against today's services (`ProjectService`, `PartyService`, `UserService`, `branding.Name`); zero API drift since their deletion. The resurrection is a copy-paste from `git show`, plus a one-line wiring in `cmd/server/main.go` + `internal/handlers/handlers.go`.
|
||||
|
||||
### 5.2 New service — `SubmissionDraftService`
|
||||
|
||||
```go
|
||||
// internal/services/submission_draft_service.go (NEW, ~300 LoC)
|
||||
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
}
|
||||
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID
|
||||
ProjectID uuid.UUID
|
||||
SubmissionCode string
|
||||
UserID uuid.UUID
|
||||
Name string
|
||||
Overrides PlaceholderMap // jsonb → map[string]string
|
||||
LastExportedAt *time.Time
|
||||
LastExportedSHA *string
|
||||
CreatedAt, UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// List returns every draft for (project, submission_code, user) ordered by updated_at DESC.
|
||||
// Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error)
|
||||
|
||||
// Get returns a single draft by ID, gated on project visibility.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error)
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for (project, submission_code).
|
||||
// Creates "Entwurf 1" / "Draft 1" if none exists. Idempotent on repeat calls.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name (lawyer can rename via Update).
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error)
|
||||
|
||||
// Update patches the draft. Permitted fields: name, overrides. last_exported_* is set by the export handler.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error)
|
||||
|
||||
// Delete archives the draft. ON DELETE CASCADE from project takes care of project-archival fallout.
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error
|
||||
|
||||
// MarkExported updates last_exported_at + last_exported_sha after a successful export.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, sha string) error
|
||||
```
|
||||
|
||||
`DraftPatch` is a `struct { Name *string; Overrides *PlaceholderMap }` — nil pointer = "no change", non-nil = "set to this". `Overrides` is replace-semantics (lawyer's sidebar sends the full map); the service does not merge.
|
||||
|
||||
### 5.3 Wiring
|
||||
|
||||
```go
|
||||
// cmd/server/main.go (additions, no replacements)
|
||||
draftSvc := services.NewSubmissionDraftService(db, projectSvc)
|
||||
varsSvc := services.NewSubmissionVarsService(db, projectSvc, partySvc, userSvc)
|
||||
// Slice B only:
|
||||
tplRegistry := services.NewTemplateRegistry(os.Getenv("GITEA_TOKEN"), branding.Name)
|
||||
```
|
||||
|
||||
No new env var. `GITEA_TOKEN` is already documented in CLAUDE.md and used by `internal/handlers/files.go`.
|
||||
|
||||
---
|
||||
|
||||
## §6 UI surface
|
||||
|
||||
### 6.1 Page layout
|
||||
|
||||
`/projects/{id}/submissions/{code}/draft` lands on the user's latest draft for that (project, code). `/projects/{id}/submissions/{code}/draft/{draftID}` opens a specific draft (e.g. "Entwurf 2"). Both routes call the same renderer + client bundle; the difference is which draft `EnsureLatest` vs `Get` returns.
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||
│ ← Zurück zum Projekt: BMW AG ./. Bosch GmbH │
|
||||
│ Schriftsatz: Klageerwiderung (DE.ZPO.276.1) • Entwurf 1 │
|
||||
│ [⬇ Als .docx exportieren] │
|
||||
├────────── Sidebar (sticky) ────────────────┬─────── Preview ────────────────┤
|
||||
│ Entwurf 1 ▼ │ [HTML-rendered merge] │
|
||||
│ • Entwurf 1 (zuletzt 23 Mai 2026) │ │
|
||||
│ • Entwurf 2 (zuletzt 20 Mai 2026) │ An das Landgericht München I │
|
||||
│ • [+ Neuer Entwurf] │ Pacellistr. 5 │
|
||||
│ │ 80333 München │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ Variablen │ In der Sache │
|
||||
│ firm.name HLC │ │
|
||||
│ project.case_number 2 O 123/25 [✎] │ BMW AG, vertreten durch … │
|
||||
│ project.court LG München I [✎] │ — Klägerin — │
|
||||
│ parties.claimant.name BMW AG [✎] │ │
|
||||
│ parties.defendant.name Bosch GmbH [✎] │ gegen │
|
||||
│ parties.defendant.representative │ │
|
||||
│ Dr. Maria Schmidt [✎] │ Bosch GmbH … │
|
||||
│ deadline.due_date 2026-06-12 [✎] │ — Beklagte — │
|
||||
│ rule.legal_source_pretty │ │
|
||||
│ § 276 Abs. 1 ZPO ✓ │ Sehr geehrte Damen und Herren,│
|
||||
│ … │ │
|
||||
│ │ [KEIN WERT: project.our_side] │
|
||||
│ ───────────────────────────────────────── │ │
|
||||
│ [Entwurf umbenennen] [Entwurf löschen] │ │
|
||||
└────────────────────────────────────────────┴────────────────────────────────┘
|
||||
```
|
||||
|
||||
Sidebar grouping (top-to-bottom, locale-aware labels):
|
||||
|
||||
1. **Schriftsatz** (rule.* — read-only metadata: name, legal_source_pretty, primary_party)
|
||||
2. **Mandanten & Parteien** (parties.*)
|
||||
3. **Verfahren** (project.* — case_number, court, patent_number, patent_number_upc, our_side, …)
|
||||
4. **Frist** (deadline.* — due_date, computed_from)
|
||||
5. **Kanzlei & Datum** (firm.*, user.*, today.*)
|
||||
|
||||
Each placeholder row shows: human label (DE/EN), resolved value, edit icon. Click [✎] expands an inline text input pre-filled with the current value. Blur or Enter → debounced autosave (500ms). Empty override → revert to bag value.
|
||||
|
||||
### 6.2 Preview pane
|
||||
|
||||
Read-only HTML. The same `SubmissionRenderer.Render(...)` call that produces the .docx for export ALSO produces a sidecar HTML preview (the in-house renderer walks `<w:p>` / `<w:r>` runs and emits `<p>` / inline `<strong>` / `<em>` based on `<w:b>` / `<w:i>` flags). Preview re-renders on every autosave round-trip (cheap: server-side merge, ~10ms for a 5-page brief). Loading state: ghost-skeleton paragraphs during the round-trip.
|
||||
|
||||
This is the SLIGHTLY non-trivial coder piece: the in-house renderer today emits .docx; the coder adds a parallel `RenderHTML` path that walks the same tree but emits HTML. Same regex, same run-merge logic, different writer. Coder estimates ~120 LoC on top of the resurrected `submission_render.go`.
|
||||
|
||||
### 6.3 Routing + handlers
|
||||
|
||||
```
|
||||
GET /projects/{id}/submissions/{code}/draft → page (lands on latest, creates if none)
|
||||
GET /projects/{id}/submissions/{code}/draft/{draftID} → page (specific draft)
|
||||
GET /api/projects/{id}/submissions/{code}/drafts → list drafts for current user
|
||||
POST /api/projects/{id}/submissions/{code}/drafts → create new draft → returns row + redirect target
|
||||
GET /api/projects/{id}/submissions/{code}/drafts/{draftID} → single draft + resolved bag + HTML preview
|
||||
PATCH /api/projects/{id}/submissions/{code}/drafts/{draftID} → update name / overrides; returns new preview
|
||||
DELETE /api/projects/{id}/submissions/{code}/drafts/{draftID} → delete
|
||||
POST /api/projects/{id}/submissions/{code}/drafts/{draftID}/export
|
||||
→ .docx download (application/vnd.openxmlformats-officedocument.wordprocessingml.document)
|
||||
```
|
||||
|
||||
Page-route handler is `handleSubmissionDraftPage` in `internal/handlers/submission_drafts.go` — calls `handleProjectsDetailPage` shape (returns HTML for an SSR layout) with a deep-route flag, OR ships an entirely new TSX page module. Inventor default: **new TSX page** at `frontend/src/submission-draft.tsx` rendering its own layout. Lighter than retrofitting the existing project-detail page with conditional panels, and the URL semantics demand it (`#tab-submissions` is the tab; `/draft/{id}` is a distinct page).
|
||||
|
||||
### 6.4 Schriftsätze tab — additive change
|
||||
|
||||
```diff
|
||||
- // Each row: [Generieren ↓]
|
||||
+ // Each row: [Bearbeiten ↗] [Generieren ↓]
|
||||
```
|
||||
|
||||
`[Bearbeiten ↗]` → `window.location.href = "/projects/{id}/submissions/{code}/draft"`. `[Generieren ↓]` stays as today (one-click format-only export of the universal .dotm). For users who want zero-config "give me a clean firm style template", `[Generieren]` is the path; for users who want a merged draft, `[Bearbeiten]` is the path.
|
||||
|
||||
Per the `.entity-table` row contract in CLAUDE.md, the row itself becomes clickable (navigates to `/draft`), with the `Generieren` button stopping propagation. The `entity-table--readonly` modifier is removed.
|
||||
|
||||
---
|
||||
|
||||
## §7 Variable contract (v1 placeholder set)
|
||||
|
||||
Reproduced from the resurrected `submission_vars.go` (commits `3677c81` + `1765d5e`). The sidebar's "Variablen" section enumerates this list in the exact same order as `addProjectVars` / `addPartyVars` / etc., grouped per §6.1.
|
||||
|
||||
```
|
||||
firm.name — branding.Name (HLC or FIRM_NAME override)
|
||||
firm.signature_block — empty in v1 (Phase 2 affordance)
|
||||
|
||||
today — 2026-05-22 (ISO, Europe/Berlin)
|
||||
today.iso — ISO short
|
||||
today.long_de — "22. Mai 2026"
|
||||
today.long_en — "22 May 2026"
|
||||
|
||||
user.display_name — paliad.users.display_name
|
||||
user.email — paliad.users.email
|
||||
user.office — paliad.users.office
|
||||
|
||||
project.title — paliad.projects.title
|
||||
project.reference — paliad.projects.reference
|
||||
project.case_number — paliad.projects.case_number
|
||||
project.court — paliad.projects.court
|
||||
project.patent_number — DE/inline form "EP 1 234 567 B1"
|
||||
project.patent_number_upc — UPC parenthesised form "EP 1 234 567 (B1)" (Slice 2 helper, 1765d5e)
|
||||
project.filing_date — ISO date
|
||||
project.grant_date — ISO date
|
||||
project.our_side — claimant | defendant
|
||||
project.our_side_de — "Klägerin" | "Beklagte"
|
||||
project.our_side_en — "Claimant" | "Defendant"
|
||||
project.instance_level — lg | olg | bgh | cfi | …
|
||||
project.client_number — paliad.projects.client_number
|
||||
project.matter_number — paliad.projects.matter_number
|
||||
project.proceeding.code — e.g. "de.inf.lg"
|
||||
project.proceeding.name — locale-aware (DE: Verletzungsklage am Landgericht)
|
||||
project.proceeding.name_de — explicit DE
|
||||
project.proceeding.name_en — explicit EN
|
||||
|
||||
parties.claimant.name — first paliad.parties row with role='claimant'
|
||||
parties.claimant.representative
|
||||
parties.defendant.name — first row with role='defendant'
|
||||
parties.defendant.representative
|
||||
parties.other.name — first non-claimant/defendant row
|
||||
parties.other.representative
|
||||
|
||||
rule.submission_code — "de.inf.lg.erwidg"
|
||||
rule.name — locale-aware ("Klageerwiderung" / "Statement of Defence")
|
||||
rule.name_de
|
||||
rule.name_en
|
||||
rule.legal_source — "DE.ZPO.276.1"
|
||||
rule.legal_source_pretty — "§ 276 Abs. 1 ZPO" / "Section 276(1) ZPO"
|
||||
rule.primary_party — claimant | defendant | court | both
|
||||
rule.event_type — filing | hearing | decision
|
||||
|
||||
deadline.due_date — ISO of next pending deadline for this rule on this project
|
||||
deadline.due_date_long_de — "12. Juni 2026"
|
||||
deadline.due_date_long_en — "12 June 2026"
|
||||
deadline.original_due_date — ISO if extended
|
||||
deadline.computed_from — anchor description (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen")
|
||||
deadline.title — deadline.title
|
||||
deadline.source — "rule" | "manual" | …
|
||||
```
|
||||
|
||||
Variable bag construction is `SubmissionVarsService.Build(ctx, in)` — exactly the function in `3677c81`'s `submission_vars.go`, no changes.
|
||||
|
||||
### 7.1 Override semantics
|
||||
|
||||
The lawyer's `overrides` map (jsonb in `submission_drafts`) shadows the bag at export time:
|
||||
|
||||
```go
|
||||
bag := varsSvc.Build(ctx, ...).Placeholders // ~30 keys, resolved from project state
|
||||
for k, v := range draft.Overrides { // lawyer's edits
|
||||
if v == "" {
|
||||
delete(bag, k) // empty override means "force missing marker"
|
||||
} else {
|
||||
bag[k] = v // non-empty override replaces
|
||||
}
|
||||
}
|
||||
docx := renderer.Render(templateBytes, bag, missingMarker(lang))
|
||||
```
|
||||
|
||||
Edge case: lawyer types empty string into a field that was resolved from the project. Decision: empty override forces the `[KEIN WERT: …]` marker. Lawyer's intent ("blank this out, I'll fill manually in Word") is honoured rather than silently falling back to project state. Sidebar UX: empty override field is annotated "→ [KEIN WERT: …]" so the lawyer sees the consequence before exporting.
|
||||
|
||||
---
|
||||
|
||||
## §8 Template authoring (mWorkRepo layout, naming, fallback chain)
|
||||
|
||||
### 8.1 Slice A — universal .dotm only
|
||||
|
||||
Slice A merges the variable bag into the same universal HL Patents Style .dotm that today's format-only convert ships. **The .dotm body must carry `{{placeholder}}` tokens** — currently it doesn't (it's a firm style template, not a per-submission template). m has two ways to seed Slice A:
|
||||
|
||||
- **8.1.a** — author one universal template (`m/mWorkRepo/templates/_base/_universal.docx` or similar) with `{{firm.name}}`, `{{rule.name}}`, `{{project.case_number}}`, `{{parties.claimant.name}}`, etc. The merge engine fills these and outputs a draft that's still a generic letter shape but pre-populated.
|
||||
- **8.1.b** — author one Klageerwiderung-shaped template (`m/mWorkRepo/templates/HLC/de.inf.lg.erwidg.docx`) and route Slice A's export to that path for `submission_code='de.inf.lg.erwidg'`, with a hard-coded fall-back to `_universal.docx` for any other code. This is essentially Slice A + B's first template — wins both rounds.
|
||||
|
||||
Inventor recommendation: **8.1.b**. Strictly more useful, identical engine code, identical mWorkRepo round-trip. The Slice A → Slice B transition is then "add more templates", not "rewire the resolver".
|
||||
|
||||
### 8.2 Slice B — fallback-chain registry
|
||||
|
||||
Layout reproduced from the 2026-05-19 design §5.1:
|
||||
|
||||
```
|
||||
m/mWorkRepo (existing repo, already proxied)
|
||||
└── templates/
|
||||
├── HLC/ # FIRM_NAME-keyed override dir
|
||||
│ ├── de.inf.lg.erwidg.docx # Slice A target (per 8.1.b above)
|
||||
│ ├── de.inf.lg.klage.docx # Slice B addition
|
||||
│ ├── upc.inf.cfi.soc.docx # Slice B addition
|
||||
│ └── upc.inf.cfi.sod.docx # Slice B addition
|
||||
├── _base/ # Cross-firm baseline
|
||||
│ ├── de.inf.lg.erwidg.docx # base equivalent (Slice C+)
|
||||
│ ├── de.inf.lg.docx # proceeding-family fallback
|
||||
│ ├── upc.inf.cfi.docx
|
||||
│ ├── _skeleton.docx # ultra-generic fallback
|
||||
│ └── _universal.docx # the v1 Slice A "any code" template
|
||||
└── README.md # placeholder reference for template authors
|
||||
```
|
||||
|
||||
Naming: `{submission_code}.docx`. Family fallback uses the first three dot-segments (`de.inf.lg` from `de.inf.lg.erwidg`). Skeleton is the ultra-generic fallback (letterhead + party block + court address + signature stub).
|
||||
|
||||
### 8.3 Lookup algorithm
|
||||
|
||||
```go
|
||||
// services/submission_templates.go (resurrected from 3677c81)
|
||||
func (r *TemplateRegistry) candidates(submissionCode string) []string {
|
||||
family := familyOf(submissionCode)
|
||||
out := []string{
|
||||
fmt.Sprintf("templates/%s/%s.docx", r.firmName, submissionCode),
|
||||
fmt.Sprintf("templates/_base/%s.docx", submissionCode),
|
||||
}
|
||||
if family != "" && family != submissionCode {
|
||||
out = append(out, fmt.Sprintf("templates/_base/%s.docx", family))
|
||||
}
|
||||
out = append(out, "templates/_base/_skeleton.docx")
|
||||
return out
|
||||
}
|
||||
```
|
||||
|
||||
Gitea proxy: same `internal/handlers/files.go` shape. 5-min SHA refresh, in-process cache, `GITEA_TOKEN` for auth. The original `submission_templates.go` already implements this end-to-end; the coder re-applies it from `git show 3677c81`.
|
||||
|
||||
### 8.4 No template at all — Slice A vs Slice B
|
||||
|
||||
Slice A: the universal template always resolves; `ErrNoTemplate` is impossible.
|
||||
|
||||
Slice B: if every candidate in the fallback chain 404s, the handler returns 503 + `"Vorlagen-Repository nicht erreichbar"` in the UI (same handling as the original Slice 1 design §5.4). Since the chain ends at `_skeleton.docx`, this only fires when the mWorkRepo itself is misconfigured.
|
||||
|
||||
### 8.5 Template authoring task lands outside this design
|
||||
|
||||
Inventor flags but does not assign: HLC must author the per-submission_code `.docx` templates. Slice A's `_universal.docx` is one document. Slice B adds Klageerwiderung, Klageerhebung, SoC, SoD, … iteratively. **Template authoring runs in parallel with engine code**; the coder ships the engine, m + HLC ships the templates. The two converge before the slice closes.
|
||||
|
||||
This is the m-escalated piece (see §11): without per-submission templates, Slice B is engine-only.
|
||||
|
||||
---
|
||||
|
||||
## §9 Slice plan
|
||||
|
||||
### Slice A — schema + new page + variables-only export against universal .docx
|
||||
|
||||
Ships the editor end-to-end with one template.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| Migration 119 — `submission_drafts` table + RLS + trigger | `internal/db/migrations/119_submission_drafts.{up,down}.sql` |
|
||||
| `SubmissionVarsService` resurrected | `internal/services/submission_vars.go` (from `3677c81` + Slice 2 patch `1765d5e`) |
|
||||
| `SubmissionRenderer` resurrected with new `RenderHTML` | `internal/services/submission_render.go` (from `8ea3509`); adds `RenderHTML(...) string` for preview |
|
||||
| `SubmissionDraftService` | `internal/services/submission_draft_service.go` (NEW) |
|
||||
| Handlers (page + 7 API endpoints) | `internal/handlers/submission_drafts.go` (NEW) |
|
||||
| Wiring | `cmd/server/main.go`, `internal/handlers/handlers.go` |
|
||||
| Page TSX | `frontend/src/submission-draft.tsx` (NEW) |
|
||||
| Client bundle | `frontend/src/client/submission-draft.ts` (NEW) |
|
||||
| Schriftsätze tab update | `frontend/src/projects-detail.tsx` (rows get [Bearbeiten]), `frontend/src/client/submissions.ts` (handler) |
|
||||
| i18n | new keys under `projects.detail.submissions.draft.*` and `submissions.draft.*` (page-level) |
|
||||
| One template at `m/mWorkRepo/templates/_base/_universal.docx` (8.1.b → also `templates/HLC/de.inf.lg.erwidg.docx`) | mWorkRepo, separate PR by m |
|
||||
| Tests | `internal/services/submission_render_test.go` (resurrected + RenderHTML), `internal/services/submission_vars_test.go` (round-trip), handler smoke |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Opening `/projects/{id}/submissions/de.inf.lg.erwidg/draft` lands on the user's latest draft (or creates "Entwurf 1").
|
||||
2. Sidebar renders ~30 placeholders, pre-filled from project state.
|
||||
3. Editing a sidebar value autosaves within 500ms and updates the preview pane.
|
||||
4. Multiple drafts per (project, code, user) supported; switcher in sidebar.
|
||||
5. Clicking "Als .docx exportieren" downloads a merged `.docx` (universal template + project + lawyer overrides).
|
||||
6. `system_audit_log` row appears on export (`event_type='submission.exported'`).
|
||||
7. `project_events` row appears on export and surfaces in Verlauf.
|
||||
8. RLS: caller without `can_see_project` gets 404 on the page and 404 on every draft API.
|
||||
9. Schriftsätze tab on project detail shows [Bearbeiten] alongside [Generieren].
|
||||
10. `go build ./... && go vet ./... && go test ./... && bun run build` clean.
|
||||
|
||||
### Slice B — per-submission_code templates + fallback chain
|
||||
|
||||
Engine is unchanged from Slice A; this slice wires `TemplateRegistry` into the export endpoint and lights up per-code templates.
|
||||
|
||||
| Deliverable | Files |
|
||||
|---|---|
|
||||
| `TemplateRegistry` resurrected | `internal/services/submission_templates.go` (from `3677c81`) |
|
||||
| Handler swaps Slice A's `fetchHLPatentsStyleBytes` for `templateRegistry.Resolve(code)` | `internal/handlers/submission_drafts.go` |
|
||||
| `has_template` boolean per row in Schriftsätze tab list (today: unconditionally true; under Slice B: depends on registry probe) | `internal/handlers/submissions.go` |
|
||||
| Templates authored in mWorkRepo: at least Klageerwiderung + Klageerhebung + SoC + SoD | mWorkRepo PR by m |
|
||||
| Tests for fallback chain | `internal/services/submission_templates_test.go` (resurrect from history if it existed; otherwise new) |
|
||||
|
||||
Acceptance:
|
||||
|
||||
1. Pushing `m/mWorkRepo/templates/HLC/upc.inf.cfi.soc.docx` makes the SoC draft page resolve that template within 5 min (or instantly via `POST /api/files/refresh`).
|
||||
2. `has_template=false` rows in the Schriftsätze tab show [Keine Vorlage] instead of [Bearbeiten]/[Generieren]. Existing list ordering preserved.
|
||||
3. `last_exported_sha` on `submission_drafts` records which SHA the lawyer exported against.
|
||||
4. Misconfigured repo (every fallback 404s) → 503 with clear error.
|
||||
|
||||
### Slice C — toggleable passages
|
||||
|
||||
Lawyer can include/exclude boilerplate sections before export.
|
||||
|
||||
| Deliverable | Notes |
|
||||
|---|---|
|
||||
| `passages` jsonb column on `submission_drafts` | `migration 120` (or whatever's free at land time): `passages jsonb NOT NULL DEFAULT '{}'::jsonb` — `{"intro": true, "patent_validity_attack": false, "non_infringement": true}`. |
|
||||
| Template syntax for passage blocks | `{{#passage intro}}…{{/passage}}` — start/end markers, merger drops the block when the corresponding `passages.{key}` is false. The in-house renderer's run-fragmentation handling extends to the new tokens cleanly. |
|
||||
| Sidebar UI | "Passagen" group above "Variablen", per-passage toggle (on by default), help text per passage. |
|
||||
| Template author API | `templates/README.md` documents the passage syntax + a worked example. |
|
||||
|
||||
Acceptance: turning off `non_infringement` in the sidebar of a Klageerwiderung draft removes the corresponding section from the exported .docx; preview reflects immediately.
|
||||
|
||||
Slices D+ (not detailed here): citation insertion from the sources system (waits for that surface), per-firm template overrides (registry already supports this), `/admin/submission-templates` variable contract sidebar.
|
||||
|
||||
---
|
||||
|
||||
## §10 Out of scope
|
||||
|
||||
- AI-drafted prose (the 2026-05-19 design §11 sketch; still deferred).
|
||||
- PDF export. v1 ships `.docx` only; the lawyer's Word does the PDF step.
|
||||
- Multi-user collaboration on a single draft. Each draft is owner-scoped (`user_id`).
|
||||
- Real-time co-editing. Last-write-wins per draft; no operational transforms.
|
||||
- An in-paliad WYSIWYG editor for `.docx` content. Preview is read-only; final edits happen in Word.
|
||||
- A paliad-side template uploader. Gitea stays as the editor for templates until lawyers complain about the round-trip.
|
||||
- Translation of templates DE↔EN. Templates are mono-locale; the variable bag is bilingual.
|
||||
- Citation insertion from the sources system. Waits for the sources surface m parked.
|
||||
- Frist-detail "Exportieren" button. The submission page is reachable only from the project's Schriftsätze tab in v1; a Frist-level deep-link is a Slice D+ affordance.
|
||||
- Validation of the rendered draft against any legal rule. The engine produces text; the lawyer's substantive review is downstream.
|
||||
- Sending the draft to court / e-filing. The lawyer downloads and handles transmission outside paliad.
|
||||
|
||||
---
|
||||
|
||||
## §11 Material picks escalated to head
|
||||
|
||||
Per project CLAUDE.md inventor → head policy, the four picks below carry enough cost or risk to deserve head's read. Head ratifies (or escalates to m) before the coder shift starts.
|
||||
|
||||
### Q-E1 — Template authoring effort
|
||||
|
||||
Slice A needs at least one custom-authored template (`_universal.docx` or `de.inf.lg.erwidg.docx`) carrying `{{placeholder}}` tokens. Slice B needs four more (Klageerhebung, SoC, SoD, Erwiderung). The engine ships independently of template content, but the feature is unfinished without lawyer-authored templates.
|
||||
|
||||
**Inventor pick:** ship Slice A with **one** lawyer-authored template (8.1.b: `templates/HLC/de.inf.lg.erwidg.docx`) + the universal fallback. m + HLC owns the authoring; the coder owns the engine. Slices A and template-1 land together.
|
||||
|
||||
**Material because:** without a template, the feature looks broken in user testing. Head decides: does m commit to authoring or reviewing the first template before Slice A merges, or does Slice A merge engine-only and we accept the "format-only export with placeholders" intermediate state for a week?
|
||||
|
||||
### Q-E2 — `paliad.documents` row on export
|
||||
|
||||
The original Slice 1 design wrote an audit-only `paliad.documents` row (`file_path NULL`, `doc_type='generated_submission'`) per generation, on the theory that "Documents" would become the canonical listing UI. This design defers that.
|
||||
|
||||
**Inventor pick:** **no** `paliad.documents` write. `system_audit_log` + `project_events` carry the audit trail. The `documents` table is reserved for actually-uploaded documents (Phase 2 of the broader docs roadmap).
|
||||
|
||||
**Material because:** if head agrees, we skip a column repurpose (`ai_extracted` jsonb being used for generation provenance — the 2026-05-19 design noted this was ugly). If head disagrees, the coder lands the row inside Slice A.
|
||||
|
||||
### Q-E3 — Preview render — server or client?
|
||||
|
||||
Server-side: `RenderHTML(...)` on the in-house renderer, round-trip per autosave. Cheaper to build, costs ~10ms server-side per keystroke (debounced 500ms).
|
||||
|
||||
Client-side: ship the merged document body as JSON of paragraph runs, render in TS. Faster preview, harder to build (parallel render path in TS), and **diverges** the preview from the export shape (export still goes server-side).
|
||||
|
||||
**Inventor pick:** **server-side**. Single source of truth for the merge logic. The 500ms debounce already absorbs the round-trip; a 10ms server merge plus 50ms HTTP RTT is sub-perceptible.
|
||||
|
||||
**Material because:** if head wants the client-side preview for fully-offline draft editing, the coder needs a TS port of `substituteInDocumentXML`. Bigger build, but no round-trip latency on every keystroke.
|
||||
|
||||
### Q-E4 — Inter-user draft visibility
|
||||
|
||||
Today's design: each user sees only their own drafts. If two associates on the same project both draft a Klageerwiderung, they don't see each other's drafts (each has their own row).
|
||||
|
||||
**Inventor pick:** **owner-scoped (status quo of this design)**. The unique constraint includes `user_id`; the `List` endpoint filters by current user.
|
||||
|
||||
**Material because:** if head wants project-team visibility ("paralegal sees associate's draft for review"), the unique constraint shifts to `(project_id, submission_code, name)` (drop `user_id`), the RLS already covers the read path (`can_see_project`), and `submission_drafts` becomes a project-team resource. **This is a Phase-shape change** — the lawyer model differs. Inventor flags it because the change is cheap to make now (one column + one constraint) and expensive to make later (drafts already accumulate per-user). Head's call.
|
||||
|
||||
---
|
||||
|
||||
## §12 Implementation notes
|
||||
|
||||
For the coder, not for head.
|
||||
|
||||
- **Resurrection is `git show`, not "re-write".** The four file revisions (`3677c81:internal/services/submission_vars.go`, `1765d5e:internal/services/submission_vars.go` for the Slice 2 patch, `8ea3509:internal/services/submission_render.go`, `3677c81:internal/services/submission_templates.go`) can be applied via `git checkout 3677c81 -- internal/services/submission_vars.go` etc. The coder should verify each compiles against today's `cmd/server/main.go` wiring before applying.
|
||||
- **Renderer's `RenderHTML` is new.** The .docx walker today emits OOXML bytes; the HTML emitter walks the same tree and emits `<p>` / `<strong>` / `<em>` / `<br>`. ~120 LoC on top of the resurrected file. Same regex (`placeholderRegex`), same run-merge logic, different writer.
|
||||
- **Sidebar variable schema needs a label table.** The variable contract from §7 is keyed by dotted paths; the sidebar UI needs DE/EN labels per key. Coder adds `services/submission_var_labels.go` with a `map[string]struct{LabelDE, LabelEN, HelpDE, HelpEN}` for the ~30 keys. (Mirrors `internal/services/email_template_variables.go` shape — same lawyer-facing pattern paliad already ships at `/admin/email-templates`.)
|
||||
- **Autosave race.** The lawyer types fast → multiple PATCHes in flight. Coder uses a request-ID-debouncing pattern on the client (cancel in-flight PATCH when a new one starts) and last-write-wins on the server. No version column on the draft row in v1.
|
||||
- **Empty-override semantics in the jsonb.** `overrides = {"project.case_number": ""}` means "force missing marker". `overrides = {}` (key absent) means "fall back to bag". The service code distinguishes — careful with `omitempty`.
|
||||
- **i18n key audit.** Add `projects.detail.submissions.action.edit`, `submissions.draft.title`, `submissions.draft.export`, `submissions.draft.sidebar.{firm,project,parties,deadline,user}.group`, `submissions.draft.rename`, `submissions.draft.delete`, `submissions.draft.new`, etc. Roughly 35 new keys in DE + EN.
|
||||
- **`entity-table` row contract.** Schriftsätze tab today carries `entity-table--readonly`. Slice A removes that modifier and adds a row-click handler that navigates to `/projects/{id}/submissions/{code}/draft`, skipping clicks on the inner [Generieren] button. Matches the pattern in `frontend/src/client/checklists.ts`, `client/projects-detail.ts`, `client/deadlines.ts`.
|
||||
- **Migration 119 may collide.** Other worktrees (paliadin aichat, mig 118) may land 119 before this branch merges. Coder verifies at land time; bump to the next free number if needed.
|
||||
|
||||
---
|
||||
|
||||
## §13 Acceptance gate
|
||||
|
||||
Per inventor SKILL.md + project CLAUDE.md: this design needs head's go/no-go before any coder is hired. After head ratifies (with or without escalating §11 to m):
|
||||
|
||||
- The head decides whether to hire the same worker as `/mai-coder` with this design as the brief, or a fresh coder.
|
||||
- A coder shift takes this doc as the spec, ships Slice A, opens a PR (no self-merge).
|
||||
- Slices B and C are SEPARATE tasks — not auto-spawned.
|
||||
|
||||
Inventor parks here.
|
||||
@@ -18,6 +18,9 @@ import { renderProjects } from "./src/projects";
|
||||
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 { renderSubmissionsIndex } from "./src/submissions-index";
|
||||
import { renderSubmissionsNew } from "./src/submissions-new";
|
||||
import { renderEvents } from "./src/events";
|
||||
import { renderDeadlinesNew } from "./src/deadlines-new";
|
||||
import { renderDeadlinesDetail } from "./src/deadlines-detail";
|
||||
@@ -252,6 +255,9 @@ async function build() {
|
||||
join(import.meta.dir, "src/client/projects-new.ts"),
|
||||
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/submissions-index.ts"),
|
||||
join(import.meta.dir, "src/client/submissions-new.ts"),
|
||||
join(import.meta.dir, "src/client/events.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-new.ts"),
|
||||
join(import.meta.dir, "src/client/deadlines-detail.ts"),
|
||||
@@ -376,6 +382,9 @@ async function build() {
|
||||
await Bun.write(join(DIST, "projects-new.html"), renderProjectsNew());
|
||||
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, "submissions-index.html"), renderSubmissionsIndex());
|
||||
await Bun.write(join(DIST, "submissions-new.html"), renderSubmissionsNew());
|
||||
// t-paliad-115 — shared EventsPage at the canonical /events URL.
|
||||
// One HTML output; defaultType="all" baked in. Sidebar Fristen /
|
||||
// Termine entries point at /events?type=… and events.ts re-highlights
|
||||
|
||||
@@ -27,6 +27,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossar",
|
||||
"nav.gebuehrentabellen": "Geb\u00fchrentabellen",
|
||||
"nav.checklisten": "Checklisten",
|
||||
"nav.submissions": "Schriftsätze",
|
||||
"nav.gerichte": "Gerichte",
|
||||
"nav.logout": "Abmelden",
|
||||
"nav.akten": "Akten",
|
||||
@@ -1425,10 +1426,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notizen",
|
||||
"projects.detail.tab.checklisten": "Checklisten",
|
||||
"projects.detail.tab.submissions": "Schriftsätze",
|
||||
"projects.detail.tab.settings": "Verwaltung",
|
||||
"projects.detail.export.button": "Daten exportieren",
|
||||
"projects.detail.export.tooltip": "Daten dieses Projekts (mit Unter-Projekten) als Excel + JSON + CSV herunterladen.",
|
||||
"projects.detail.submissions.empty": "Für dieses Verfahren sind keine Schriftsätze hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.",
|
||||
"projects.detail.settings.export.heading": "Daten exportieren",
|
||||
"projects.detail.settings.export.description": "Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.",
|
||||
"projects.detail.settings.archive.heading": "Projekt archivieren",
|
||||
"projects.detail.settings.archive.description": "Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).",
|
||||
"projects.detail.settings.archive.cta": "Bearbeiten öffnen",
|
||||
"projects.detail.submissions.empty": "Es sind aktuell keine Schriftsatzvorlagen hinterlegt.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Projekt bearbeiten",
|
||||
"projects.detail.submissions.col.name": "Schriftsatz",
|
||||
"projects.detail.submissions.col.party": "Partei",
|
||||
@@ -1436,7 +1443,50 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generieren",
|
||||
"projects.detail.submissions.action.no_template": "Keine Vorlage",
|
||||
"projects.detail.submissions.action.edit": "Bearbeiten",
|
||||
"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",
|
||||
"submissions.draft.back": "← Zurück zum Projekt",
|
||||
"submissions.draft.loading": "Lädt…",
|
||||
"submissions.draft.notfound": "Schriftsatz nicht gefunden oder keine Berechtigung.",
|
||||
"submissions.draft.action.export": "Als .docx exportieren",
|
||||
"submissions.draft.action.new": "+ Neuer Entwurf",
|
||||
"submissions.draft.action.delete": "Löschen",
|
||||
"submissions.draft.switcher.label": "Entwurf",
|
||||
"submissions.draft.name.placeholder": "Name dieses Entwurfs",
|
||||
"submissions.draft.preview.title": "Vorschau",
|
||||
"submissions.draft.preview.hint": "Read-only Vorschau — finale Bearbeitung in Word.",
|
||||
// t-paliad-240 — global Schriftsätze drafts index page.
|
||||
"submissions.index.title": "Schriftsätze — Paliad",
|
||||
"submissions.index.heading": "Schriftsätze",
|
||||
"submissions.index.subtitle": "Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.",
|
||||
"submissions.index.loading": "Lädt…",
|
||||
"submissions.index.empty": "Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.",
|
||||
"submissions.index.empty.cta": "+ Neuer Entwurf",
|
||||
"submissions.index.error": "Schriftsätze konnten nicht geladen werden.",
|
||||
"submissions.index.col.project": "Projekt",
|
||||
"submissions.index.col.submission": "Schriftsatz",
|
||||
"submissions.index.col.draft": "Entwurf",
|
||||
"submissions.index.col.updated": "Zuletzt geändert",
|
||||
"submissions.index.action.new": "+ Neuer Entwurf",
|
||||
// t-paliad-243 — global Schriftsatz picker (/submissions/new).
|
||||
"submissions.new.title": "Neuer Schriftsatz — Paliad",
|
||||
"submissions.new.back": "← Zurück zur Übersicht",
|
||||
"submissions.new.heading": "Neuer Schriftsatz",
|
||||
"submissions.new.subtitle": "Wählen Sie eine Vorlage. Optional verknüpfen Sie den Entwurf mit einem Projekt — sonst füllen Sie alle Variablen manuell.",
|
||||
"submissions.new.search.placeholder": "Suche nach Schriftsatz, Code oder Norm…",
|
||||
"submissions.new.loading": "Lädt…",
|
||||
"submissions.new.error": "Katalog konnte nicht geladen werden.",
|
||||
"submissions.new.col.name": "Schriftsatz",
|
||||
"submissions.new.col.party": "Partei",
|
||||
"submissions.new.col.source": "Rechtsgrundlage",
|
||||
"submissions.new.col.actions": "Entwurf starten",
|
||||
"submissions.new.empty.filtered": "Keine passenden Schriftsätze. Filter zurücksetzen.",
|
||||
"submissions.new.picker.title": "Projekt wählen",
|
||||
"submissions.new.picker.placeholder": "Projekt suchen (Titel oder Aktenzeichen)…",
|
||||
"submissions.new.picker.loading": "Lädt Projekte…",
|
||||
"submissions.new.picker.empty": "Keine sichtbaren Projekte.",
|
||||
"projects.detail.verlauf.empty": "Noch keine Ereignisse aufgezeichnet.",
|
||||
"projects.detail.verlauf.loadMore": "Mehr laden",
|
||||
// SmartTimeline (t-paliad-171, Slice 1).
|
||||
@@ -1550,9 +1600,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Fortschritt",
|
||||
"projects.detail.checklisten.col.created": "Angelegt",
|
||||
"projects.detail.checklisten.hint.prefix": "Instanzen werden auf der Vorlagen-Seite unter ",
|
||||
"projects.detail.checklisten.hint.prefix": "Vorlagen werden auf der ",
|
||||
"projects.detail.checklisten.hint.link": "Checklisten",
|
||||
"projects.detail.checklisten.hint.suffix": " angelegt.",
|
||||
"projects.detail.checklisten.hint.suffix": "-Seite angelegt und bearbeitet.",
|
||||
"projects.detail.checklisten.add": "Checkliste hinzuf\u00fcgen",
|
||||
"projects.detail.checklisten.add.search": "Vorlage suchen\u2026",
|
||||
"projects.detail.checklisten.add.empty_pick": "Keine passenden Vorlagen gefunden.",
|
||||
"projects.detail.checklisten.add.created": "Checkliste hinzugef\u00fcgt.",
|
||||
"projects.detail.checklisten.add.error": "Checkliste konnte nicht angelegt werden.",
|
||||
"projects.detail.delete": "Projekt archivieren",
|
||||
"projects.detail.delete.confirm.title": "Projekt wirklich archivieren?",
|
||||
"projects.detail.delete.confirm.body": "Das Projekt wird archiviert. Es kann nicht direkt wiederhergestellt werden.",
|
||||
@@ -2921,6 +2976,7 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"nav.glossar": "Glossary",
|
||||
"nav.gebuehrentabellen": "Fee Schedules",
|
||||
"nav.checklisten": "Checklists",
|
||||
"nav.submissions": "Submissions",
|
||||
"nav.gerichte": "Courts",
|
||||
"nav.logout": "Sign Out",
|
||||
"nav.akten": "Matters",
|
||||
@@ -4297,10 +4353,16 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.tab.notizen": "Notes",
|
||||
"projects.detail.tab.checklisten": "Checklists",
|
||||
"projects.detail.tab.submissions": "Submissions",
|
||||
"projects.detail.tab.settings": "Settings",
|
||||
"projects.detail.export.button": "Export data",
|
||||
"projects.detail.export.tooltip": "Download this project's data (including sub-projects) as Excel + JSON + CSV.",
|
||||
"projects.detail.submissions.empty": "No submissions are configured for this proceeding.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet. Edit the project to choose one.",
|
||||
"projects.detail.settings.export.heading": "Export data",
|
||||
"projects.detail.settings.export.description": "Download all data for this project (including sub-projects) as an Excel + JSON + CSV archive.",
|
||||
"projects.detail.settings.archive.heading": "Archive project",
|
||||
"projects.detail.settings.archive.description": "Archiving happens in the edit dialog (danger zone).",
|
||||
"projects.detail.settings.archive.cta": "Open edit dialog",
|
||||
"projects.detail.submissions.empty": "No submission templates are configured yet.",
|
||||
"projects.detail.submissions.empty.no_proceeding": "No proceeding type is set for this project yet — the catalog below still lists every template.",
|
||||
"projects.detail.submissions.empty.no_proceeding.cta": "Edit project",
|
||||
"projects.detail.submissions.col.name": "Submission",
|
||||
"projects.detail.submissions.col.party": "Party",
|
||||
@@ -4308,7 +4370,49 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.submissions.col.action": "",
|
||||
"projects.detail.submissions.action.generate": "Generate",
|
||||
"projects.detail.submissions.action.no_template": "No template",
|
||||
"projects.detail.submissions.action.edit": "Edit",
|
||||
"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",
|
||||
"submissions.draft.back": "← Back to project",
|
||||
"submissions.draft.loading": "Loading…",
|
||||
"submissions.draft.notfound": "Submission not found or insufficient access.",
|
||||
"submissions.draft.action.export": "Export as .docx",
|
||||
"submissions.draft.action.new": "+ New draft",
|
||||
"submissions.draft.action.delete": "Delete",
|
||||
"submissions.draft.switcher.label": "Draft",
|
||||
"submissions.draft.name.placeholder": "Name of this draft",
|
||||
"submissions.draft.preview.title": "Preview",
|
||||
"submissions.draft.preview.hint": "Read-only preview — final formatting in Word.",
|
||||
// t-paliad-240 — global submissions drafts index page.
|
||||
"submissions.index.title": "Submissions — Paliad",
|
||||
"submissions.index.heading": "Submissions",
|
||||
"submissions.index.subtitle": "Your submission drafts across every visible project.",
|
||||
"submissions.index.loading": "Loading…",
|
||||
"submissions.index.empty": "No drafts yet. Start a new draft — with or without a project.",
|
||||
"submissions.index.empty.cta": "+ New draft",
|
||||
"submissions.index.error": "Could not load submissions.",
|
||||
"submissions.index.col.project": "Project",
|
||||
"submissions.index.col.submission": "Submission",
|
||||
"submissions.index.col.draft": "Draft",
|
||||
"submissions.index.col.updated": "Last updated",
|
||||
"submissions.index.action.new": "+ New draft",
|
||||
"submissions.new.title": "New submission — Paliad",
|
||||
"submissions.new.back": "← Back to drafts",
|
||||
"submissions.new.heading": "New submission",
|
||||
"submissions.new.subtitle": "Pick a template. Optionally bind it to a project — otherwise all variables are filled manually.",
|
||||
"submissions.new.search.placeholder": "Search by name, code or statute…",
|
||||
"submissions.new.loading": "Loading…",
|
||||
"submissions.new.error": "Could not load catalog.",
|
||||
"submissions.new.col.name": "Submission",
|
||||
"submissions.new.col.party": "Party",
|
||||
"submissions.new.col.source": "Legal source",
|
||||
"submissions.new.col.actions": "Start draft",
|
||||
"submissions.new.empty.filtered": "No submissions match the filters. Reset them to see the full catalog.",
|
||||
"submissions.new.picker.title": "Pick a project",
|
||||
"submissions.new.picker.placeholder": "Search project (title or reference)…",
|
||||
"submissions.new.picker.loading": "Loading projects…",
|
||||
"submissions.new.picker.empty": "No visible projects.",
|
||||
"projects.detail.verlauf.empty": "No events recorded yet.",
|
||||
"projects.detail.verlauf.loadMore": "Load more",
|
||||
"projects.detail.smarttimeline.empty": "No events captured yet.",
|
||||
@@ -4421,9 +4525,14 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"projects.detail.checklisten.col.name": "Name",
|
||||
"projects.detail.checklisten.col.progress": "Progress",
|
||||
"projects.detail.checklisten.col.created": "Created",
|
||||
"projects.detail.checklisten.hint.prefix": "Instances are created on the template page under ",
|
||||
"projects.detail.checklisten.hint.prefix": "Templates are created and edited on the ",
|
||||
"projects.detail.checklisten.hint.link": "Checklists",
|
||||
"projects.detail.checklisten.hint.suffix": ".",
|
||||
"projects.detail.checklisten.hint.suffix": " page.",
|
||||
"projects.detail.checklisten.add": "Add checklist",
|
||||
"projects.detail.checklisten.add.search": "Search template…",
|
||||
"projects.detail.checklisten.add.empty_pick": "No matching templates.",
|
||||
"projects.detail.checklisten.add.created": "Checklist added.",
|
||||
"projects.detail.checklisten.add.error": "Could not create checklist.",
|
||||
"projects.detail.delete": "Archive project",
|
||||
"projects.detail.delete.confirm.title": "Archive project?",
|
||||
"projects.detail.delete.confirm.body": "The project will be archived. It cannot be directly restored.",
|
||||
|
||||
@@ -175,7 +175,8 @@ type TabId =
|
||||
| "appointments"
|
||||
| "notes"
|
||||
| "checklists"
|
||||
| "submissions";
|
||||
| "submissions"
|
||||
| "settings";
|
||||
|
||||
const VALID_TABS: TabId[] = [
|
||||
"history",
|
||||
@@ -187,6 +188,7 @@ const VALID_TABS: TabId[] = [
|
||||
"notes",
|
||||
"checklists",
|
||||
"submissions",
|
||||
"settings",
|
||||
];
|
||||
|
||||
// Legacy German tab slugs that may appear in bookmarked URLs after the
|
||||
@@ -214,6 +216,9 @@ interface ChecklistTemplateSummary {
|
||||
slug: string;
|
||||
titleDE: string;
|
||||
titleEN: string;
|
||||
descriptionDE?: string;
|
||||
descriptionEN?: string;
|
||||
regime?: string;
|
||||
itemCount: number;
|
||||
}
|
||||
|
||||
@@ -1182,13 +1187,16 @@ function renderHeader() {
|
||||
netdocs.style.display = "none";
|
||||
}
|
||||
|
||||
// Delete visibility: partner/admin only
|
||||
// Delete visibility: partner/admin only. The Verwaltung tab's archive
|
||||
// sub-section mirrors the same gate (t-paliad-245) — it only points at
|
||||
// the Edit-modal danger zone, so it's pointless to show when the danger
|
||||
// zone itself is hidden.
|
||||
const deleteWrap = document.getElementById("project-delete-wrap")!;
|
||||
if (me && (me.global_role === "global_admin")) {
|
||||
deleteWrap.style.display = "";
|
||||
} else {
|
||||
deleteWrap.style.display = "none";
|
||||
}
|
||||
const archiveSection = document.getElementById("project-settings-archive");
|
||||
const canArchive = !!me && me.global_role === "global_admin";
|
||||
deleteWrap.style.display = canArchive ? "" : "none";
|
||||
if (archiveSection) archiveSection.style.display = canArchive ? "" : "none";
|
||||
updateSettingsTabVisibility();
|
||||
}
|
||||
|
||||
// wrapEventTitleLink — kept for the dashboard activity feed which reuses
|
||||
@@ -1631,18 +1639,34 @@ function showTab(tab: TabId) {
|
||||
}
|
||||
|
||||
let checklistInstancesInited = false;
|
||||
async function loadAndRenderChecklistInstances(projectID: string) {
|
||||
if (checklistInstancesInited) return;
|
||||
let checklistCatalogLoaded = false;
|
||||
|
||||
// loadChecklistCatalog populates `checklistTemplates` (slug → template) from
|
||||
// `/api/checklists`. Reused by the tab renderer and the add-instance modal so
|
||||
// the second open doesn't refetch the catalog (t-paliad-239).
|
||||
async function loadChecklistCatalog(): Promise<ChecklistTemplateSummary[]> {
|
||||
if (checklistCatalogLoaded) return Object.values(checklistTemplates);
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists`);
|
||||
const list = resp.ok ? (((await resp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of list) checklistTemplates[tpl.slug] = tpl;
|
||||
checklistCatalogLoaded = true;
|
||||
return list;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAndRenderChecklistInstances(projectID: string, force = false) {
|
||||
if (checklistInstancesInited && !force) return;
|
||||
checklistInstancesInited = true;
|
||||
try {
|
||||
const [instResp, tplResp] = await Promise.all([
|
||||
const [instResp] = await Promise.all([
|
||||
fetch(`/api/projects/${projectID}/checklists`),
|
||||
fetch(`/api/checklists`),
|
||||
loadChecklistCatalog(),
|
||||
]);
|
||||
checklistInstances = instResp.ok ? ((await instResp.json()) ?? []) : [];
|
||||
const templates = tplResp.ok ? (((await tplResp.json()) as ChecklistTemplateSummary[]) ?? []) : [];
|
||||
checklistTemplates = {};
|
||||
for (const tpl of templates) checklistTemplates[tpl.slug] = tpl;
|
||||
} catch {
|
||||
checklistInstances = [];
|
||||
}
|
||||
@@ -1702,6 +1726,143 @@ function renderChecklistInstances() {
|
||||
});
|
||||
}
|
||||
|
||||
// initAddChecklistModal wires the "Checkliste hinzufügen" button on the
|
||||
// project-detail Checklists tab (t-paliad-239). Opens a template picker
|
||||
// modal; on pick, POSTs to /api/checklists/{slug}/instances with the
|
||||
// current project_id and the template title as the instance name.
|
||||
function initAddChecklistModal(projectID: string) {
|
||||
const addBtn = document.getElementById("checklist-add-btn") as HTMLButtonElement | null;
|
||||
const modal = document.getElementById("add-checklist-modal") as HTMLDivElement | null;
|
||||
const closeBtn = document.getElementById("add-checklist-close") as HTMLButtonElement | null;
|
||||
const search = document.getElementById("add-checklist-search") as HTMLInputElement | null;
|
||||
const list = document.getElementById("add-checklist-list") as HTMLDivElement | null;
|
||||
const empty = document.getElementById("add-checklist-empty") as HTMLParagraphElement | null;
|
||||
const modalMsg = document.getElementById("add-checklist-msg") as HTMLParagraphElement | null;
|
||||
const tabMsg = document.getElementById("project-checklists-msg") as HTMLParagraphElement | null;
|
||||
if (!addBtn || !modal || !closeBtn || !search || !list || !empty || !modalMsg || !tabMsg) return;
|
||||
|
||||
const close = () => {
|
||||
modal.style.display = "none";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
};
|
||||
|
||||
const renderPicker = () => {
|
||||
const isEN = getLang() === "en";
|
||||
const q = search.value.trim().toLowerCase();
|
||||
const all = Object.values(checklistTemplates);
|
||||
all.sort((a, b) => {
|
||||
const at = (isEN ? a.titleEN : a.titleDE) || a.slug;
|
||||
const bt = (isEN ? b.titleEN : b.titleDE) || b.slug;
|
||||
return at.localeCompare(bt, isEN ? "en" : "de");
|
||||
});
|
||||
const filtered = q
|
||||
? all.filter((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || "";
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
return title.toLowerCase().includes(q)
|
||||
|| desc.toLowerCase().includes(q)
|
||||
|| (tpl.regime || "").toLowerCase().includes(q);
|
||||
})
|
||||
: all;
|
||||
|
||||
if (filtered.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
list.innerHTML = filtered.map((tpl) => {
|
||||
const title = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
const desc = (isEN ? tpl.descriptionEN : tpl.descriptionDE) || "";
|
||||
const regime = tpl.regime || "";
|
||||
const regimeChip = regime
|
||||
? `<span class="checklist-regime checklist-regime-${escapeHtml(regime)}">${escapeHtml(regime)}</span>`
|
||||
: "";
|
||||
const descLine = desc ? `<p class="add-checklist-row-desc">${escapeHtml(desc)}</p>` : "";
|
||||
return `<button type="button" class="add-checklist-row" data-slug="${escapeHtml(tpl.slug)}">
|
||||
<div class="add-checklist-row-head">
|
||||
<span class="add-checklist-row-title">${escapeHtml(title)}</span>
|
||||
${regimeChip}
|
||||
</div>
|
||||
${descLine}
|
||||
</button>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const slug = btn.dataset.slug!;
|
||||
void pickTemplate(slug, btn);
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const pickTemplate = async (slug: string, btn: HTMLButtonElement) => {
|
||||
const tpl = checklistTemplates[slug];
|
||||
if (!tpl) return;
|
||||
const isEN = getLang() === "en";
|
||||
const name = (isEN ? tpl.titleEN : tpl.titleDE) || tpl.slug;
|
||||
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = true;
|
||||
});
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
|
||||
try {
|
||||
const resp = await fetch(`/api/checklists/${encodeURIComponent(slug)}/instances`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ name, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
return;
|
||||
}
|
||||
close();
|
||||
flashTabMsg(t("projects.detail.checklisten.add.created"));
|
||||
await loadAndRenderChecklistInstances(projectID, true);
|
||||
} catch {
|
||||
modalMsg.textContent = t("projects.detail.checklisten.add.error");
|
||||
modalMsg.className = "form-msg form-msg-error";
|
||||
list.querySelectorAll<HTMLButtonElement>(".add-checklist-row").forEach((b) => {
|
||||
b.disabled = false;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let flashTimer = 0;
|
||||
const flashTabMsg = (text: string) => {
|
||||
tabMsg.textContent = text;
|
||||
tabMsg.className = "form-msg form-msg-success";
|
||||
if (flashTimer) window.clearTimeout(flashTimer);
|
||||
flashTimer = window.setTimeout(() => {
|
||||
tabMsg.textContent = "";
|
||||
tabMsg.className = "form-msg";
|
||||
}, 3500);
|
||||
};
|
||||
|
||||
addBtn.addEventListener("click", async () => {
|
||||
await loadChecklistCatalog();
|
||||
search.value = "";
|
||||
modalMsg.textContent = "";
|
||||
modalMsg.className = "form-msg";
|
||||
renderPicker();
|
||||
modal.style.display = "flex";
|
||||
search.focus();
|
||||
});
|
||||
closeBtn.addEventListener("click", close);
|
||||
modal.addEventListener("click", (e) => { if (e.target === e.currentTarget) close(); });
|
||||
search.addEventListener("input", renderPicker);
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") close();
|
||||
});
|
||||
}
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
@@ -1889,6 +2050,17 @@ function initEditModal() {
|
||||
});
|
||||
}
|
||||
|
||||
// Verwaltung → Projekt archivieren — opens the edit modal scrolled to
|
||||
// the danger-zone archive button (t-paliad-245).
|
||||
const archiveLink = document.getElementById(
|
||||
"project-settings-archive-link",
|
||||
) as HTMLButtonElement | null;
|
||||
if (archiveLink) {
|
||||
archiveLink.addEventListener("click", () => {
|
||||
openEditModal("project-delete-btn");
|
||||
});
|
||||
}
|
||||
|
||||
form.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
if (!project) return;
|
||||
@@ -2105,6 +2277,7 @@ async function main() {
|
||||
initSmartTimelineClientToggle(id);
|
||||
initSmartTimelineAddModal(id);
|
||||
initAttachUnitForm(id);
|
||||
initAddChecklistModal(id);
|
||||
initNotesContainer(id);
|
||||
mountVerlaufFilterBar(id);
|
||||
wireExportButton(id);
|
||||
@@ -2834,17 +3007,21 @@ function canExportProject(): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
// wireExportButton reveals + hooks up the project-export button on the
|
||||
// tabs nav. Triggers a download via a transient <a download> — same
|
||||
// pattern as the personal export in client/settings.ts.
|
||||
// wireExportButton reveals the Export sub-section of the Verwaltung tab
|
||||
// (t-paliad-245) and hooks up the project-export button. Triggers a
|
||||
// download via a transient <a download> — same pattern as the personal
|
||||
// export in client/settings.ts.
|
||||
function wireExportButton(projectID: string): void {
|
||||
const section = document.getElementById("project-settings-export") as HTMLElement | null;
|
||||
const btn = document.getElementById("project-export-btn") as HTMLButtonElement | null;
|
||||
if (!btn) return;
|
||||
if (!section || !btn) return;
|
||||
if (!canExportProject()) {
|
||||
btn.style.display = "none";
|
||||
section.style.display = "none";
|
||||
updateSettingsTabVisibility();
|
||||
return;
|
||||
}
|
||||
btn.style.display = "";
|
||||
section.style.display = "";
|
||||
updateSettingsTabVisibility();
|
||||
btn.addEventListener("click", () => {
|
||||
const a = document.createElement("a");
|
||||
a.href = `/api/projects/${encodeURIComponent(projectID)}/export`;
|
||||
@@ -2855,6 +3032,17 @@ function wireExportButton(projectID: string): void {
|
||||
});
|
||||
}
|
||||
|
||||
// updateSettingsTabVisibility hides the Verwaltung tab when none of its
|
||||
// sub-sections are visible to the current user — an empty tab is worse
|
||||
// UX than no tab. Called whenever a sub-section's visibility flips.
|
||||
function updateSettingsTabVisibility(): void {
|
||||
const tab = document.querySelector<HTMLElement>('.entity-tab[data-tab="settings"]');
|
||||
if (!tab) return;
|
||||
const exportShown = document.getElementById("project-settings-export")?.style.display !== "none";
|
||||
const archiveShown = document.getElementById("project-settings-archive")?.style.display !== "none";
|
||||
tab.style.display = exportShown || archiveShown ? "" : "none";
|
||||
}
|
||||
|
||||
function canRemoveTeamMember(m: ProjectTeamMember): boolean {
|
||||
if (!me) return false;
|
||||
if (m.user_id === me.id) return true;
|
||||
|
||||
955
frontend/src/client/submission-draft.ts
Normal file
955
frontend/src/client/submission-draft.ts
Normal file
@@ -0,0 +1,955 @@
|
||||
import { initI18n, t } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-238 Slice A — client bundle for the dedicated
|
||||
// Submissions/Schriftsätze editor at
|
||||
// /projects/{id}/submissions/{code}/draft
|
||||
// /projects/{id}/submissions/{code}/draft/{draft_id}
|
||||
//
|
||||
// Reads (project_id, submission_code, optional draft_id) from the URL,
|
||||
// loads the editor payload (draft row + resolved bag + merged bag +
|
||||
// HTML preview), and wires the sidebar / preview / autosave / export.
|
||||
//
|
||||
// Autosave is debounced 500ms after the lawyer stops typing in any
|
||||
// variable input. Each PATCH returns a fresh editor payload, so the
|
||||
// preview pane stays in lockstep with the variable overrides.
|
||||
|
||||
interface SubmissionDraftJSON {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
submission_code: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
variables: Record<string, string>;
|
||||
last_exported_at?: string | null;
|
||||
last_exported_sha?: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface SubmissionRuleSummary {
|
||||
name: string;
|
||||
name_en: string;
|
||||
submission_code: string;
|
||||
primary_party?: string;
|
||||
event_type?: string;
|
||||
legal_source?: string;
|
||||
legal_source_pretty?: string;
|
||||
}
|
||||
|
||||
interface SubmissionDraftView {
|
||||
draft: SubmissionDraftJSON;
|
||||
rule?: SubmissionRuleSummary;
|
||||
resolved_bag: Record<string, string>;
|
||||
merged_bag: Record<string, string>;
|
||||
preview_html: string;
|
||||
lang: string;
|
||||
has_template: boolean;
|
||||
template_missing?: boolean;
|
||||
}
|
||||
|
||||
interface SubmissionDraftListResponse {
|
||||
project_id: string;
|
||||
submission_code: string;
|
||||
drafts: SubmissionDraftJSON[];
|
||||
}
|
||||
|
||||
interface ParsedPath {
|
||||
// Project-scoped path: /projects/{id}/submissions/{code}/draft[/{draft_id}].
|
||||
// Global path: /submissions/draft/{draft_id} — projectID + submissionCode are derived
|
||||
// from the loaded draft row after fetch.
|
||||
projectID: string | null;
|
||||
submissionCode: string | null;
|
||||
draftID?: string;
|
||||
// mode tracks the URL shape we were entered from. Affects redirect
|
||||
// semantics when we create a new draft or navigate away.
|
||||
mode: "project" | "global";
|
||||
}
|
||||
|
||||
const PROJECT_PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
|
||||
const GLOBAL_PATH_RE = /^\/submissions\/draft\/([0-9a-fA-F-]{36})\/?$/;
|
||||
|
||||
function parsePath(): ParsedPath | null {
|
||||
let m = PROJECT_PATH_RE.exec(window.location.pathname);
|
||||
if (m) {
|
||||
return {
|
||||
projectID: m[1],
|
||||
submissionCode: decodeURIComponent(m[2]),
|
||||
draftID: m[3],
|
||||
mode: "project",
|
||||
};
|
||||
}
|
||||
m = GLOBAL_PATH_RE.exec(window.location.pathname);
|
||||
if (m) {
|
||||
return {
|
||||
projectID: null,
|
||||
submissionCode: null,
|
||||
draftID: m[1],
|
||||
mode: "global",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
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, "'");
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// 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.
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface VariableLabel {
|
||||
de: string;
|
||||
en: string;
|
||||
}
|
||||
|
||||
interface VariableGroup {
|
||||
id: string;
|
||||
label: VariableLabel;
|
||||
keys: string[];
|
||||
}
|
||||
|
||||
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" },
|
||||
"rule.submission_code": { de: "Schriftsatz-Code", en: "Submission code" },
|
||||
"rule.name": { de: "Schriftsatz", en: "Submission" },
|
||||
"rule.name_de": { de: "Schriftsatz (DE)", en: "Submission (DE)" },
|
||||
"rule.name_en": { de: "Schriftsatz (EN)", en: "Submission (EN)" },
|
||||
"rule.legal_source": { de: "Rechtsgrundlage (Code)", en: "Legal source (code)" },
|
||||
"rule.legal_source_pretty": { de: "Rechtsgrundlage", en: "Legal source" },
|
||||
"rule.primary_party": { de: "Partei (typisch)", en: "Primary party" },
|
||||
"rule.event_type": { de: "Schriftsatz-Typ", en: "Event type" },
|
||||
"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" },
|
||||
};
|
||||
|
||||
const VARIABLE_GROUPS: VariableGroup[] = [
|
||||
{
|
||||
id: "rule",
|
||||
label: { de: "Schriftsatz", en: "Submission" },
|
||||
keys: [
|
||||
"rule.name",
|
||||
"rule.legal_source_pretty",
|
||||
"rule.primary_party",
|
||||
"rule.event_type",
|
||||
"rule.submission_code",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "parties",
|
||||
label: { de: "Mandanten & Parteien", en: "Clients & parties" },
|
||||
keys: [
|
||||
"parties.claimant.name",
|
||||
"parties.claimant.representative",
|
||||
"parties.defendant.name",
|
||||
"parties.defendant.representative",
|
||||
"parties.other.name",
|
||||
"parties.other.representative",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "project",
|
||||
label: { de: "Verfahren", en: "Proceeding" },
|
||||
keys: [
|
||||
"project.title",
|
||||
"project.case_number",
|
||||
"project.court",
|
||||
"project.patent_number",
|
||||
"project.patent_number_upc",
|
||||
"project.filing_date",
|
||||
"project.grant_date",
|
||||
"project.our_side",
|
||||
"project.proceeding.name",
|
||||
"project.client_number",
|
||||
"project.matter_number",
|
||||
"project.reference",
|
||||
"project.instance_level",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "deadline",
|
||||
label: { de: "Frist", en: "Deadline" },
|
||||
keys: [
|
||||
"deadline.due_date",
|
||||
"deadline.due_date_long_de",
|
||||
"deadline.due_date_long_en",
|
||||
"deadline.computed_from",
|
||||
"deadline.title",
|
||||
"deadline.original_due_date",
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "firm",
|
||||
label: { de: "Kanzlei & Datum", en: "Firm & date" },
|
||||
keys: [
|
||||
"firm.name",
|
||||
"user.display_name",
|
||||
"user.email",
|
||||
"user.office",
|
||||
"today.long_de",
|
||||
"today.long_en",
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
function labelFor(key: string): string {
|
||||
const entry = VARIABLE_LABELS[key];
|
||||
if (!entry) return key;
|
||||
return isEN() ? entry.en : entry.de;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Module state
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface State {
|
||||
parsed: ParsedPath;
|
||||
view: SubmissionDraftView | null;
|
||||
drafts: SubmissionDraftJSON[];
|
||||
saveTimer: number | null;
|
||||
pendingOverrides: Record<string, string> | null;
|
||||
inFlight: AbortController | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
parsed: null as unknown as ParsedPath,
|
||||
view: null,
|
||||
drafts: [],
|
||||
saveTimer: null,
|
||||
pendingOverrides: null,
|
||||
inFlight: null,
|
||||
};
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function boot(): Promise<void> {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
|
||||
const parsed = parsePath();
|
||||
if (!parsed) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
state.parsed = parsed;
|
||||
|
||||
try {
|
||||
if (parsed.mode === "global") {
|
||||
// Global path: we have a draft_id, fetch by id alone. Drafts
|
||||
// list (the sidebar switcher) is scoped to the same project +
|
||||
// submission_code AFTER we've loaded the draft.
|
||||
if (!parsed.draftID) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const view = await fetchGlobalView(parsed.draftID);
|
||||
state.view = view;
|
||||
// Backfill parsed.* from the loaded draft so the sidebar
|
||||
// switcher can list peers; project-less drafts get no peer list
|
||||
// beyond themselves (no useful (project, code) cross-section).
|
||||
state.parsed = {
|
||||
...parsed,
|
||||
projectID: view.draft.project_id,
|
||||
submissionCode: view.draft.submission_code,
|
||||
};
|
||||
if (view.draft.project_id) {
|
||||
try {
|
||||
const list = await fetchDrafts(state.parsed);
|
||||
state.drafts = list.drafts;
|
||||
} catch { state.drafts = [view.draft]; }
|
||||
} else {
|
||||
state.drafts = [view.draft];
|
||||
}
|
||||
paint();
|
||||
return;
|
||||
}
|
||||
|
||||
// Project-scoped path: same logic as before.
|
||||
if (!parsed.projectID || !parsed.submissionCode) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
const list = await fetchDrafts(parsed);
|
||||
state.drafts = list.drafts;
|
||||
let draft: SubmissionDraftJSON | null = null;
|
||||
if (parsed.draftID) {
|
||||
draft = state.drafts.find((d) => d.id === parsed.draftID) ?? null;
|
||||
if (!draft) {
|
||||
showNotFound();
|
||||
return;
|
||||
}
|
||||
} else if (state.drafts.length > 0) {
|
||||
draft = state.drafts[0];
|
||||
// Redirect to the canonical /draft/{id} URL so refresh + share
|
||||
// both land on the same draft.
|
||||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||||
window.history.replaceState({}, "", url);
|
||||
state.parsed = { ...parsed, draftID: draft.id };
|
||||
} else {
|
||||
draft = await createProjectDraft(parsed);
|
||||
state.drafts = [draft];
|
||||
const url = `/projects/${parsed.projectID}/submissions/${encodeURIComponent(parsed.submissionCode)}/draft/${draft.id}`;
|
||||
window.history.replaceState({}, "", url);
|
||||
state.parsed = { ...parsed, draftID: draft.id };
|
||||
}
|
||||
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
|
||||
state.view = view;
|
||||
paint();
|
||||
} catch (err) {
|
||||
console.error("submission-draft boot:", err);
|
||||
showError(isEN() ? "Failed to load draft." : "Entwurf konnte nicht geladen werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// API
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
async function fetchDrafts(p: ParsedPath): Promise<SubmissionDraftListResponse> {
|
||||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`drafts list ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function createProjectDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
|
||||
if (!p.projectID || !p.submissionCode) throw new Error("no project context");
|
||||
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts`;
|
||||
const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" } });
|
||||
if (!resp.ok) throw new Error(`create draft ${resp.status}`);
|
||||
const view = (await resp.json()) as SubmissionDraftView;
|
||||
return view.draft;
|
||||
}
|
||||
|
||||
async function fetchView(projectID: string, code: string, draftID: string): Promise<SubmissionDraftView> {
|
||||
const url = `/api/projects/${projectID}/submissions/${encodeURIComponent(code)}/drafts/${draftID}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function fetchGlobalView(draftID: string): Promise<SubmissionDraftView> {
|
||||
const resp = await fetch(`/api/submission-drafts/${draftID}`);
|
||||
if (!resp.ok) throw new Error(`get draft ${resp.status}`);
|
||||
return resp.json();
|
||||
}
|
||||
|
||||
async function patchDraft(payload: { name?: string; variables?: Record<string, string>; project_id?: string | null }): Promise<SubmissionDraftView> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) throw new Error("no draft id");
|
||||
if (state.inFlight) {
|
||||
state.inFlight.abort();
|
||||
state.inFlight = null;
|
||||
}
|
||||
const ctl = new AbortController();
|
||||
state.inFlight = ctl;
|
||||
// The global PATCH endpoint accepts both project-scoped and
|
||||
// project-less drafts — route everything through it so attach (set
|
||||
// project_id) works from both URL shapes.
|
||||
const url = `/api/submission-drafts/${p.draftID}`;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
signal: ctl.signal,
|
||||
});
|
||||
if (!resp.ok) throw new Error(`patch draft ${resp.status}`);
|
||||
return resp.json();
|
||||
} finally {
|
||||
if (state.inFlight === ctl) state.inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDraft(): Promise<void> {
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) return;
|
||||
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
|
||||
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Render
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function paint(): void {
|
||||
if (!state.view) return;
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-notfound");
|
||||
hide("submission-draft-error");
|
||||
show("submission-draft-body");
|
||||
|
||||
paintHeader();
|
||||
paintBackLink();
|
||||
paintNoProjectBanner();
|
||||
paintSwitcher();
|
||||
paintNameRow();
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
}
|
||||
|
||||
function paintHeader(): void {
|
||||
const view = state.view!;
|
||||
const title = document.getElementById("submission-draft-title");
|
||||
if (title) {
|
||||
const ruleName = view.rule?.name ?? view.draft.submission_code;
|
||||
title.textContent = ruleName;
|
||||
}
|
||||
const subtitle = document.getElementById("submission-draft-subtitle");
|
||||
if (subtitle) {
|
||||
const code = view.draft.submission_code;
|
||||
const source = view.rule?.legal_source_pretty ?? view.rule?.legal_source ?? "";
|
||||
const parts: string[] = [code];
|
||||
if (source) parts.push(source);
|
||||
subtitle.textContent = parts.join(" · ");
|
||||
}
|
||||
}
|
||||
|
||||
function paintBackLink(): void {
|
||||
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
|
||||
if (!back || !state.view) return;
|
||||
if (state.view.draft.project_id) {
|
||||
back.href = `/projects/${state.view.draft.project_id}/submissions`;
|
||||
back.textContent = isEN() ? "← Back to project" : "← Zurück zum Projekt";
|
||||
} else {
|
||||
back.href = "/submissions";
|
||||
back.textContent = isEN() ? "← Back to drafts" : "← Zurück zur Übersicht";
|
||||
}
|
||||
}
|
||||
|
||||
// paintNoProjectBanner adds (or removes) the "Kein Projekt zugeordnet"
|
||||
// banner above the editor body. The banner offers a "Projekt zuweisen"
|
||||
// button that opens an inline project picker — same modal pattern the
|
||||
// /submissions/new page uses. Removed once the draft has a project_id.
|
||||
function paintNoProjectBanner(): void {
|
||||
const body = document.getElementById("submission-draft-body");
|
||||
if (!body || !state.view) return;
|
||||
let banner = document.getElementById("submission-draft-noproject-banner");
|
||||
|
||||
if (state.view.draft.project_id) {
|
||||
if (banner) banner.remove();
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = isEN()
|
||||
? "No project assigned — all variables are filled manually."
|
||||
: "Kein Projekt zugeordnet — alle Variablen werden manuell befüllt.";
|
||||
const cta = isEN() ? "Assign project…" : "Projekt zuweisen…";
|
||||
const html = `<p class="submission-draft-noproject-banner-msg">${escapeHtml(msg)}</p>
|
||||
<button type="button" id="submission-draft-noproject-assign"
|
||||
class="btn-secondary btn-small">${escapeHtml(cta)}</button>`;
|
||||
|
||||
if (banner) {
|
||||
banner.innerHTML = html;
|
||||
} else {
|
||||
banner = document.createElement("aside");
|
||||
banner.id = "submission-draft-noproject-banner";
|
||||
banner.className = "submission-draft-noproject-banner";
|
||||
banner.innerHTML = html;
|
||||
// Insert before the header.
|
||||
const header = body.querySelector(".submission-draft-header");
|
||||
if (header && header.parentElement) {
|
||||
header.parentElement.insertBefore(banner, header);
|
||||
} else {
|
||||
body.prepend(banner);
|
||||
}
|
||||
}
|
||||
|
||||
const btn = document.getElementById("submission-draft-noproject-assign") as HTMLButtonElement | null;
|
||||
if (btn) btn.onclick = () => openProjectAssignPicker();
|
||||
}
|
||||
|
||||
function paintSwitcher(): void {
|
||||
const sel = document.getElementById("submission-draft-pick") as HTMLSelectElement | null;
|
||||
if (!sel || !state.view) return;
|
||||
sel.innerHTML = state.drafts
|
||||
.map((d) => `<option value="${escapeHtml(d.id)}"${d.id === state.view!.draft.id ? " selected" : ""}>${escapeHtml(d.name)}</option>`)
|
||||
.join("");
|
||||
sel.onchange = () => {
|
||||
const id = sel.value;
|
||||
if (!id || !state.view) return;
|
||||
if (id === state.view.draft.id) return;
|
||||
const p = state.parsed;
|
||||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${id}`;
|
||||
window.location.href = url;
|
||||
};
|
||||
}
|
||||
|
||||
function paintNameRow(): void {
|
||||
const input = document.getElementById("submission-draft-name") as HTMLInputElement | null;
|
||||
const del = document.getElementById("submission-draft-delete-btn") as HTMLButtonElement | null;
|
||||
if (input && state.view) {
|
||||
input.value = state.view.draft.name;
|
||||
input.onchange = () => {
|
||||
const newName = input.value.trim();
|
||||
if (!newName || newName === state.view!.draft.name) return;
|
||||
void renameDraft(newName);
|
||||
};
|
||||
}
|
||||
if (del) del.onclick = () => onDelete();
|
||||
|
||||
const newBtn = document.getElementById("submission-draft-new-btn") as HTMLButtonElement | null;
|
||||
if (newBtn) newBtn.onclick = () => onCreateNew();
|
||||
|
||||
const exportBtn = document.getElementById("submission-draft-export-btn") as HTMLButtonElement | null;
|
||||
if (exportBtn) exportBtn.onclick = () => onExport(exportBtn);
|
||||
}
|
||||
|
||||
function paintVariables(): void {
|
||||
const host = document.getElementById("submission-draft-variables");
|
||||
if (!host || !state.view) return;
|
||||
const overrides = state.view.draft.variables ?? {};
|
||||
const resolved = state.view.resolved_bag ?? {};
|
||||
const merged = state.view.merged_bag ?? {};
|
||||
|
||||
let html = "";
|
||||
for (const group of VARIABLE_GROUPS) {
|
||||
const groupLabel = isEN() ? group.label.en : group.label.de;
|
||||
html += `<section class="submission-draft-var-group" data-group="${group.id}">`;
|
||||
html += `<h3 class="submission-draft-var-group-title">${escapeHtml(groupLabel)}</h3>`;
|
||||
for (const key of group.keys) {
|
||||
const label = labelFor(key);
|
||||
const override = overrides[key];
|
||||
const resolvedVal = resolved[key] ?? "";
|
||||
const mergedVal = merged[key] ?? "";
|
||||
const overridden = Object.prototype.hasOwnProperty.call(overrides, key);
|
||||
html += `<label class="submission-draft-var-row" data-key="${escapeHtml(key)}">`;
|
||||
html += `<span class="submission-draft-var-label">${escapeHtml(label)}</span>`;
|
||||
html += `<input type="text" class="submission-draft-var-input entity-form-input"`;
|
||||
html += ` data-var="${escapeHtml(key)}"`;
|
||||
html += ` value="${escapeHtml(overridden ? override : resolvedVal)}"`;
|
||||
html += ` data-resolved="${escapeHtml(resolvedVal)}" />`;
|
||||
const hintParts: string[] = [];
|
||||
hintParts.push(`<code>{{${escapeHtml(key)}}}</code>`);
|
||||
if (overridden) {
|
||||
if (override === "") {
|
||||
hintParts.push(`<span class="submission-draft-var-marker">${escapeHtml(isEN() ? "→ [NO VALUE: " + key + "]" : "→ [KEIN WERT: " + key + "]")}</span>`);
|
||||
} else if (override !== resolvedVal) {
|
||||
const original = resolvedVal === "" ? (isEN() ? "(empty)" : "(leer)") : resolvedVal;
|
||||
hintParts.push(`<span class="submission-draft-var-was">${escapeHtml((isEN() ? "Project: " : "Projekt: ") + original)}</span>`);
|
||||
}
|
||||
}
|
||||
html += `<span class="submission-draft-var-hint">${hintParts.join(" · ")}</span>`;
|
||||
if (overridden && override !== resolvedVal) {
|
||||
html += `<button type="button" class="submission-draft-var-reset btn-small btn-link" data-reset-key="${escapeHtml(key)}">${escapeHtml(isEN() ? "Reset" : "Zurücksetzen")}</button>`;
|
||||
}
|
||||
html += `</label>`;
|
||||
// Visual hint: marker text appears in preview when override is "".
|
||||
void mergedVal;
|
||||
}
|
||||
html += `</section>`;
|
||||
}
|
||||
host.innerHTML = html;
|
||||
|
||||
host.querySelectorAll<HTMLInputElement>(".submission-draft-var-input").forEach((inp) => {
|
||||
inp.addEventListener("input", () => onVarChange(inp));
|
||||
});
|
||||
host.querySelectorAll<HTMLButtonElement>(".submission-draft-var-reset").forEach((btn) => {
|
||||
btn.addEventListener("click", () => onVarReset(btn.dataset.resetKey ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
function paintPreview(): void {
|
||||
const host = document.getElementById("submission-draft-preview");
|
||||
if (!host || !state.view) return;
|
||||
host.innerHTML = state.view.preview_html ?? "";
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Event handlers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function onVarChange(input: HTMLInputElement): void {
|
||||
const key = input.dataset.var;
|
||||
if (!key || !state.view) return;
|
||||
// Stage the override on the draft view so paintPreview reflects.
|
||||
const overrides = { ...state.view.draft.variables };
|
||||
overrides[key] = input.value;
|
||||
state.view.draft.variables = overrides;
|
||||
state.pendingOverrides = overrides;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||||
state.saveTimer = window.setTimeout(() => {
|
||||
void flushAutosave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function onVarReset(key: string): void {
|
||||
if (!state.view) return;
|
||||
const overrides = { ...state.view.draft.variables };
|
||||
delete overrides[key];
|
||||
state.view.draft.variables = overrides;
|
||||
state.pendingOverrides = overrides;
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
if (state.saveTimer) window.clearTimeout(state.saveTimer);
|
||||
state.saveTimer = window.setTimeout(() => {
|
||||
void flushAutosave();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
async function flushAutosave(): Promise<void> {
|
||||
if (!state.pendingOverrides) return;
|
||||
const payload = { variables: state.pendingOverrides };
|
||||
state.pendingOverrides = null;
|
||||
try {
|
||||
const view = await patchDraft(payload);
|
||||
state.view = view;
|
||||
paintVariables();
|
||||
paintPreview();
|
||||
setSaveStatus(isEN() ? "Saved" : "Gespeichert");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft autosave:", err);
|
||||
setSaveStatus(isEN() ? "Save failed" : "Speichern fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function renameDraft(newName: string): Promise<void> {
|
||||
setSaveStatus(isEN() ? "Saving…" : "Speichert…");
|
||||
try {
|
||||
const view = await patchDraft({ name: newName });
|
||||
state.view = view;
|
||||
// Refresh the draft list cache.
|
||||
const idx = state.drafts.findIndex((d) => d.id === view.draft.id);
|
||||
if (idx >= 0) state.drafts[idx].name = view.draft.name;
|
||||
paintSwitcher();
|
||||
setSaveStatus(isEN() ? "Renamed" : "Umbenannt");
|
||||
} catch (err) {
|
||||
if ((err as Error).name === "AbortError") return;
|
||||
console.error("submission-draft rename:", err);
|
||||
const msg = (err as Error).message?.includes("409")
|
||||
? (isEN() ? "Name already in use" : "Name bereits vergeben")
|
||||
: (isEN() ? "Rename failed" : "Umbenennen fehlgeschlagen");
|
||||
setSaveStatus(msg, true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onCreateNew(): Promise<void> {
|
||||
const p = state.parsed;
|
||||
// From a project-less draft, "Neuer Entwurf" can't auto-pick a
|
||||
// (project, code) cross-section — kick the user out to the global
|
||||
// picker instead.
|
||||
if (!p.projectID || !p.submissionCode) {
|
||||
window.location.href = "/submissions/new";
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const fresh = await createProjectDraft(p);
|
||||
const url = `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft/${fresh.id}`;
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error("submission-draft new:", err);
|
||||
setSaveStatus(isEN() ? "Create failed" : "Anlegen fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onDelete(): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const msg = isEN()
|
||||
? `Delete draft "${state.view.draft.name}"? This cannot be undone.`
|
||||
: `Entwurf "${state.view.draft.name}" löschen? Das kann nicht rückgängig gemacht werden.`;
|
||||
if (!window.confirm(msg)) return;
|
||||
try {
|
||||
await deleteDraft();
|
||||
const p = state.parsed;
|
||||
const url = p.projectID && p.submissionCode
|
||||
? `/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/draft`
|
||||
: "/submissions";
|
||||
window.location.href = url;
|
||||
} catch (err) {
|
||||
console.error("submission-draft delete:", err);
|
||||
setSaveStatus(isEN() ? "Delete failed" : "Löschen fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
async function onExport(btn: HTMLButtonElement): Promise<void> {
|
||||
if (!state.view) return;
|
||||
const p = state.parsed;
|
||||
if (!p.draftID) return;
|
||||
const originalLabel = btn.textContent ?? "";
|
||||
btn.disabled = true;
|
||||
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
|
||||
try {
|
||||
// Use the global export endpoint for both project-scoped and
|
||||
// project-less drafts; the handler routes audit + project_events
|
||||
// writes based on the draft row's project_id.
|
||||
const url = `/api/submission-drafts/${p.draftID}/export`;
|
||||
const resp = await fetch(url, { method: "POST" });
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* fallthrough */ }
|
||||
alert((isEN() ? "Export failed." : "Export fehlgeschlagen.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const blob = await resp.blob();
|
||||
const filename = parseFilename(resp.headers.get("Content-Disposition") ?? "") ?? `${state.view.draft.submission_code}.docx`;
|
||||
triggerDownload(blob, filename);
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = originalLabel;
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project assign picker (project-less → project-scoped)
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PickerProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
let assignPickerProjects: PickerProjectRow[] = [];
|
||||
let assignPickerLoaded = false;
|
||||
|
||||
function openProjectAssignPicker(): void {
|
||||
ensureAssignPickerDOM();
|
||||
const modal = document.getElementById("submission-draft-assign-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!assignPickerLoaded) {
|
||||
void loadAssignPickerProjects();
|
||||
} else {
|
||||
renderAssignPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectAssignPicker(): void {
|
||||
const modal = document.getElementById("submission-draft-assign-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
function ensureAssignPickerDOM(): void {
|
||||
if (document.getElementById("submission-draft-assign-modal")) return;
|
||||
const titleTxt = isEN() ? "Assign project" : "Projekt zuweisen";
|
||||
const placeholder = isEN()
|
||||
? "Search project (title or reference)…"
|
||||
: "Projekt suchen (Titel oder Aktenzeichen)…";
|
||||
const loadingTxt = isEN() ? "Loading projects…" : "Lädt Projekte…";
|
||||
const emptyTxt = isEN() ? "No visible projects." : "Keine sichtbaren Projekte.";
|
||||
|
||||
const modal = document.createElement("div");
|
||||
modal.id = "submission-draft-assign-modal";
|
||||
modal.className = "modal-overlay";
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.style.display = "none";
|
||||
modal.innerHTML = `
|
||||
<div class="modal-card">
|
||||
<header class="modal-header">
|
||||
<h2>${escapeHtml(titleTxt)}</h2>
|
||||
<button type="button" id="submission-draft-assign-close" class="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div class="modal-body">
|
||||
<input type="search" id="submission-draft-assign-search" class="entity-form-input" placeholder="${escapeHtml(placeholder)}" />
|
||||
<ul id="submission-draft-assign-list" class="submissions-new-project-list"></ul>
|
||||
<p id="submission-draft-assign-loading" class="entity-events-empty" style="display:none">${escapeHtml(loadingTxt)}</p>
|
||||
<p id="submission-draft-assign-empty" class="entity-empty" style="display:none">${escapeHtml(emptyTxt)}</p>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectAssignPicker();
|
||||
});
|
||||
const closeBtn = document.getElementById("submission-draft-assign-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectAssignPicker());
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
if (searchInput) searchInput.addEventListener("input", () => renderAssignPickerList());
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && modal.style.display !== "none") closeProjectAssignPicker();
|
||||
});
|
||||
}
|
||||
|
||||
async function loadAssignPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submission-draft-assign-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as PickerProjectRow[];
|
||||
assignPickerProjects = rows ?? [];
|
||||
assignPickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submission-draft assignPicker:", err);
|
||||
assignPickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderAssignPickerList();
|
||||
}
|
||||
|
||||
function renderAssignPickerList(): void {
|
||||
const list = document.getElementById("submission-draft-assign-list");
|
||||
const empty = document.getElementById("submission-draft-assign-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submission-draft-assign-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = assignPickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${escapeHtml(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${escapeHtml(p.id)}">${ref}<span class="submissions-new-project-title">${escapeHtml(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
if (pid) void onAssignProject(pid);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function onAssignProject(projectID: string): Promise<void> {
|
||||
closeProjectAssignPicker();
|
||||
setSaveStatus(isEN() ? "Assigning…" : "Wird zugewiesen…");
|
||||
try {
|
||||
const view = await patchDraft({ project_id: projectID });
|
||||
state.view = view;
|
||||
setSaveStatus(isEN() ? "Project assigned" : "Projekt zugewiesen");
|
||||
// Redirect to the project-scoped URL so the editor's URL matches the
|
||||
// attached project and the project-scoped draft list (sidebar
|
||||
// switcher) loads on refresh.
|
||||
const code = view.draft.submission_code;
|
||||
window.location.href = `/projects/${projectID}/submissions/${encodeURIComponent(code)}/draft/${view.draft.id}`;
|
||||
} catch (err) {
|
||||
console.error("submission-draft assign:", err);
|
||||
setSaveStatus(isEN() ? "Assign failed" : "Zuweisung fehlgeschlagen", true);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Helpers
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function show(id: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = "";
|
||||
}
|
||||
|
||||
function hide(id: string): void {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.style.display = "none";
|
||||
}
|
||||
|
||||
function showNotFound(): void {
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-body");
|
||||
show("submission-draft-notfound");
|
||||
}
|
||||
|
||||
function showError(msg: string): void {
|
||||
hide("submission-draft-loading");
|
||||
hide("submission-draft-body");
|
||||
const el = document.getElementById("submission-draft-error");
|
||||
if (el) {
|
||||
el.textContent = msg;
|
||||
el.style.display = "";
|
||||
}
|
||||
}
|
||||
|
||||
function setSaveStatus(msg: string, errorState: boolean = false): void {
|
||||
const el = document.getElementById("submission-draft-savestatus");
|
||||
if (!el) return;
|
||||
el.textContent = msg;
|
||||
el.classList.toggle("submission-draft-savestatus--error", errorState);
|
||||
}
|
||||
|
||||
function parseFilename(header: string): string | null {
|
||||
const m = /filename\s*=\s*"?([^";]+)"?/i.exec(header);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
function triggerDownload(blob: Blob, filename: string): void {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
a.download = filename;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
|
||||
// Keep t() referenced so the bundler doesn't tree-shake it; future
|
||||
// affordances will use the per-page i18n keys.
|
||||
void t;
|
||||
|
||||
if (document.readyState === "loading") {
|
||||
document.addEventListener("DOMContentLoaded", () => { void boot(); });
|
||||
} else {
|
||||
void boot();
|
||||
}
|
||||
130
frontend/src/client/submissions-index.ts
Normal file
130
frontend/src/client/submissions-index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { initI18n, onLangChange, t, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// t-paliad-240 — global Schriftsätze drafts index. Loads
|
||||
// /api/user/submission-drafts and renders one entity-table row per
|
||||
// draft. Row click → editor at /projects/{project_id}/submissions/
|
||||
// {submission_code}/draft/{draft_id}. Per project CLAUDE.md row-click
|
||||
// contract: a table whose rows look clickable must navigate on click;
|
||||
// inner links / buttons keep their own affordance.
|
||||
|
||||
interface DraftRow {
|
||||
id: string;
|
||||
project_id: string | null;
|
||||
project_title: string | null;
|
||||
project_reference?: string | null;
|
||||
submission_code: string;
|
||||
name: string;
|
||||
last_exported_at?: string | null;
|
||||
updated_at: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
let drafts: DraftRow[] = [];
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function fmtDate(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return "";
|
||||
const isEN = getLang() === "en";
|
||||
return d.toLocaleDateString(isEN ? "en-GB" : "de-DE", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
async function load(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-index-loading")!;
|
||||
const empty = document.getElementById("submissions-index-empty")!;
|
||||
const error = document.getElementById("submissions-index-error")!;
|
||||
const wrap = document.getElementById("submissions-index-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/user/submission-drafts");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
drafts = (data.drafts ?? []) as DraftRow[];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
|
||||
if (drafts.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
render();
|
||||
}
|
||||
|
||||
function render(): void {
|
||||
const body = document.getElementById("submissions-index-body")!;
|
||||
|
||||
const isEN = getLang() === "en";
|
||||
const noProjectLabel = isEN ? "(no project)" : "(kein Projekt)";
|
||||
|
||||
body.innerHTML = drafts.map((d) => {
|
||||
const projectCell = (() => {
|
||||
if (!d.project_id) {
|
||||
return `<span class="submissions-index-no-project">${esc(noProjectLabel)}</span>`;
|
||||
}
|
||||
const title = esc(d.project_title ?? "");
|
||||
if (d.project_reference) {
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project"><span class="entity-ref">${esc(d.project_reference)}</span> ${title}</a>`;
|
||||
}
|
||||
return `<a href="/projects/${esc(d.project_id)}" class="checklist-instance-project">${title}</a>`;
|
||||
})();
|
||||
|
||||
const href = d.project_id
|
||||
? `/projects/${esc(d.project_id)}/submissions/${esc(d.submission_code)}/draft/${esc(d.id)}`
|
||||
: `/submissions/draft/${esc(d.id)}`;
|
||||
|
||||
return `<tr class="submissions-index-row" data-href="${esc(href)}">
|
||||
<td>${projectCell}</td>
|
||||
<td>${esc(d.submission_code)}</td>
|
||||
<td><a href="${esc(href)}" class="submissions-index-draft-name">${esc(d.name)}</a></td>
|
||||
<td>${esc(fmtDate(d.updated_at))}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
body.querySelectorAll<HTMLTableRowElement>(".submissions-index-row").forEach((row) => {
|
||||
const href = row.dataset.href!;
|
||||
row.addEventListener("click", (e) => {
|
||||
// Inner <a> elements (project link, draft name) handle their own
|
||||
// navigation — let the browser dispatch them.
|
||||
if ((e.target as HTMLElement).closest("a, button")) return;
|
||||
window.location.href = href;
|
||||
});
|
||||
});
|
||||
|
||||
// Keep tsc happy for the imported `t` (used only via data-i18n on
|
||||
// static markup — keep the import so future dynamic strings can hook
|
||||
// in without re-importing).
|
||||
void t;
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
onLangChange(() => {
|
||||
if (drafts.length > 0) render();
|
||||
});
|
||||
void load();
|
||||
});
|
||||
368
frontend/src/client/submissions-new.ts
Normal file
368
frontend/src/client/submissions-new.ts
Normal file
@@ -0,0 +1,368 @@
|
||||
import { initI18n, getLang } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
|
||||
// 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}).
|
||||
|
||||
interface CatalogEntry {
|
||||
submission_code: string;
|
||||
name: string;
|
||||
name_en: string;
|
||||
event_type?: string;
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface CatalogResponse {
|
||||
entries: CatalogEntry[];
|
||||
}
|
||||
|
||||
interface ProjectRow {
|
||||
id: string;
|
||||
title: string;
|
||||
reference?: string | null;
|
||||
}
|
||||
|
||||
interface State {
|
||||
entries: CatalogEntry[];
|
||||
activeProceeding: string | null; // null = all
|
||||
searchTerm: string;
|
||||
pickerForCode: string | null;
|
||||
}
|
||||
|
||||
const state: State = {
|
||||
entries: [],
|
||||
activeProceeding: null,
|
||||
searchTerm: "",
|
||||
pickerForCode: null,
|
||||
};
|
||||
|
||||
function isEN(): boolean {
|
||||
return getLang() === "en";
|
||||
}
|
||||
|
||||
function esc(s: string): string {
|
||||
const d = document.createElement("div");
|
||||
d.textContent = s;
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
function partyLabel(role: string | undefined): string {
|
||||
switch ((role ?? "").toLowerCase()) {
|
||||
case "claimant": return isEN() ? "Claimant" : "Klägerin";
|
||||
case "defendant": return isEN() ? "Defendant" : "Beklagte";
|
||||
case "both": return isEN() ? "Both" : "Beide";
|
||||
case "court": return isEN() ? "Court" : "Gericht";
|
||||
default: return "";
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCatalog(): Promise<void> {
|
||||
const loading = document.getElementById("submissions-new-loading")!;
|
||||
const error = document.getElementById("submissions-new-error")!;
|
||||
const wrap = document.getElementById("submissions-new-tablewrap")!;
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/submissions/catalog");
|
||||
if (!resp.ok) {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
const data = (await resp.json()) as CatalogResponse;
|
||||
state.entries = data.entries ?? [];
|
||||
} catch {
|
||||
loading.style.display = "none";
|
||||
error.style.display = "";
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = "none";
|
||||
wrap.style.display = "";
|
||||
renderChips();
|
||||
renderTable();
|
||||
}
|
||||
|
||||
function renderChips(): void {
|
||||
const host = document.getElementById("submissions-new-proceeding-chips");
|
||||
if (!host) return;
|
||||
const seen = new Map<string, string>();
|
||||
for (const e of state.entries) {
|
||||
if (!seen.has(e.proceeding_code)) {
|
||||
seen.set(e.proceeding_code, isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name);
|
||||
}
|
||||
}
|
||||
const chips: string[] = [];
|
||||
const allLabel = isEN() ? "All" : "Alle";
|
||||
const allActive = state.activeProceeding === null;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${allActive ? " submissions-new-chip--active" : ""}" data-code="">${esc(allLabel)}</button>`);
|
||||
for (const [code, name] of seen) {
|
||||
const active = state.activeProceeding === code;
|
||||
chips.push(`<button type="button" class="submissions-new-chip${active ? " submissions-new-chip--active" : ""}" data-code="${esc(code)}">${esc(name)} <span class="submissions-new-chip-code">${esc(code)}</span></button>`);
|
||||
}
|
||||
host.innerHTML = chips.join("");
|
||||
host.querySelectorAll<HTMLButtonElement>(".submissions-new-chip").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code ?? "";
|
||||
state.activeProceeding = code === "" ? null : code;
|
||||
renderChips();
|
||||
renderTable();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function filtered(): CatalogEntry[] {
|
||||
const term = state.searchTerm.trim().toLowerCase();
|
||||
return state.entries.filter((e) => {
|
||||
if (state.activeProceeding !== null && e.proceeding_code !== state.activeProceeding) {
|
||||
return false;
|
||||
}
|
||||
if (term === "") return true;
|
||||
const name = isEN() && e.name_en ? e.name_en : e.name;
|
||||
const hay = [
|
||||
name,
|
||||
e.submission_code,
|
||||
e.legal_source ?? "",
|
||||
e.proceeding_code,
|
||||
e.proceeding_name,
|
||||
e.proceeding_name_en,
|
||||
].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
});
|
||||
}
|
||||
|
||||
function renderTable(): void {
|
||||
const body = document.getElementById("submissions-new-body");
|
||||
const empty = document.getElementById("submissions-new-empty");
|
||||
const wrap = document.getElementById("submissions-new-tablewrap");
|
||||
if (!body || !empty || !wrap) return;
|
||||
|
||||
const rows = filtered();
|
||||
if (rows.length === 0) {
|
||||
wrap.style.display = "none";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
empty.style.display = "none";
|
||||
|
||||
// Group by proceeding.
|
||||
const groups = new Map<string, { name: string; entries: CatalogEntry[] }>();
|
||||
for (const e of rows) {
|
||||
const gname = isEN() && e.proceeding_name_en ? e.proceeding_name_en : e.proceeding_name;
|
||||
const bucket = groups.get(e.proceeding_code);
|
||||
if (bucket) {
|
||||
bucket.entries.push(e);
|
||||
} else {
|
||||
groups.set(e.proceeding_code, { name: gname, entries: [e] });
|
||||
}
|
||||
}
|
||||
|
||||
const colspan = 4;
|
||||
const html: string[] = [];
|
||||
for (const [code, group] of groups) {
|
||||
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>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
body.querySelectorAll<HTMLButtonElement>(".submissions-new-start-no-project").forEach((btn) => {
|
||||
btn.addEventListener("click", () => {
|
||||
const code = btn.dataset.code;
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: CatalogEntry): string {
|
||||
const name = isEN() && entry.name_en ? entry.name_en : entry.name;
|
||||
const source = entry.legal_source ?? "";
|
||||
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";
|
||||
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${esc(name)}</span>
|
||||
<span class="submission-code">${esc(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<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>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
async function startDraft(submissionCode: string, projectID: string | null): Promise<void> {
|
||||
try {
|
||||
const resp = await fetch("/api/submission-drafts", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ submission_code: submissionCode, project_id: projectID }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
let detail = "";
|
||||
try {
|
||||
const data = (await resp.json()) as { error?: string };
|
||||
detail = data.error ?? "";
|
||||
} catch { /* ignore */ }
|
||||
alert((isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.") + (detail ? `\n\n${detail}` : ""));
|
||||
return;
|
||||
}
|
||||
const view = await resp.json() as { draft: { id: string; project_id: string | null; submission_code: string } };
|
||||
const id = view.draft.id;
|
||||
const pid = view.draft.project_id;
|
||||
const code = view.draft.submission_code;
|
||||
if (pid) {
|
||||
window.location.href = `/projects/${pid}/submissions/${encodeURIComponent(code)}/draft/${id}`;
|
||||
} else {
|
||||
window.location.href = `/submissions/draft/${id}`;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("submissions-new createDraft:", err);
|
||||
alert(isEN() ? "Failed to create draft." : "Entwurf konnte nicht angelegt werden.");
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Project picker modal
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
let pickerProjects: ProjectRow[] = [];
|
||||
let pickerLoaded = false;
|
||||
|
||||
function openProjectPicker(submissionCode: string): void {
|
||||
state.pickerForCode = submissionCode;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "";
|
||||
if (!pickerLoaded) {
|
||||
void loadPickerProjects();
|
||||
} else {
|
||||
renderPickerList();
|
||||
}
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (searchInput) {
|
||||
searchInput.value = "";
|
||||
setTimeout(() => searchInput.focus(), 50);
|
||||
}
|
||||
}
|
||||
|
||||
function closeProjectPicker(): void {
|
||||
state.pickerForCode = null;
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) modal.style.display = "none";
|
||||
}
|
||||
|
||||
async function loadPickerProjects(): Promise<void> {
|
||||
const loadingEl = document.getElementById("submissions-new-project-loading");
|
||||
if (loadingEl) loadingEl.style.display = "";
|
||||
try {
|
||||
const resp = await fetch("/api/projects?status=active");
|
||||
if (!resp.ok) throw new Error(`projects list ${resp.status}`);
|
||||
const rows = (await resp.json()) as ProjectRow[];
|
||||
pickerProjects = rows ?? [];
|
||||
pickerLoaded = true;
|
||||
} catch (err) {
|
||||
console.error("submissions-new loadPickerProjects:", err);
|
||||
pickerProjects = [];
|
||||
} finally {
|
||||
if (loadingEl) loadingEl.style.display = "none";
|
||||
}
|
||||
renderPickerList();
|
||||
}
|
||||
|
||||
function renderPickerList(): void {
|
||||
const list = document.getElementById("submissions-new-project-list");
|
||||
const empty = document.getElementById("submissions-new-project-empty");
|
||||
if (!list || !empty) return;
|
||||
|
||||
const searchInput = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
const term = (searchInput?.value ?? "").trim().toLowerCase();
|
||||
|
||||
const matches = pickerProjects.filter((p) => {
|
||||
if (term === "") return true;
|
||||
const hay = [p.title, p.reference ?? ""].join(" ").toLowerCase();
|
||||
return hay.includes(term);
|
||||
}).slice(0, 50);
|
||||
|
||||
if (matches.length === 0) {
|
||||
list.innerHTML = "";
|
||||
empty.style.display = "";
|
||||
return;
|
||||
}
|
||||
empty.style.display = "none";
|
||||
|
||||
list.innerHTML = matches.map((p) => {
|
||||
const ref = p.reference ? `<span class="entity-ref">${esc(p.reference)}</span> ` : "";
|
||||
return `<li class="submissions-new-project-item" data-id="${esc(p.id)}">${ref}<span class="submissions-new-project-title">${esc(p.title)}</span></li>`;
|
||||
}).join("");
|
||||
|
||||
list.querySelectorAll<HTMLLIElement>(".submissions-new-project-item").forEach((li) => {
|
||||
li.addEventListener("click", () => {
|
||||
const pid = li.dataset.id;
|
||||
const code = state.pickerForCode;
|
||||
if (pid && code) {
|
||||
closeProjectPicker();
|
||||
void startDraft(code, pid);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
// Boot
|
||||
// ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
function wireToolbar(): void {
|
||||
const search = document.getElementById("submissions-new-search") as HTMLInputElement | null;
|
||||
if (search) {
|
||||
search.addEventListener("input", () => {
|
||||
state.searchTerm = search.value;
|
||||
renderTable();
|
||||
});
|
||||
}
|
||||
|
||||
const closeBtn = document.getElementById("submissions-new-project-modal-close");
|
||||
if (closeBtn) closeBtn.addEventListener("click", () => closeProjectPicker());
|
||||
|
||||
const modal = document.getElementById("submissions-new-project-modal");
|
||||
if (modal) {
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
const pickerSearch = document.getElementById("submissions-new-project-search") as HTMLInputElement | null;
|
||||
if (pickerSearch) {
|
||||
pickerSearch.addEventListener("input", () => renderPickerList());
|
||||
}
|
||||
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && state.pickerForCode) closeProjectPicker();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
initI18n();
|
||||
initSidebar();
|
||||
wireToolbar();
|
||||
void loadCatalog();
|
||||
});
|
||||
@@ -1,10 +1,13 @@
|
||||
// Submissions panel — fetches the project's submission catalog and
|
||||
// renders one row per filing-type rule, with a [Generieren] action
|
||||
// when a .docx template resolves server-side.
|
||||
// Submissions panel — fetches the full submission catalog across every
|
||||
// proceeding and renders it grouped by proceeding, with the project's
|
||||
// own proceeding pinned at the top.
|
||||
//
|
||||
// t-paliad-215 Slice 1. Loaded lazily by the projects-detail tab
|
||||
// switcher so projects without the Schriftsätze tab open don't pay
|
||||
// for the per-row template-availability probes.
|
||||
// t-paliad-215 Slice 1 introduced the per-project list. t-paliad-242
|
||||
// broadened it to the catalog: from any project a lawyer can pick a
|
||||
// Statement of Defence under UPC.INF.CFI, a Klageerwiderung under
|
||||
// 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.
|
||||
|
||||
function escapeHtml(s: string): string {
|
||||
return s
|
||||
@@ -23,11 +26,15 @@ interface SubmissionEntry {
|
||||
primary_party?: string;
|
||||
legal_source?: string;
|
||||
has_template: boolean;
|
||||
proceeding_code: string;
|
||||
proceeding_name: string;
|
||||
proceeding_name_en: string;
|
||||
}
|
||||
|
||||
interface SubmissionListResponse {
|
||||
project_id: string;
|
||||
proceeding_type_id?: number;
|
||||
project_proceeding_code?: string;
|
||||
entries: SubmissionEntry[];
|
||||
}
|
||||
|
||||
@@ -74,13 +81,13 @@ function render(data: SubmissionListResponse): void {
|
||||
const body = document.getElementById("project-submissions-body");
|
||||
if (!empty || !noProc || !wrap || !body) return;
|
||||
|
||||
if (data.proceeding_type_id == null || data.proceeding_type_id === 0) {
|
||||
noProc.style.display = "";
|
||||
empty.style.display = "none";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
noProc.style.display = "none";
|
||||
// t-paliad-242: the catalog is shown to every project regardless of
|
||||
// whether a proceeding is bound — the no-proceeding hint stays as a
|
||||
// soft nudge above the table, but no longer hides the catalog.
|
||||
noProc.style.display = data.proceeding_type_id == null || data.proceeding_type_id === 0
|
||||
? ""
|
||||
: "none";
|
||||
|
||||
if (data.entries.length === 0) {
|
||||
empty.style.display = "";
|
||||
wrap.style.display = "none";
|
||||
@@ -90,29 +97,56 @@ function render(data: SubmissionListResponse): void {
|
||||
wrap.style.display = "";
|
||||
|
||||
const isEN = document.documentElement.lang === "en";
|
||||
body.innerHTML = data.entries.map((entry) => {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const action = entry.has_template
|
||||
? `<button type="button" class="btn-primary btn-cta-lime btn-small submission-generate-btn"
|
||||
data-code="${escapeHtml(entry.submission_code)}"
|
||||
data-project="${escapeHtml(data.project_id)}"
|
||||
data-i18n="projects.detail.submissions.action.generate">${isEN ? "Generate" : "Generieren"}</button>`
|
||||
: `<span class="submission-no-template" data-i18n="projects.detail.submissions.action.no_template">${isEN ? "No template" : "Keine Vorlage"}</span>`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}).join("");
|
||||
|
||||
// Wire button clicks. One click handler per render to avoid stale
|
||||
// closures from the previous render's data.
|
||||
// Group entries by proceeding_code. Build a stable group order:
|
||||
// project's own proceeding first (when present), then alphabetical
|
||||
// by proceeding_code for the rest.
|
||||
const groups = new Map<string, { name: string; entries: SubmissionEntry[] }>();
|
||||
for (const entry of data.entries) {
|
||||
const key = entry.proceeding_code || "";
|
||||
const groupName = isEN && entry.proceeding_name_en
|
||||
? entry.proceeding_name_en
|
||||
: entry.proceeding_name;
|
||||
const bucket = groups.get(key);
|
||||
if (bucket) {
|
||||
bucket.entries.push(entry);
|
||||
} else {
|
||||
groups.set(key, { name: groupName, entries: [entry] });
|
||||
}
|
||||
}
|
||||
|
||||
const ownCode = data.project_proceeding_code ?? "";
|
||||
const orderedCodes: string[] = [];
|
||||
if (ownCode && groups.has(ownCode)) orderedCodes.push(ownCode);
|
||||
for (const code of Array.from(groups.keys()).sort()) {
|
||||
if (code !== ownCode) orderedCodes.push(code);
|
||||
}
|
||||
|
||||
const ownSuffix = isEN ? " (this project)" : " (dieses Projekt)";
|
||||
const colspan = 4;
|
||||
|
||||
const html: string[] = [];
|
||||
for (const code of orderedCodes) {
|
||||
const group = groups.get(code);
|
||||
if (!group) continue;
|
||||
const isOwn = code === ownCode;
|
||||
const label = group.name + (isOwn ? ownSuffix : "");
|
||||
const headerClass = isOwn
|
||||
? "entity-table-group-header entity-table-group-header--own"
|
||||
: "entity-table-group-header";
|
||||
html.push(`<tr class="${headerClass}">`
|
||||
+ `<th colspan="${colspan}" scope="colgroup">`
|
||||
+ `<span class="entity-table-group-header__name">${escapeHtml(label)}</span>`
|
||||
+ ` <span class="entity-table-group-header__code">${escapeHtml(code)}</span>`
|
||||
+ `</th></tr>`);
|
||||
for (const entry of group.entries) {
|
||||
html.push(renderRow(entry, data.project_id, isEN));
|
||||
}
|
||||
}
|
||||
body.innerHTML = html.join("");
|
||||
|
||||
// Wire button clicks. One handler per 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();
|
||||
@@ -122,6 +156,33 @@ function render(data: SubmissionListResponse): void {
|
||||
});
|
||||
}
|
||||
|
||||
function renderRow(entry: SubmissionEntry, projectID: string, isEN: boolean): string {
|
||||
const name = isEN && entry.name_en ? entry.name_en : entry.name;
|
||||
const party = formatParty(entry.primary_party, isEN);
|
||||
const source = entry.legal_source ?? "";
|
||||
const draftHref = `/projects/${encodeURIComponent(projectID)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
|
||||
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"
|
||||
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}`;
|
||||
return `<tr class="submission-row">
|
||||
<td>
|
||||
<span class="submission-name">${escapeHtml(name)}</span>
|
||||
<span class="submission-code">${escapeHtml(entry.submission_code)}</span>${templateBadge}
|
||||
</td>
|
||||
<td>${escapeHtml(party)}</td>
|
||||
<td>${escapeHtml(source)}</td>
|
||||
<td class="submission-action-cell">${action}</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderError(): void {
|
||||
const empty = document.getElementById("project-submissions-empty");
|
||||
const noProc = document.getElementById("project-submissions-no-proceeding");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { initI18n, onLangChange, t, tDyn } from "./i18n";
|
||||
import { initSidebar } from "./sidebar";
|
||||
import { openBroadcastModal, firstName, type BroadcastRecipient } from "./broadcast";
|
||||
import { openBroadcastModal, firstName, buildMailtoHref, type BroadcastRecipient } from "./broadcast";
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
@@ -341,28 +341,64 @@ function buildProjectFilter() {
|
||||
function buildBroadcastButton() {
|
||||
const wrap = document.getElementById("team-broadcast-wrap");
|
||||
if (!wrap) return;
|
||||
if (!canBroadcast()) {
|
||||
// Wait for /api/me so the affordance never flickers between admin (form)
|
||||
// and non-admin (mailto) on initial paint. canBroadcast() already returns
|
||||
// false when me is null but we'd briefly render the mailto anchor before
|
||||
// the admin form, which is visually jarring.
|
||||
if (!me) {
|
||||
wrap.innerHTML = "";
|
||||
wrap.style.display = "none";
|
||||
return;
|
||||
}
|
||||
wrap.style.display = "";
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${esc(t("team.broadcast.button") || "E-Mail an Auswahl")} <span class="team-broadcast-count" id="team-broadcast-count">0</span>
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
const label = esc(t("team.broadcast.button") || "E-Mail an Auswahl");
|
||||
const counter = `<span class="team-broadcast-count" id="team-broadcast-count">0</span>`;
|
||||
if (canBroadcast()) {
|
||||
// Admin path (global_admin or project-lead-of-selected): opens the
|
||||
// in-app compose modal that POSTs to /api/team/broadcast.
|
||||
wrap.innerHTML = `
|
||||
<button type="button" class="btn btn-primary" id="team-broadcast-btn">
|
||||
${label} ${counter}
|
||||
</button>
|
||||
`;
|
||||
document.getElementById("team-broadcast-btn")?.addEventListener("click", () => onBroadcastClick());
|
||||
} else {
|
||||
// Non-admin path (t-paliad-244): native mailto: anchor pre-filled with
|
||||
// the current filter set. href is refreshed in updateBroadcastButton()
|
||||
// whenever filters change so the link always reflects what's visible.
|
||||
wrap.innerHTML = `
|
||||
<a class="btn btn-primary" id="team-broadcast-btn" href="mailto:">
|
||||
${label} ${counter}
|
||||
</a>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
function updateBroadcastButton() {
|
||||
buildBroadcastButton();
|
||||
const recipients = displayedRecipients();
|
||||
const countEl = document.getElementById("team-broadcast-count");
|
||||
if (countEl) {
|
||||
const n = displayedRecipients().length;
|
||||
countEl.textContent = String(n);
|
||||
const btn = document.getElementById("team-broadcast-btn") as HTMLButtonElement | null;
|
||||
if (btn) btn.disabled = n === 0;
|
||||
if (countEl) countEl.textContent = String(recipients.length);
|
||||
const btn = document.getElementById("team-broadcast-btn");
|
||||
if (!btn) return;
|
||||
if (btn.tagName === "BUTTON") {
|
||||
(btn as HTMLButtonElement).disabled = recipients.length === 0;
|
||||
} else {
|
||||
// Anchor (non-admin): regenerate the mailto: href against the current
|
||||
// visible recipients, and disable the affordance when empty so a click
|
||||
// doesn't open an empty mail composer.
|
||||
const a = btn as HTMLAnchorElement;
|
||||
if (recipients.length === 0) {
|
||||
a.setAttribute("href", "mailto:");
|
||||
a.setAttribute("aria-disabled", "true");
|
||||
a.style.pointerEvents = "none";
|
||||
a.style.opacity = "0.5";
|
||||
} else {
|
||||
a.setAttribute("href", buildMailtoHref(recipients));
|
||||
a.removeAttribute("aria-disabled");
|
||||
a.style.pointerEvents = "";
|
||||
a.style.opacity = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -673,14 +709,21 @@ function renderSelectionFooter(): void {
|
||||
"{n}",
|
||||
String(n),
|
||||
);
|
||||
const sendLabel = esc(t("team.selection.send") || "E-Mail an Auswahl");
|
||||
// t-paliad-244: mirror buildBroadcastButton() so the bottom send button
|
||||
// behaves the same as the filter-bar one. Admin (canBroadcast) opens the
|
||||
// compose modal; non-admin gets a native mailto: anchor pre-filled with
|
||||
// the explicit selection.
|
||||
const adminPath = canBroadcast();
|
||||
const sendAction = adminPath
|
||||
? `<button type="button" class="btn-primary" id="team-selection-send">${sendLabel}</button>`
|
||||
: `<a class="btn-primary" id="team-selection-send" href="${buildMailtoHref(selectedRecipients())}">${sendLabel}</a>`;
|
||||
footer.innerHTML = `
|
||||
<span class="team-selection-count">${esc(countLabel)}</span>
|
||||
<button type="button" class="btn-secondary btn-small" id="team-selection-clear">
|
||||
${esc(t("team.selection.clear") || "Auswahl aufheben")}
|
||||
</button>
|
||||
<button type="button" class="btn-primary" id="team-selection-send">
|
||||
${esc(t("team.selection.send") || "E-Mail an Auswahl")}
|
||||
</button>
|
||||
${sendAction}
|
||||
`;
|
||||
footer.style.display = "";
|
||||
document.body.classList.add("team-has-selection");
|
||||
@@ -691,9 +734,12 @@ function renderSelectionFooter(): void {
|
||||
syncMasterCheckbox();
|
||||
renderSelectionFooter();
|
||||
});
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
if (adminPath) {
|
||||
document.getElementById("team-selection-send")?.addEventListener("click", () => {
|
||||
onBroadcastFromSelection();
|
||||
});
|
||||
}
|
||||
// Anchor path has no click handler — native href open is the action.
|
||||
}
|
||||
|
||||
// selectedRecipients maps the explicit selection Set into the
|
||||
|
||||
@@ -13,6 +13,10 @@ const ICON_BOOK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" st
|
||||
// at a glance.
|
||||
const ICON_BOOK_OPEN = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h7a3 3 0 0 1 3 3v13a2 2 0 0 0-2-2H2z"/><path d="M22 4h-7a3 3 0 0 0-3 3v13a2 2 0 0 1 2-2h8z"/></svg>';
|
||||
const ICON_TABLE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/></svg>';
|
||||
// Document-with-lines icon for /submissions (t-paliad-240) — distinct
|
||||
// from ICON_BOOK / ICON_BOOK_OPEN / ICON_NEWSPAPER so the Schriftsätze
|
||||
// affordance reads as "a draft document" at a glance.
|
||||
const ICON_FILE_TEXT = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="8" y1="13" x2="16" y2="13"/><line x1="8" y1="17" x2="16" y2="17"/><line x1="8" y1="9" x2="10" y2="9"/></svg>';
|
||||
const ICON_CHECK = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/></svg>';
|
||||
const ICON_GLOBE = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10A15.3 15.3 0 0 1 12 2z"/></svg>';
|
||||
const ICON_BUILDING = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 21h18"/><path d="M5 21V5a2 2 0 0 1 2-2h7a2 2 0 0 1 2 2v16"/><path d="M16 9h3a2 2 0 0 1 2 2v10"/><path d="M9 7h2"/><path d="M9 11h2"/><path d="M9 15h2"/></svg>';
|
||||
@@ -175,6 +179,7 @@ export function Sidebar({ currentPath, authenticated = true }: SidebarProps): st
|
||||
{group("nav.group.werkzeuge", "Werkzeuge",
|
||||
navItem("/tools/fristenrechner", ICON_CLOCK, "nav.fristenrechner", "Fristenrechner", currentPath) +
|
||||
navItem("/tools/verfahrensablauf", ICON_BOOK_OPEN, "nav.verfahrensablauf", "Verfahrensablauf", currentPath) +
|
||||
navItem("/submissions", ICON_FILE_TEXT, "nav.submissions", "Schriftsätze", currentPath) +
|
||||
navItem("/tools/kostenrechner", ICON_CALC, "nav.kostenrechner", "Kostenrechner", currentPath) +
|
||||
navItem("/tools/gebuehrentabellen", ICON_TABLE, "nav.gebuehrentabellen", "Gebührentabellen", currentPath) +
|
||||
navItem("/checklists", ICON_CHECK, "nav.checklisten", "Checklisten", currentPath) +
|
||||
|
||||
@@ -1904,6 +1904,7 @@ export type I18nKey =
|
||||
| "nav.paliadin"
|
||||
| "nav.projekte"
|
||||
| "nav.soon.tooltip"
|
||||
| "nav.submissions"
|
||||
| "nav.team"
|
||||
| "nav.termine"
|
||||
| "nav.user_views.new"
|
||||
@@ -2137,6 +2138,11 @@ export type I18nKey =
|
||||
| "projects.detail.appointments.form.cancel"
|
||||
| "projects.detail.appointments.form.submit"
|
||||
| "projects.detail.back"
|
||||
| "projects.detail.checklisten.add"
|
||||
| "projects.detail.checklisten.add.created"
|
||||
| "projects.detail.checklisten.add.empty_pick"
|
||||
| "projects.detail.checklisten.add.error"
|
||||
| "projects.detail.checklisten.add.search"
|
||||
| "projects.detail.checklisten.col.created"
|
||||
| "projects.detail.checklisten.col.name"
|
||||
| "projects.detail.checklisten.col.progress"
|
||||
@@ -2182,6 +2188,11 @@ export type I18nKey =
|
||||
| "projects.detail.parteien.role.defendant"
|
||||
| "projects.detail.parteien.role.thirdparty"
|
||||
| "projects.detail.save"
|
||||
| "projects.detail.settings.archive.cta"
|
||||
| "projects.detail.settings.archive.description"
|
||||
| "projects.detail.settings.archive.heading"
|
||||
| "projects.detail.settings.export.description"
|
||||
| "projects.detail.settings.export.heading"
|
||||
| "projects.detail.smarttimeline.add.cancel"
|
||||
| "projects.detail.smarttimeline.add.choice.amend"
|
||||
| "projects.detail.smarttimeline.add.choice.appointment"
|
||||
@@ -2255,6 +2266,7 @@ export type I18nKey =
|
||||
| "projects.detail.smarttimeline.track.only.counterclaim"
|
||||
| "projects.detail.smarttimeline.track.only.parent"
|
||||
| "projects.detail.smarttimeline.track.only.parent_context"
|
||||
| "projects.detail.submissions.action.edit"
|
||||
| "projects.detail.submissions.action.generate"
|
||||
| "projects.detail.submissions.action.no_template"
|
||||
| "projects.detail.submissions.col.action"
|
||||
@@ -2270,6 +2282,7 @@ export type I18nKey =
|
||||
| "projects.detail.tab.kinder"
|
||||
| "projects.detail.tab.notizen"
|
||||
| "projects.detail.tab.parteien"
|
||||
| "projects.detail.tab.settings"
|
||||
| "projects.detail.tab.submissions"
|
||||
| "projects.detail.tab.team"
|
||||
| "projects.detail.tab.termine"
|
||||
@@ -2490,6 +2503,45 @@ export type I18nKey =
|
||||
| "search.no_results"
|
||||
| "search.placeholder"
|
||||
| "sidebar.resize.title"
|
||||
| "submissions.draft.action.delete"
|
||||
| "submissions.draft.action.export"
|
||||
| "submissions.draft.action.new"
|
||||
| "submissions.draft.back"
|
||||
| "submissions.draft.loading"
|
||||
| "submissions.draft.name.placeholder"
|
||||
| "submissions.draft.notfound"
|
||||
| "submissions.draft.preview.hint"
|
||||
| "submissions.draft.preview.title"
|
||||
| "submissions.draft.switcher.label"
|
||||
| "submissions.draft.title"
|
||||
| "submissions.index.action.new"
|
||||
| "submissions.index.col.draft"
|
||||
| "submissions.index.col.project"
|
||||
| "submissions.index.col.submission"
|
||||
| "submissions.index.col.updated"
|
||||
| "submissions.index.empty"
|
||||
| "submissions.index.empty.cta"
|
||||
| "submissions.index.error"
|
||||
| "submissions.index.heading"
|
||||
| "submissions.index.loading"
|
||||
| "submissions.index.subtitle"
|
||||
| "submissions.index.title"
|
||||
| "submissions.new.back"
|
||||
| "submissions.new.col.actions"
|
||||
| "submissions.new.col.name"
|
||||
| "submissions.new.col.party"
|
||||
| "submissions.new.col.source"
|
||||
| "submissions.new.empty.filtered"
|
||||
| "submissions.new.error"
|
||||
| "submissions.new.heading"
|
||||
| "submissions.new.loading"
|
||||
| "submissions.new.picker.empty"
|
||||
| "submissions.new.picker.loading"
|
||||
| "submissions.new.picker.placeholder"
|
||||
| "submissions.new.picker.title"
|
||||
| "submissions.new.search.placeholder"
|
||||
| "submissions.new.subtitle"
|
||||
| "submissions.new.title"
|
||||
| "team.broadcast.body"
|
||||
| "team.broadcast.body_placeholder"
|
||||
| "team.broadcast.button"
|
||||
|
||||
@@ -89,20 +89,9 @@ export function renderProjectsDetail(): string {
|
||||
<a className="entity-tab" data-tab="notes" href="#" data-i18n="projects.detail.tab.notizen">Notizen</a>
|
||||
<a className="entity-tab" data-tab="checklists" href="#" data-i18n="projects.detail.tab.checklisten">Checklisten</a>
|
||||
<a className="entity-tab" data-tab="submissions" href="#" data-i18n="projects.detail.tab.submissions">Schriftsätze</a>
|
||||
{/* t-paliad-214 Slice 2 — project-subtree export button.
|
||||
Sits at the end of the tab nav. Hidden by default; the
|
||||
client unhides it after /api/me confirms the caller can
|
||||
extract (responsibility ∈ {lead, member} OR global_admin). */}
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="entity-tab entity-tab-action"
|
||||
style="display:none"
|
||||
title=""
|
||||
data-i18n-title="projects.detail.export.tooltip"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
{/* Verwaltung — rare admin actions (export, archive). Sits
|
||||
last in the tab list per t-paliad-245. */}
|
||||
<a className="entity-tab" data-tab="settings" href="#" data-i18n="projects.detail.tab.settings">Verwaltung</a>
|
||||
</nav>
|
||||
|
||||
{/* History (Verlauf) — t-paliad-171 SmartTimeline Slice 1.
|
||||
@@ -596,6 +585,12 @@ export function renderProjectsDetail(): string {
|
||||
|
||||
{/* Checklists (Checklisten) */}
|
||||
<section className="entity-tab-panel" id="tab-checklists" style="display:none">
|
||||
<div className="party-controls">
|
||||
<button id="checklist-add-btn" className="btn-primary btn-cta-lime btn-small" type="button" data-i18n="projects.detail.checklisten.add">
|
||||
Checkliste hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
<p className="form-msg" id="project-checklists-msg" />
|
||||
<p id="project-checklists-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.checklisten.empty">
|
||||
Für dieses Projekt sind noch keine Checklisten-Instanzen erfasst.
|
||||
</p>
|
||||
@@ -613,23 +608,24 @@ export function renderProjectsDetail(): string {
|
||||
</table>
|
||||
</div>
|
||||
<p className="tool-subtitle checklists-hint">
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Instanzen werden auf der Vorlagen-Seite unter </span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.prefix">Vorlagen werden auf der </span>
|
||||
<a href="/checklists" data-i18n="projects.detail.checklisten.hint.link">Checklisten</a>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix"> angelegt.</span>
|
||||
<span data-i18n="projects.detail.checklisten.hint.suffix">-Seite angelegt und bearbeitet.</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Submissions (Schriftsätze) — t-paliad-215 Slice 1.
|
||||
Lists the project's filing-type rules with a per-row
|
||||
[Generieren] button when a .docx template resolves
|
||||
in the registry's fallback chain (firm → base/code →
|
||||
base/family → skeleton). Empty for projects with no
|
||||
proceeding bound; otherwise enumerates every active
|
||||
filing rule for the proceeding. */}
|
||||
{/* Submissions (Schriftsätze) — t-paliad-242 broadened
|
||||
the original t-paliad-215 list to the full
|
||||
cross-proceeding catalog. The table shows every
|
||||
active filing rule across every proceeding, grouped
|
||||
by proceeding; the project's own proceeding is
|
||||
pinned to the top. The no-proceeding hint stays as
|
||||
a soft nudge above the catalog (the table renders
|
||||
regardless). */}
|
||||
<section className="entity-tab-panel" id="tab-submissions" style="display:none">
|
||||
<div id="project-submissions-no-proceeding" className="entity-events-empty" style="display:none">
|
||||
<p data-i18n="projects.detail.submissions.empty.no_proceeding">
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt. Bitte im Projekt bearbeiten.
|
||||
Für dieses Projekt ist noch kein Verfahrenstyp gesetzt — der Katalog unten zeigt trotzdem alle Vorlagen.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
@@ -640,7 +636,7 @@ export function renderProjectsDetail(): string {
|
||||
</button>
|
||||
</div>
|
||||
<p id="project-submissions-empty" className="entity-events-empty" style="display:none" data-i18n="projects.detail.submissions.empty">
|
||||
Für dieses Verfahren sind keine Schriftsätze hinterlegt.
|
||||
Es sind aktuell keine Schriftsatzvorlagen hinterlegt.
|
||||
</p>
|
||||
<div className="entity-table-wrap" id="project-submissions-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
@@ -659,6 +655,39 @@ export function renderProjectsDetail(): string {
|
||||
Schriftsätze werden direkt aus dem Projekt heraus als .docx generiert. Anpassen, drucken, einreichen.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
{/* Verwaltung — rare admin actions (export, archive). Each
|
||||
sub-section hides itself if the caller is not entitled
|
||||
(export: §4 gate; archive: global_admin). */}
|
||||
<section className="entity-tab-panel" id="tab-settings" style="display:none">
|
||||
<div className="settings-section" id="project-settings-export" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.export.heading">Daten exportieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.export.description">
|
||||
Lade alle Daten dieses Projekts (inkl. Unter-Projekten) als Excel + JSON + CSV-Archiv herunter.
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-export-btn"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.export.button">
|
||||
Daten exportieren
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="settings-section" id="project-settings-archive" style="display:none">
|
||||
<h3 className="entity-section-heading" data-i18n="projects.detail.settings.archive.heading">Projekt archivieren</h3>
|
||||
<p className="tool-subtitle" data-i18n="projects.detail.settings.archive.description">
|
||||
Archivieren erfolgt aus dem Bearbeiten-Dialog (Gefahrenbereich).
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
id="project-settings-archive-link"
|
||||
className="btn-secondary"
|
||||
data-i18n="projects.detail.settings.archive.cta">
|
||||
Bearbeiten öffnen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Full edit modal — same form as /projects/new, pre-filled. */}
|
||||
@@ -699,6 +728,27 @@ export function renderProjectsDetail(): string {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add-checklist-instance modal (t-paliad-239) — picks a
|
||||
template and POSTs to /api/checklists/{slug}/instances
|
||||
with the current project_id; the row appears in the
|
||||
Checklists tab list on success. */}
|
||||
<div className="modal-overlay" id="add-checklist-modal" style="display:none">
|
||||
<div className="modal-card modal-card-wide">
|
||||
<div className="modal-header">
|
||||
<h2 data-i18n="projects.detail.checklisten.add">Checkliste hinzufügen</h2>
|
||||
<button className="modal-close" id="add-checklist-close" type="button">×</button>
|
||||
</div>
|
||||
<div className="form-field">
|
||||
<input type="text" id="add-checklist-search" autocomplete="off" data-i18n-placeholder="projects.detail.checklisten.add.search" placeholder="Vorlage suchen…" />
|
||||
</div>
|
||||
<div className="add-checklist-list" id="add-checklist-list" />
|
||||
<p className="entity-events-empty" id="add-checklist-empty" style="display:none" data-i18n="projects.detail.checklisten.add.empty_pick">
|
||||
Keine passenden Vorlagen gefunden.
|
||||
</p>
|
||||
<p className="form-msg" id="add-checklist-msg" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete confirmation modal */}
|
||||
<div className="modal-overlay" id="delete-modal" style="display:none">
|
||||
<div className="modal-card">
|
||||
|
||||
@@ -5340,6 +5340,63 @@ dialog.modal::backdrop {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* --- Add-checklist template picker (t-paliad-239) ------------------
|
||||
Modal shown from the project Checklists tab. Vertical scrollable
|
||||
list of clickable template rows; each row is a full-width <button>
|
||||
with title + regime chip + optional description. */
|
||||
.add-checklist-list {
|
||||
margin-top: 0.5rem;
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.4rem;
|
||||
padding-right: 0.25rem;
|
||||
}
|
||||
|
||||
.add-checklist-row {
|
||||
display: block;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 0.65rem 0.85rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.add-checklist-row:hover:not(:disabled) {
|
||||
background: rgb(var(--hlc-lime-rgb) / 0.06);
|
||||
border-color: rgb(var(--hlc-lime-rgb) / 0.45);
|
||||
}
|
||||
|
||||
.add-checklist-row:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: progress;
|
||||
}
|
||||
|
||||
.add-checklist-row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.add-checklist-row-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.add-checklist-row-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.82rem;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* --- Checklist detail --- */
|
||||
|
||||
.checklist-back {
|
||||
@@ -5529,10 +5586,370 @@ dialog.modal::backdrop {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* t-paliad-242 — grouped Schriftsätze catalog. The Schriftsätze tab
|
||||
shows filing rules from every proceeding the platform knows about,
|
||||
grouped by proceeding (DE LG vs UPC CFI vs EPO Opposition etc.).
|
||||
.entity-table-group-header is a <tr> spanning all columns that
|
||||
labels each block; the --own modifier picks out the project's own
|
||||
proceeding with a lime border so the lawyer sees their primary
|
||||
context at a glance. */
|
||||
.entity-table-group-header th {
|
||||
padding: 0.65rem 1rem;
|
||||
text-align: left;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--color-text-muted);
|
||||
background: var(--color-bg-subtle);
|
||||
border-top: 1px solid var(--color-border);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.entity-table tbody tr.entity-table-group-header,
|
||||
.entity-table--readonly tbody tr.entity-table-group-header {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.entity-table tbody tr.entity-table-group-header:hover,
|
||||
.entity-table--readonly tbody tr.entity-table-group-header:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.entity-table-group-header--own th {
|
||||
background: var(--color-bg-lime-tint);
|
||||
color: var(--color-text);
|
||||
border-left: 3px solid var(--color-accent-fg);
|
||||
}
|
||||
|
||||
.entity-table-group-header__name {
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
font-size: 0.95rem;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.entity-table-group-header__code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
color: var(--color-text-muted);
|
||||
margin-left: 0.5rem;
|
||||
font-size: 0.78rem;
|
||||
text-transform: none;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
|
||||
.submission-template-badge {
|
||||
display: inline-block;
|
||||
margin-left: 0.4rem;
|
||||
padding: 0.05rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
background: var(--color-bg-subtle);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.submissions-hint {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* t-paliad-238 — dedicated draft editor page. */
|
||||
.submission-draft-page {
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.submission-draft-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submission-draft-header-text h1 {
|
||||
margin: 0 0 0.25rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-header-actions {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.submission-draft-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 360px) 1fr;
|
||||
gap: 1.5rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.submission-draft-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.submission-draft-sidebar {
|
||||
position: sticky;
|
||||
top: 1rem;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
background: var(--color-surface, #fff);
|
||||
max-height: calc(100vh - 2rem);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.submission-draft-switcher {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submission-draft-switcher select {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.submission-draft-name-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-name-row input {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.submission-draft-savestatus {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
min-height: 1.2em;
|
||||
margin: 0.25rem 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-savestatus--error {
|
||||
color: var(--color-danger, #c00);
|
||||
}
|
||||
|
||||
.submission-draft-variables {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-group {
|
||||
border-top: 1px solid var(--color-border);
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-group-title {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 0.95em;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-var-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.2rem;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-label {
|
||||
font-size: 0.9em;
|
||||
color: var(--color-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submission-draft-var-input {
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.submission-draft-var-hint {
|
||||
font-size: 0.8em;
|
||||
color: var(--color-text-muted);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-var-hint code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.95em;
|
||||
}
|
||||
|
||||
.submission-draft-var-marker {
|
||||
color: var(--color-warning, #b27800);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submission-draft-var-was {
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-var-reset {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.submission-draft-preview-wrap {
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 8px;
|
||||
background: var(--color-surface, #fff);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submission-draft-preview-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-surface-alt, #fafafa);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.submission-draft-preview-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1em;
|
||||
}
|
||||
|
||||
.submission-draft-preview-hint {
|
||||
font-size: 0.85em;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.submission-draft-preview {
|
||||
padding: 1.5rem 2rem;
|
||||
line-height: 1.6;
|
||||
font-family: var(--font-serif, Georgia, serif);
|
||||
min-height: 60vh;
|
||||
}
|
||||
|
||||
.submission-draft-preview p {
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.submission-draft-preview .preview-error {
|
||||
color: var(--color-danger, #c00);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submission-edit-btn {
|
||||
margin-right: 0.4rem;
|
||||
}
|
||||
|
||||
/* t-paliad-243 — global Schriftsätze picker (/submissions/new) +
|
||||
project-less editor banner + assign-project modal styling. */
|
||||
|
||||
.submissions-index-headline {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.submissions-index-no-project {
|
||||
color: var(--color-text-muted);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.submissions-new-toolbar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1.5rem;
|
||||
}
|
||||
|
||||
.submissions-new-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.submissions-new-chip {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.85rem;
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.submissions-new-chip:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
}
|
||||
|
||||
.submissions-new-chip--active {
|
||||
background: var(--color-accent, #c6f41c);
|
||||
border-color: var(--color-accent, #c6f41c);
|
||||
color: #000;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.submissions-new-chip-code {
|
||||
font-family: var(--font-mono, monospace);
|
||||
font-size: 0.78rem;
|
||||
opacity: 0.7;
|
||||
margin-left: 0.35rem;
|
||||
}
|
||||
|
||||
.submissions-new-project-list {
|
||||
list-style: none;
|
||||
margin: 0.75rem 0 0;
|
||||
padding: 0;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.submissions-new-project-item {
|
||||
padding: 0.6rem 0.85rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.submissions-new-project-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.submissions-new-project-item:hover {
|
||||
background: var(--color-surface-alt, #f4f4f4);
|
||||
}
|
||||
|
||||
.submissions-new-project-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.submission-draft-noproject-banner {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
margin: 0 0 1.25rem;
|
||||
background: var(--color-surface-alt, #f7f7f0);
|
||||
border: 1px solid var(--color-border);
|
||||
border-left: 4px solid var(--color-accent, #c6f41c);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.submission-draft-noproject-banner-msg {
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
font-size: 0.92rem;
|
||||
}
|
||||
|
||||
.checklist-instance-actions {
|
||||
display: flex;
|
||||
gap: 0.35rem;
|
||||
@@ -6128,12 +6545,18 @@ dialog.modal::backdrop {
|
||||
|
||||
/* Each filter is a label-above-control cell so the caption sits on top of
|
||||
its select / button. The whole filter-row stays a horizontal flex-wrap
|
||||
of these column-cells (t-paliad-117). */
|
||||
of these column-cells (t-paliad-117).
|
||||
|
||||
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
|
||||
container and prevents a native <select> with long option text from
|
||||
blowing the cell wider than the viewport (t-paliad-255). */
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
@@ -6147,6 +6570,10 @@ dialog.modal::backdrop {
|
||||
.filter-group .entity-select { width: 100%; }
|
||||
}
|
||||
|
||||
/* max-width: 100% caps the intrinsic width of a native <select> at its
|
||||
parent — without it, browsers size the select to the longest <option>
|
||||
text and a very long project title overflows the viewport on tablet
|
||||
widths above the 480px breakpoint (t-paliad-255). */
|
||||
.entity-select {
|
||||
padding: 0.4rem 0.75rem;
|
||||
font-size: 0.85rem;
|
||||
@@ -6155,6 +6582,8 @@ dialog.modal::backdrop {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
cursor: pointer;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entity-select:focus {
|
||||
@@ -6874,6 +7303,20 @@ dialog.modal::backdrop {
|
||||
padding: 0.5rem 0 2rem;
|
||||
}
|
||||
|
||||
/* Verwaltung tab — rare admin actions (export, archive) live here as
|
||||
stacked sections. No accent, no oversized buttons (t-paliad-245). */
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-section .tool-subtitle {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.entity-events {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
|
||||
143
frontend/src/submission-draft.tsx
Normal file
143
frontend/src/submission-draft.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
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-238 Slice A — dedicated Submissions/Schriftsätze editor page.
|
||||
//
|
||||
// Lawyer picks (or creates) a draft for one (project, submission_code),
|
||||
// edits placeholder variables in a sticky sidebar, sees a read-only
|
||||
// HTML preview of the merged document, and exports the result as
|
||||
// .docx. Drafts persist server-side per paliad.submission_drafts.
|
||||
//
|
||||
// Pure shell: client/submission-draft.ts hydrates draft list + variable
|
||||
// form + preview pane after page load. The same dist/submission-draft.html
|
||||
// serves every (project_id, submission_code, [draft_id]) URL.
|
||||
//
|
||||
// Design ref: docs/design-submission-page-2026-05-22.md §6.
|
||||
|
||||
export function renderSubmissionDraft(): 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" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.draft.title">Schriftsatz bearbeiten — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar page-submission-draft">
|
||||
<Sidebar currentPath="/projects" />
|
||||
<BottomNav currentPath="/projects" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submission-draft-page">
|
||||
<div className="container">
|
||||
<a
|
||||
id="submission-draft-back-link"
|
||||
href="/projects"
|
||||
className="back-link"
|
||||
data-i18n="submissions.draft.back">
|
||||
← Zurück zum Projekt
|
||||
</a>
|
||||
|
||||
<div id="submission-draft-loading" className="entity-loading">
|
||||
<p data-i18n="submissions.draft.loading">Lädt…</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-notfound" className="entity-empty" style="display:none">
|
||||
<p data-i18n="submissions.draft.notfound">
|
||||
Schriftsatz nicht gefunden oder keine Berechtigung.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div id="submission-draft-error" className="entity-empty" style="display:none" />
|
||||
|
||||
<div id="submission-draft-body" style="display:none">
|
||||
<header className="submission-draft-header">
|
||||
<div className="submission-draft-header-text">
|
||||
<h1 id="submission-draft-title" />
|
||||
<p id="submission-draft-subtitle" className="tool-subtitle" />
|
||||
</div>
|
||||
<div className="submission-draft-header-actions">
|
||||
<button
|
||||
id="submission-draft-export-btn"
|
||||
type="button"
|
||||
className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.draft.action.export">
|
||||
Als .docx exportieren
|
||||
</button>
|
||||
</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>
|
||||
|
||||
<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>
|
||||
|
||||
<p className="submission-draft-savestatus" id="submission-draft-savestatus" />
|
||||
|
||||
<div className="submission-draft-variables" id="submission-draft-variables" />
|
||||
</aside>
|
||||
|
||||
{/* Preview pane — read-only HTML render of the merged
|
||||
document body. Re-renders on autosave round-trip. */}
|
||||
<section className="submission-draft-preview-wrap">
|
||||
<header className="submission-draft-preview-header">
|
||||
<h2 data-i18n="submissions.draft.preview.title">Vorschau</h2>
|
||||
<span
|
||||
className="submission-draft-preview-hint"
|
||||
data-i18n="submissions.draft.preview.hint">
|
||||
Read-only Vorschau — finale Bearbeitung in Word.
|
||||
</span>
|
||||
</header>
|
||||
<div className="submission-draft-preview" id="submission-draft-preview" />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
|
||||
<script src="/assets/submission-draft.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
84
frontend/src/submissions-index.tsx
Normal file
84
frontend/src/submissions-index.tsx
Normal file
@@ -0,0 +1,84 @@
|
||||
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-240 — global Schriftsätze drafts index. Top-level sidebar
|
||||
// entry that lists every draft the caller owns across visible projects.
|
||||
// Per-project editor stays at /projects/{id}/submissions/{code}/draft —
|
||||
// this page only adds a discovery surface and click-through to it.
|
||||
|
||||
export function renderSubmissionsIndex(): 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" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.index.title">Schriftsätze — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page">
|
||||
<div className="container">
|
||||
<div className="tool-header">
|
||||
<div className="submissions-index-headline">
|
||||
<div>
|
||||
<h1 data-i18n="submissions.index.heading">Schriftsätze</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.index.subtitle">
|
||||
Ihre Schriftsatz-Entwürfe über alle sichtbaren Projekte.
|
||||
</p>
|
||||
</div>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.action.new">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-index-loading"
|
||||
data-i18n="submissions.index.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-empty" style="display:none">
|
||||
<p data-i18n="submissions.index.empty">
|
||||
Noch keine Entwürfe. Beginnen Sie mit einem neuen Entwurf — mit oder ohne Projekt.
|
||||
</p>
|
||||
<a href="/submissions/new" className="btn-primary btn-cta-lime"
|
||||
data-i18n="submissions.index.empty.cta">+ Neuer Entwurf</a>
|
||||
</div>
|
||||
|
||||
<div className="entity-empty" id="submissions-index-error" style="display:none">
|
||||
<p data-i18n="submissions.index.error">Schriftsätze konnten nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-index-tablewrap" style="display:none">
|
||||
<table className="entity-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.index.col.project">Projekt</th>
|
||||
<th data-i18n="submissions.index.col.submission">Schriftsatz</th>
|
||||
<th data-i18n="submissions.index.col.draft">Entwurf</th>
|
||||
<th data-i18n="submissions.index.col.updated">Zuletzt geändert</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-index-body" />
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
118
frontend/src/submissions-new.tsx
Normal file
118
frontend/src/submissions-new.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
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-243 — global Schriftsatz picker. Lists the full
|
||||
// cross-proceeding submission catalog (grouped by proceeding,
|
||||
// filterable) and lets the lawyer start a draft with or without
|
||||
// binding a project. Picking "Ohne Projekt" jumps straight to
|
||||
// /submissions/draft/{id}; picking "Mit Projekt verknüpfen" opens an
|
||||
// autocomplete project picker, then redirects to the project-scoped
|
||||
// editor.
|
||||
|
||||
export function renderSubmissionsNew(): 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" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<PWAHead />
|
||||
<title data-i18n="submissions.new.title">Neuer Schriftsatz — Paliad</title>
|
||||
<link rel="stylesheet" href="/assets/global.css" />
|
||||
</head>
|
||||
<body className="has-sidebar">
|
||||
<Sidebar currentPath="/submissions" />
|
||||
<BottomNav currentPath="/submissions" />
|
||||
|
||||
<main>
|
||||
<section className="tool-page submissions-new-page">
|
||||
<div className="container">
|
||||
<a href="/submissions" className="back-link"
|
||||
data-i18n="submissions.new.back">← Zurück zur Übersicht</a>
|
||||
|
||||
<div className="tool-header">
|
||||
<h1 data-i18n="submissions.new.heading">Neuer Schriftsatz</h1>
|
||||
<p className="tool-subtitle" data-i18n="submissions.new.subtitle">
|
||||
Wählen Sie eine Vorlage. Optional verknüpfen Sie den
|
||||
Entwurf mit einem Projekt — sonst füllen Sie alle
|
||||
Variablen manuell.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="submissions-new-toolbar">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.search.placeholder"
|
||||
placeholder="Suche nach Schriftsatz, Code oder Norm…" />
|
||||
<div id="submissions-new-proceeding-chips" className="submissions-new-chips" />
|
||||
</div>
|
||||
|
||||
<p className="entity-events-empty" id="submissions-new-loading"
|
||||
data-i18n="submissions.new.loading">Lädt…</p>
|
||||
|
||||
<div className="entity-empty" id="submissions-new-error" style="display:none">
|
||||
<p data-i18n="submissions.new.error">Katalog konnte nicht geladen werden.</p>
|
||||
</div>
|
||||
|
||||
<div className="entity-table-wrap" id="submissions-new-tablewrap" style="display:none">
|
||||
<table className="entity-table entity-table--readonly">
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-i18n="submissions.new.col.name">Schriftsatz</th>
|
||||
<th data-i18n="submissions.new.col.party">Partei</th>
|
||||
<th data-i18n="submissions.new.col.source">Rechtsgrundlage</th>
|
||||
<th data-i18n="submissions.new.col.actions">Entwurf starten</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="submissions-new-body" />
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<p className="entity-empty" id="submissions-new-empty" style="display:none">
|
||||
<span data-i18n="submissions.new.empty.filtered">
|
||||
Keine passenden Schriftsätze. Filter zurücksetzen.
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Project picker modal — opened by "Mit Projekt verknüpfen". */}
|
||||
<div id="submissions-new-project-modal" className="modal-overlay" style="display:none" role="dialog" aria-modal="true">
|
||||
<div className="modal-card">
|
||||
<header className="modal-header">
|
||||
<h2 data-i18n="submissions.new.picker.title">Projekt wählen</h2>
|
||||
<button type="button" id="submissions-new-project-modal-close"
|
||||
className="modal-close" aria-label="Close">×</button>
|
||||
</header>
|
||||
<div className="modal-body">
|
||||
<input
|
||||
type="search"
|
||||
id="submissions-new-project-search"
|
||||
className="entity-form-input"
|
||||
data-i18n-placeholder="submissions.new.picker.placeholder"
|
||||
placeholder="Projekt suchen (Titel oder Aktenzeichen)…" />
|
||||
<ul id="submissions-new-project-list" className="submissions-new-project-list" />
|
||||
<p id="submissions-new-project-loading" className="entity-events-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.loading">Lädt Projekte…</p>
|
||||
<p id="submissions-new-project-empty" className="entity-empty" style="display:none"
|
||||
data-i18n="submissions.new.picker.empty">Keine sichtbaren Projekte.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<Footer />
|
||||
<PaliadinWidget />
|
||||
<script src="/assets/submissions-new.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
7
internal/db/migrations/119_submission_drafts.down.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
-- t-paliad-238: revert submission_drafts table.
|
||||
--
|
||||
-- The shared paliad.tg_set_updated_at trigger function is intentionally
|
||||
-- left in place — it may be in use by other tables. DROP TABLE cascades
|
||||
-- to the trigger that references it on this table only.
|
||||
|
||||
DROP TABLE IF EXISTS paliad.submission_drafts;
|
||||
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
88
internal/db/migrations/119_submission_drafts.up.sql
Normal file
@@ -0,0 +1,88 @@
|
||||
-- t-paliad-238: dedicated Submissions/Schriftsätze page.
|
||||
--
|
||||
-- paliad.submission_drafts holds the lawyer's per-(project, submission_code)
|
||||
-- draft state for the new editor at /projects/{id}/submissions/{code}/draft.
|
||||
-- Each row is one named draft owned by one user; multiple drafts per
|
||||
-- (project, submission_code, user_id) are supported via the `name` field
|
||||
-- (auto-generated "Entwurf 1", "Entwurf 2", … and lawyer-renameable).
|
||||
--
|
||||
-- `variables` carries the lawyer's overrides for the placeholder map
|
||||
-- assembled at export time by SubmissionVarsService — empty string forces
|
||||
-- the [KEIN WERT: …] marker; absent key falls back to the resolved bag.
|
||||
--
|
||||
-- `last_exported_at` / `last_exported_sha` record provenance of the most
|
||||
-- recent .docx export (template SHA pinned for audit). Audit rows live
|
||||
-- in paliad.system_audit_log + paliad.project_events; this table is the
|
||||
-- lawyer's working state, not the audit log.
|
||||
--
|
||||
-- Visibility: per project CLAUDE.md, every project-scoped resource gates
|
||||
-- on paliad.can_see_project. UPDATE / DELETE additionally require the
|
||||
-- draft's user_id to match auth.uid() so two co-team-members don't stomp
|
||||
-- on each other's drafts (head-confirmed Q-E4 owner-scoped pick).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS paliad.submission_drafts (
|
||||
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
project_id uuid NOT NULL REFERENCES paliad.projects(id) ON DELETE CASCADE,
|
||||
submission_code text NOT NULL,
|
||||
user_id uuid NOT NULL REFERENCES paliad.users(id) ON DELETE CASCADE,
|
||||
name text NOT NULL,
|
||||
|
||||
variables jsonb NOT NULL DEFAULT '{}'::jsonb,
|
||||
|
||||
last_exported_at timestamptz,
|
||||
last_exported_sha text,
|
||||
|
||||
created_at timestamptz NOT NULL DEFAULT now(),
|
||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||
|
||||
CONSTRAINT submission_drafts_unique_per_user
|
||||
UNIQUE (project_id, submission_code, user_id, name)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS submission_drafts_project_user_idx
|
||||
ON paliad.submission_drafts (project_id, user_id, submission_code, updated_at DESC);
|
||||
|
||||
ALTER TABLE paliad.submission_drafts ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
-- updated_at maintenance: trigger function lives once per schema; reuse if
|
||||
-- it already exists, otherwise create the standard shape.
|
||||
CREATE OR REPLACE FUNCTION paliad.tg_set_updated_at()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS submission_drafts_set_updated_at ON paliad.submission_drafts;
|
||||
CREATE TRIGGER submission_drafts_set_updated_at
|
||||
BEFORE UPDATE ON paliad.submission_drafts
|
||||
FOR EACH ROW EXECUTE FUNCTION paliad.tg_set_updated_at();
|
||||
|
||||
COMMENT ON TABLE paliad.submission_drafts IS
|
||||
't-paliad-238: per-(project, submission_code, user) named drafts for the dedicated Submissions/Schriftsätze page. Each row holds the lawyer-edited variable overrides for the .docx export. Audit rows live in paliad.system_audit_log + paliad.project_events.';
|
||||
@@ -0,0 +1,46 @@
|
||||
-- t-paliad-243 revert: restore NOT NULL on project_id.
|
||||
--
|
||||
-- The revert refuses to run if any project-less draft exists — those
|
||||
-- rows would silently fail the NOT NULL re-imposition and corrupt the
|
||||
-- migration runner's state. The safe revert path is to surface the
|
||||
-- conflict to the operator who can decide whether to attach the rows
|
||||
-- to a project or delete them before retrying the down.
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1 FROM paliad.submission_drafts WHERE project_id IS NULL
|
||||
) THEN
|
||||
RAISE EXCEPTION
|
||||
'cannot re-impose NOT NULL on paliad.submission_drafts.project_id: '
|
||||
'project-less drafts exist. Attach them to a project or delete '
|
||||
'them, then re-run the down migration.';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id SET NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND paliad.can_see_project(project_id)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id))
|
||||
WITH CHECK (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (user_id = auth.uid() AND paliad.can_see_project(project_id));
|
||||
@@ -0,0 +1,70 @@
|
||||
-- t-paliad-243: drafts may exist without a project attached.
|
||||
--
|
||||
-- The global /submissions/new picker lets a lawyer start a Schriftsatz
|
||||
-- draft straight from the top-level Schriftsätze sidebar, with or
|
||||
-- without binding it to a project. project_id therefore becomes
|
||||
-- optional. Existing rows are unaffected; new rows may insert NULL.
|
||||
--
|
||||
-- RLS rewrite: every policy splits on (project_id IS NULL):
|
||||
--
|
||||
-- project_id IS NOT NULL → gate on paliad.can_see_project (existing
|
||||
-- inheritance-aware visibility).
|
||||
-- project_id IS NULL → owner-only (user_id = auth.uid()). A
|
||||
-- project-less draft is a personal scratch
|
||||
-- space — never shared, never visible to
|
||||
-- other team members.
|
||||
--
|
||||
-- INSERT enforces the same shape via WITH CHECK: a project-less insert
|
||||
-- only writes user_id = auth.uid(); a project-scoped insert additionally
|
||||
-- requires can_see_project.
|
||||
|
||||
ALTER TABLE paliad.submission_drafts
|
||||
ALTER COLUMN project_id DROP NOT NULL;
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_select ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_select
|
||||
ON paliad.submission_drafts FOR SELECT TO authenticated
|
||||
USING (
|
||||
(project_id IS NULL AND user_id = auth.uid())
|
||||
OR (project_id IS NOT NULL AND paliad.can_see_project(project_id))
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_insert ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_insert
|
||||
ON paliad.submission_drafts FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_update ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_update
|
||||
ON paliad.submission_drafts FOR UPDATE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
)
|
||||
WITH CHECK (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS submission_drafts_delete ON paliad.submission_drafts;
|
||||
CREATE POLICY submission_drafts_delete
|
||||
ON paliad.submission_drafts FOR DELETE TO authenticated
|
||||
USING (
|
||||
user_id = auth.uid()
|
||||
AND (
|
||||
project_id IS NULL
|
||||
OR paliad.can_see_project(project_id)
|
||||
)
|
||||
);
|
||||
@@ -37,6 +37,11 @@ type fileEntry struct {
|
||||
//
|
||||
// The URL slug ("hl-patents-style.dotm") is preserved as a stable public
|
||||
// identifier so existing bookmarks keep working post-rebrand.
|
||||
//
|
||||
// Per-submission templates (slug `submission/<code>.docx`) are server-only:
|
||||
// only the submission-draft editor reaches them via fetchSubmissionTemplateBytes.
|
||||
// handleFileDownload serves any slug that lands here, but the public URL
|
||||
// surface for submission templates is the export endpoint, not /files.
|
||||
var fileRegistry = map[string]fileEntry{
|
||||
"hl-patents-style.dotm": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/HL%20Patents%20Style.dotm",
|
||||
@@ -46,6 +51,72 @@ var fileRegistry = map[string]fileEntry{
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/HL Patents Style.dotm",
|
||||
},
|
||||
// Per-submission demo template (t-paliad-241). Exercises every
|
||||
// placeholder SubmissionVarsService resolves so the
|
||||
// /projects/{id}/submissions/{code}/draft editor has variables to
|
||||
// substitute. One file per submission_code; future codes register
|
||||
// the same way — slug shape "submission/<code>.docx" so the
|
||||
// namespace stays separate from the universal style template.
|
||||
"submission/de.inf.lg.erwidg.docx": {
|
||||
RawURL: "https://mgit.msbls.de/m/mWorkRepo/raw/branch/main/6%20-%20material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
DownloadName: "Klageerwiderung — " + branding.Name + ".docx",
|
||||
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
RepoOwner: "m",
|
||||
RepoName: "mWorkRepo",
|
||||
FilePath: "6 - material/Templates/Word/Paliad/" + branding.Name + "/de.inf.lg.erwidg.docx",
|
||||
},
|
||||
}
|
||||
|
||||
// submissionTemplateRegistry maps a deadline-rule submission_code to a
|
||||
// fileRegistry slug. Lookup order matches the cronus design fallback
|
||||
// chain §8: per-firm `templates/{FIRM_NAME}/{code}.docx` first, then
|
||||
// universal HL Patents Style as the global fallback.
|
||||
//
|
||||
// Add new entries here as the firm authors per-submission templates;
|
||||
// the file itself lives in mWorkRepo and is served through the shared
|
||||
// Gitea proxy cache so refreshes are visible to all consumers in one
|
||||
// place.
|
||||
var submissionTemplateRegistry = map[string]string{
|
||||
"de.inf.lg.erwidg": "submission/de.inf.lg.erwidg.docx",
|
||||
}
|
||||
|
||||
// fetchSubmissionTemplateBytes returns the per-submission_code template
|
||||
// bytes (and provenance SHA) when one is registered. The bool result
|
||||
// distinguishes "no per-code template registered" (callers fall back to
|
||||
// HL Patents Style) from an upstream fetch error.
|
||||
func fetchSubmissionTemplateBytes(ctx context.Context, submissionCode string) ([]byte, string, bool, error) {
|
||||
slug, ok := submissionTemplateRegistry[submissionCode]
|
||||
if !ok {
|
||||
return nil, "", false, nil
|
||||
}
|
||||
entry, ok := fileRegistry[slug]
|
||||
if !ok {
|
||||
return nil, "", false, fmt.Errorf("file proxy: submission template slug %q not registered", slug)
|
||||
}
|
||||
ce := getCacheEntry(slug)
|
||||
|
||||
ce.mu.RLock()
|
||||
hasData := len(ce.data) > 0
|
||||
needsCheck := time.Since(ce.lastChecked) >= checkInterval
|
||||
ce.mu.RUnlock()
|
||||
|
||||
if !hasData {
|
||||
if err := fileFetch(ce, entry); err != nil {
|
||||
return nil, "", false, err
|
||||
}
|
||||
} else if needsCheck {
|
||||
go fileCheckAndRefresh(ce, entry)
|
||||
}
|
||||
|
||||
ce.mu.RLock()
|
||||
defer ce.mu.RUnlock()
|
||||
if len(ce.data) == 0 {
|
||||
return nil, "", false, fmt.Errorf("file proxy: %s cache empty after fetch", slug)
|
||||
}
|
||||
out := make([]byte, len(ce.data))
|
||||
copy(out, ce.data)
|
||||
_ = ctx
|
||||
return out, ce.sha, true, nil
|
||||
}
|
||||
|
||||
type cacheEntry struct {
|
||||
|
||||
@@ -98,6 +98,9 @@ type Services struct {
|
||||
Projection *services.ProjectionService
|
||||
Export *services.ExportService
|
||||
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze editor.
|
||||
SubmissionDraft *services.SubmissionDraftService
|
||||
|
||||
// Paliadin is wired when DATABASE_URL is set. The concrete backend
|
||||
// is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
|
||||
// (remote → mRiver via SSH) or local tmux availability. Stays nil
|
||||
@@ -159,6 +162,7 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
firmDashboardDefault: svc.FirmDashboardDefault,
|
||||
projection: svc.Projection,
|
||||
export: svc.Export,
|
||||
submissionDraft: svc.SubmissionDraft,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +276,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/checklist-instances/{id}/reset", handleResetChecklistInstance)
|
||||
protected.HandleFunc("DELETE /api/checklist-instances/{id}", handleDeleteChecklistInstance)
|
||||
protected.HandleFunc("GET /api/projects/{id}/checklists", handleListChecklistInstancesForProject)
|
||||
// t-paliad-240 — global Schriftsätze drafts index (top-level sidebar
|
||||
// entry). Lists every draft the caller owns across visible projects.
|
||||
// The per-project Schriftsätze tab keeps the editor itself project-
|
||||
// scoped; this index is the cross-project landing.
|
||||
protected.HandleFunc("GET /submissions", gateOnboarded(handleSubmissionsIndexPage))
|
||||
protected.HandleFunc("GET /courts", handleCourtsPage)
|
||||
protected.HandleFunc("GET /api/courts", handleCourtsAPI)
|
||||
protected.HandleFunc("POST /api/courts/feedback", handleCourtsFeedback)
|
||||
@@ -313,6 +322,33 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// writes an audit row.
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions", handleListProjectSubmissions)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/generate", handleGenerateProjectSubmission)
|
||||
// t-paliad-238 — dedicated Submissions/Schriftsätze draft editor.
|
||||
// Per (project, submission_code, user) named drafts with autosaved
|
||||
// variable overrides; export merges the bag with the universal HL
|
||||
// Patents Style template (Slice A) or the per-code template
|
||||
// (Slice B, future).
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts", handleListSubmissionDrafts)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts", handleCreateSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleGetSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handlePatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/projects/{id}/submissions/{code}/drafts/{draft_id}", handleDeleteSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/projects/{id}/submissions/{code}/drafts/{draft_id}/preview", handlePreviewSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/projects/{id}/submissions/{code}/drafts/{draft_id}/export", handleExportSubmissionDraft)
|
||||
// t-paliad-240 — global drafts index (across visible projects).
|
||||
protected.HandleFunc("GET /api/user/submission-drafts", handleListUserSubmissionDrafts)
|
||||
// t-paliad-243 — global Schriftsätze drafts with optional project
|
||||
// binding. The picker page at /submissions/new lists the full
|
||||
// cross-proceeding catalog (without a project context) and posts to
|
||||
// POST /api/submission-drafts to spawn a draft. The
|
||||
// /api/submission-drafts/{draft_id}* endpoints back the project-less
|
||||
// editor and ALSO accept project-scoped drafts (the draft row
|
||||
// carries its own project_id so the project segment is redundant).
|
||||
protected.HandleFunc("GET /api/submissions/catalog", handleListSubmissionCatalog)
|
||||
protected.HandleFunc("POST /api/submission-drafts", handleCreateGlobalSubmissionDraft)
|
||||
protected.HandleFunc("GET /api/submission-drafts/{draft_id}", handleGetGlobalSubmissionDraft)
|
||||
protected.HandleFunc("PATCH /api/submission-drafts/{draft_id}", handleGlobalPatchSubmissionDraft)
|
||||
protected.HandleFunc("DELETE /api/submission-drafts/{draft_id}", handleGlobalDeleteSubmissionDraft)
|
||||
protected.HandleFunc("POST /api/submission-drafts/{draft_id}/export", handleGlobalExportSubmissionDraft)
|
||||
// /counterclaim creates a CCR sub-project linked via the new
|
||||
// paliad.projects.counterclaim_of FK (t-paliad-174 Slice 3).
|
||||
protected.HandleFunc("POST /api/projects/{id}/counterclaim", handleCreateProjectCounterclaim)
|
||||
@@ -470,6 +506,16 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
// Without this route the deep-link 404s; the tab still works via
|
||||
// in-page click since it just toggles a panel.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions", gateOnboarded(handleProjectsDetailPage))
|
||||
// t-paliad-238 — dedicated submission draft editor. Both routes
|
||||
// render the project detail page; the actual editor mounts
|
||||
// client-side based on the URL path.
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft", gateOnboarded(handleSubmissionDraftPage))
|
||||
protected.HandleFunc("GET /projects/{id}/submissions/{code}/draft/{draft_id}", gateOnboarded(handleSubmissionDraftPage))
|
||||
// t-paliad-243 — global Schriftsätze pages: picker + project-less
|
||||
// editor. Both render dist/* files; client bundles parse the URL
|
||||
// and branch on whether a project segment is present.
|
||||
protected.HandleFunc("GET /submissions/new", gateOnboarded(handleSubmissionsNewPage))
|
||||
protected.HandleFunc("GET /submissions/draft/{draft_id}", gateOnboarded(handleSubmissionDraftGlobalPage))
|
||||
// t-paliad-177 — standalone Project Timeline / Chart page (Slice 1).
|
||||
// Horizontal SVG renderer mounted client-side; reuses the existing
|
||||
// /api/projects/{id}/timeline JSON endpoint for data.
|
||||
|
||||
@@ -61,6 +61,9 @@ type dbServices struct {
|
||||
firmDashboardDefault *services.FirmDashboardDefaultService
|
||||
projection *services.ProjectionService
|
||||
export *services.ExportService
|
||||
|
||||
// t-paliad-238 — submission draft editor.
|
||||
submissionDraft *services.SubmissionDraftService
|
||||
}
|
||||
|
||||
var dbSvc *dbServices
|
||||
|
||||
1085
internal/handlers/submission_drafts.go
Normal file
1085
internal/handlers/submission_drafts.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,27 +1,32 @@
|
||||
package handlers
|
||||
|
||||
// Submission generator HTTP layer (t-paliad-230 — format-only scope
|
||||
// reduction of t-paliad-215).
|
||||
// reduction of t-paliad-215; t-paliad-242 broadened the list endpoint
|
||||
// to the full cross-proceeding catalog; t-paliad-253 promoted /generate
|
||||
// from format-only to the same merge engine the draft editor uses).
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// GET /api/projects/{id}/submissions
|
||||
// Lists the project's proceeding-relevant filing rules.
|
||||
// has_template is unconditionally true: every project gets
|
||||
// offered the universal HL Patents Style template.
|
||||
// Lists every published filing rule across every active
|
||||
// proceeding the platform knows about, joined with its
|
||||
// proceeding_type so the frontend can group by proceeding.
|
||||
// has_template flips per-row: true when a per-submission .docx
|
||||
// is wired in submissionTemplateRegistry, false when the
|
||||
// editor falls back to the universal HL Patents Style.
|
||||
//
|
||||
// POST /api/projects/{id}/submissions/{code}/generate
|
||||
// Fetches the cached HL Patents Style .dotm (same proxy used
|
||||
// by /files/hl-patents-style.dotm), converts it to a clean
|
||||
// .docx via services.ConvertDotmToDocx, writes one
|
||||
// paliad.system_audit_log row, and streams the result as an
|
||||
// attachment download.
|
||||
//
|
||||
// No variable substitution, no per-submission templates, no
|
||||
// project_events/documents writes. Those layers are deferred to a
|
||||
// future "merge engine" slice; today's generator hands the lawyer a
|
||||
// clean .docx of the firm style and lets them edit and save under
|
||||
// their own filename.
|
||||
// Resolves the template through the cronus fallback chain
|
||||
// (per-firm `submissionTemplateRegistry[code]` first, HL
|
||||
// Patents Style as the universal fallback), builds a fresh
|
||||
// variable bag via SubmissionVarsService.Build, and runs the
|
||||
// SubmissionRenderer merge so every {{placeholder}} resolves
|
||||
// to project state (or `[KEIN WERT: key]` for empties). Writes
|
||||
// one paliad.system_audit_log row and streams the .docx as an
|
||||
// attachment download. The HL Patents Style fallback has no
|
||||
// placeholders today, so for codes without a per-firm template
|
||||
// the renderer is a no-op on substitution but still runs the
|
||||
// .dotm→.docx pre-pass.
|
||||
//
|
||||
// Visibility: every endpoint runs through ProjectService.GetByID
|
||||
// (paliad.can_see_project gate). Unauthorised callers get 404 — same
|
||||
@@ -62,26 +67,48 @@ const hlPatentsStyleSlug = "hl-patents-style.dotm"
|
||||
|
||||
// submissionListEntry is one row in the Schriftsätze panel.
|
||||
type submissionListEntry struct {
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
SubmissionCode string `json:"submission_code"`
|
||||
Name string `json:"name"`
|
||||
NameEN string `json:"name_en"`
|
||||
EventType string `json:"event_type,omitempty"`
|
||||
PrimaryParty string `json:"primary_party,omitempty"`
|
||||
LegalSource string `json:"legal_source,omitempty"`
|
||||
HasTemplate bool `json:"has_template"`
|
||||
ProceedingCode string `json:"proceeding_code"`
|
||||
ProceedingName string `json:"proceeding_name"`
|
||||
ProceedingNameEN string `json:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
// submissionListResponse wraps the list with a project-level header.
|
||||
//
|
||||
// ProjectProceedingCode names the project's own proceeding so the
|
||||
// frontend can pin its group to the top of the grouped catalog
|
||||
// (t-paliad-242). nil when the project hasn't bound a proceeding yet.
|
||||
type submissionListResponse struct {
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
ProjectID uuid.UUID `json:"project_id"`
|
||||
ProceedingTypeID *int `json:"proceeding_type_id,omitempty"`
|
||||
ProjectProceedingCode *string `json:"project_proceeding_code,omitempty"`
|
||||
Entries []submissionListEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// handleListProjectSubmissions returns the published filing rules for
|
||||
// the project's proceeding_type. has_template is true for every row —
|
||||
// Slice 1 (t-paliad-230) ships one universal template, so the only
|
||||
// "no template" case is a project that has no proceeding_type bound.
|
||||
// handleListProjectSubmissions returns every published filing rule
|
||||
// across every active proceeding the platform knows about, joined with
|
||||
// its proceeding_type so the Schriftsätze tab can group rows by
|
||||
// proceeding (t-paliad-242 — m wants to see the entire catalog from any
|
||||
// project, not just the rules for the project's own proceeding).
|
||||
//
|
||||
// Visibility is gated on the PROJECT (paliad.can_see_project via
|
||||
// ProjectService.GetByID); the rules themselves are static reference
|
||||
// data shared across the firm.
|
||||
//
|
||||
// has_template flips when a per-submission .docx is wired into
|
||||
// submissionTemplateRegistry (files.go). When false, the universal HL
|
||||
// Patents Style .dotm is the fallback — the editor (t-paliad-238)
|
||||
// resolves both flavours transparently, so every row remains
|
||||
// generatable and editable from the UI.
|
||||
//
|
||||
// Rows are sorted by (proceeding_code, submission_code) so the
|
||||
// frontend's groupBy stays cheap and the order is stable.
|
||||
func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -110,53 +137,145 @@ func handleListProjectSubmissions(w http.ResponseWriter, r *http.Request) {
|
||||
Entries: []submissionListEntry{},
|
||||
}
|
||||
|
||||
if project.ProceedingTypeID == nil {
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
return
|
||||
}
|
||||
|
||||
rules, err := dbSvc.rules.List(ctx, project.ProceedingTypeID)
|
||||
entries, ownCode, err := loadSubmissionCatalog(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list rules for proceeding %d: %v", *project.ProceedingTypeID, err)
|
||||
log.Printf("submissions: list submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
|
||||
for _, rule := range rules {
|
||||
if rule.SubmissionCode == nil || *rule.SubmissionCode == "" {
|
||||
continue
|
||||
}
|
||||
if rule.EventType == nil || *rule.EventType != "filing" {
|
||||
continue
|
||||
}
|
||||
if rule.LifecycleState != "published" {
|
||||
continue
|
||||
}
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: *rule.SubmissionCode,
|
||||
Name: rule.Name,
|
||||
NameEN: rule.NameEN,
|
||||
HasTemplate: true,
|
||||
}
|
||||
if rule.EventType != nil {
|
||||
entry.EventType = *rule.EventType
|
||||
}
|
||||
if rule.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *rule.PrimaryParty
|
||||
}
|
||||
if rule.LegalSource != nil {
|
||||
entry.LegalSource = *rule.LegalSource
|
||||
}
|
||||
resp.Entries = append(resp.Entries, entry)
|
||||
}
|
||||
resp.Entries = entries
|
||||
resp.ProjectProceedingCode = ownCode
|
||||
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission fetches the universal HL Patents
|
||||
// Style .dotm, converts it to a clean .docx, writes one audit row, and
|
||||
// streams the result. No variable substitution; the bytes that go down
|
||||
// the wire are the firm style template with macros stripped.
|
||||
// handleListSubmissionCatalog returns the same cross-proceeding catalog
|
||||
// without a project context — used by the global /submissions/new
|
||||
// picker (t-paliad-243). No project_proceeding_code is returned since
|
||||
// the picker isn't pinned to one project.
|
||||
func handleListSubmissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
}
|
||||
if _, ok := requireUser(w, r); !ok {
|
||||
return
|
||||
}
|
||||
|
||||
entries, _, err := loadSubmissionCatalog(r.Context(), nil)
|
||||
if err != nil {
|
||||
log.Printf("submissions: list global submission catalog: %v", err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"entries": entries})
|
||||
}
|
||||
|
||||
// loadSubmissionCatalog runs the shared catalog query. When
|
||||
// projectProceedingTypeID is non-nil, the returned ownCode points at
|
||||
// that proceeding's code so the frontend can pin its group to the top;
|
||||
// otherwise ownCode is nil.
|
||||
func loadSubmissionCatalog(ctx context.Context, projectProceedingTypeID *int) ([]submissionListEntry, *string, error) {
|
||||
type catalogRow struct {
|
||||
SubmissionCode string `db:"submission_code"`
|
||||
Name string `db:"name"`
|
||||
NameEN string `db:"name_en"`
|
||||
EventType *string `db:"event_type"`
|
||||
PrimaryParty *string `db:"primary_party"`
|
||||
LegalSource *string `db:"legal_source"`
|
||||
ProceedingID int `db:"proceeding_type_id"`
|
||||
ProceedingCode string `db:"proceeding_code"`
|
||||
ProceedingName string `db:"proceeding_name"`
|
||||
ProceedingNameEN string `db:"proceeding_name_en"`
|
||||
}
|
||||
|
||||
var rows []catalogRow
|
||||
err := dbSvc.projects.DB().SelectContext(ctx, &rows,
|
||||
`SELECT dr.submission_code AS submission_code,
|
||||
dr.name AS name,
|
||||
dr.name_en AS name_en,
|
||||
dr.event_type AS event_type,
|
||||
dr.primary_party AS primary_party,
|
||||
dr.legal_source AS legal_source,
|
||||
dr.proceeding_type_id AS proceeding_type_id,
|
||||
pt.code AS proceeding_code,
|
||||
pt.name AS proceeding_name,
|
||||
pt.name_en AS proceeding_name_en
|
||||
FROM paliad.deadline_rules dr
|
||||
JOIN paliad.proceeding_types pt ON pt.id = dr.proceeding_type_id
|
||||
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 <> ''
|
||||
AND pt.is_active = true
|
||||
ORDER BY pt.code ASC, dr.submission_code ASC`)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
entries := make([]submissionListEntry, 0, len(rows))
|
||||
var ownCode *string
|
||||
for _, row := range rows {
|
||||
entry := submissionListEntry{
|
||||
SubmissionCode: row.SubmissionCode,
|
||||
Name: row.Name,
|
||||
NameEN: row.NameEN,
|
||||
HasTemplate: hasPerSubmissionTemplate(row.SubmissionCode),
|
||||
ProceedingCode: row.ProceedingCode,
|
||||
ProceedingName: row.ProceedingName,
|
||||
ProceedingNameEN: row.ProceedingNameEN,
|
||||
}
|
||||
if row.EventType != nil {
|
||||
entry.EventType = *row.EventType
|
||||
}
|
||||
if row.PrimaryParty != nil {
|
||||
entry.PrimaryParty = *row.PrimaryParty
|
||||
}
|
||||
if row.LegalSource != nil {
|
||||
entry.LegalSource = *row.LegalSource
|
||||
}
|
||||
entries = append(entries, entry)
|
||||
if projectProceedingTypeID != nil && row.ProceedingID == *projectProceedingTypeID && ownCode == nil {
|
||||
code := row.ProceedingCode
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
// If the project's proceeding has no filing rules of its own, fall
|
||||
// back to a direct proceeding_types lookup so the frontend can still
|
||||
// pin the right group even when the catalog ordering wouldn't have
|
||||
// surfaced the code via a row.
|
||||
if projectProceedingTypeID != nil && ownCode == nil {
|
||||
var code string
|
||||
if err := dbSvc.projects.DB().GetContext(ctx, &code,
|
||||
`SELECT code FROM paliad.proceeding_types WHERE id = $1`, *projectProceedingTypeID); err == nil && code != "" {
|
||||
ownCode = &code
|
||||
}
|
||||
}
|
||||
|
||||
return entries, ownCode, nil
|
||||
}
|
||||
|
||||
// hasPerSubmissionTemplate reports whether a per-submission .docx is
|
||||
// wired in the fileRegistry (files.go). false means the editor falls
|
||||
// back to the universal HL Patents Style — still renderable, still
|
||||
// editable, but the UI may want to surface a "universal Vorlage"
|
||||
// indicator. Read-only — no I/O, just a map lookup.
|
||||
func hasPerSubmissionTemplate(submissionCode string) bool {
|
||||
_, ok := submissionTemplateRegistry[submissionCode]
|
||||
return ok
|
||||
}
|
||||
|
||||
// handleGenerateProjectSubmission resolves the per-submission template
|
||||
// (per-firm first, HL Patents Style fallback), builds a fresh variable
|
||||
// bag from project state via SubmissionVarsService, runs the merge
|
||||
// engine so every {{placeholder}} substitutes, writes one audit row,
|
||||
// and streams the result. Pre-t-paliad-253 this handler ignored the
|
||||
// per-firm registry and returned the bare HL Patents Style .dotm with
|
||||
// no substitution — the "Generieren" button on the Schriftsätze tab
|
||||
// therefore produced a generic firm-style .docx instead of a
|
||||
// project-merged Klageerwiderung, which is what m noticed in
|
||||
// m/paliad#84.
|
||||
func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !requireDB(w) {
|
||||
return
|
||||
@@ -165,6 +284,12 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if dbSvc.submissionDraft == nil {
|
||||
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
|
||||
"error": "submissions not configured",
|
||||
})
|
||||
return
|
||||
}
|
||||
projectID, err := uuid.Parse(r.PathValue("id"))
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project id"})
|
||||
@@ -179,60 +304,37 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), submissionRenderTimeout)
|
||||
defer cancel()
|
||||
|
||||
project, err := dbSvc.projects.GetByID(ctx, uid, projectID)
|
||||
tplBytes, _, err := resolveSubmissionTemplate(ctx, submissionCode)
|
||||
if err != nil {
|
||||
writeServiceError(w, err)
|
||||
log.Printf("submissions: template fetch (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
|
||||
return
|
||||
}
|
||||
|
||||
rule, err := loadPublishedRuleByCode(ctx, submissionCode)
|
||||
docx, resolved, err := dbSvc.submissionDraft.RenderProjectSubmission(ctx, uid, projectID, submissionCode, tplBytes)
|
||||
if err != nil {
|
||||
if errors.Is(err, errRuleNotFound) {
|
||||
if errors.Is(err, services.ErrSubmissionRuleNotFound) {
|
||||
writeJSON(w, http.StatusNotFound, map[string]string{
|
||||
"error": fmt.Sprintf("no published rule for submission_code %q", submissionCode),
|
||||
})
|
||||
return
|
||||
}
|
||||
log.Printf("submissions: load rule %q: %v", submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "rule lookup failed"})
|
||||
// ErrNotVisible / project ErrNotFound from the visibility gate
|
||||
// surface through writeServiceError as 404, matching the rest
|
||||
// of the project surfaces.
|
||||
log.Printf("submissions: render (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
writeServiceError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
dotm, err := fetchHLPatentsStyleBytes(ctx)
|
||||
if err != nil {
|
||||
log.Printf("submissions: fetch HL Patents Style .dotm: %v", err)
|
||||
writeJSON(w, http.StatusBadGateway, map[string]string{
|
||||
"error": "template upstream unreachable",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
docx, err := services.ConvertDotmToDocx(dotm)
|
||||
if err != nil {
|
||||
log.Printf("submissions: convert dotm for project %s code %s: %v", projectID, submissionCode, err)
|
||||
writeJSON(w, http.StatusInternalServerError, map[string]string{
|
||||
"error": "convert failed",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
user, err := dbSvc.users.GetByID(ctx, uid)
|
||||
if err != nil {
|
||||
log.Printf("submissions: load user %s: %v", uid, err)
|
||||
}
|
||||
lang := "de"
|
||||
if user != nil && user.Lang != "" {
|
||||
lang = user.Lang
|
||||
}
|
||||
|
||||
filename := submissionFileName(rule, project, lang)
|
||||
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
|
||||
|
||||
// Audit write is best-effort with a background context so the
|
||||
// download still succeeds if the DB races. Audit failure here only
|
||||
// affects the system_audit_log feed — never the user's response.
|
||||
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancelBG()
|
||||
if err := writeSubmissionAuditRow(bgCtx, user, project.ID, submissionCode, rule.Name, filename); err != nil {
|
||||
if err := writeSubmissionAuditRow(bgCtx, resolved.User, projectID, submissionCode, resolved.Rule.Name, filename); err != nil {
|
||||
log.Printf("submissions: audit insert failed (project=%s code=%s): %v", projectID, submissionCode, err)
|
||||
}
|
||||
|
||||
@@ -244,41 +346,6 @@ func handleGenerateProjectSubmission(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// errRuleNotFound is the sentinel for "no published rule with that
|
||||
// submission_code" — distinguished from a generic DB error so the
|
||||
// handler returns 404 instead of 500.
|
||||
var errRuleNotFound = errors.New("submission rule not found")
|
||||
|
||||
// loadPublishedRuleByCode fetches the rule the user requested. Only
|
||||
// published+active rows resolve; drafts and archived rules never feed
|
||||
// a real submission.
|
||||
func loadPublishedRuleByCode(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := dbSvc.projects.DB().GetContext(ctx, &rule,
|
||||
`SELECT id, proceeding_type_id, parent_id, submission_code, name, name_en,
|
||||
description, primary_party, event_type, duration_value, duration_unit,
|
||||
timing, rule_code, deadline_notes, deadline_notes_en, sequence_order,
|
||||
alt_duration_value, alt_duration_unit, alt_rule_code, anchor_alt,
|
||||
concept_id, legal_source, is_spawn, spawn_label, is_active,
|
||||
created_at, updated_at, lifecycle_state
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if err != nil {
|
||||
if strings.Contains(err.Error(), "no rows") {
|
||||
return nil, errRuleNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// 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 —
|
||||
|
||||
570
internal/services/submission_draft_service.go
Normal file
570
internal/services/submission_draft_service.go
Normal file
@@ -0,0 +1,570 @@
|
||||
package services
|
||||
|
||||
// Submission draft service — CRUD over paliad.submission_drafts plus
|
||||
// the render+export entry points that combine the variable bag, lawyer
|
||||
// overrides, and template fetch into a .docx or HTML preview
|
||||
// (t-paliad-238 Slice A, design doc
|
||||
// docs/design-submission-page-2026-05-22.md §5.2).
|
||||
//
|
||||
// Each draft is owned by one user; multiple drafts per (project,
|
||||
// submission_code, user_id) are supported via the `name` column. The
|
||||
// override semantics are explicit:
|
||||
//
|
||||
// variables = {"project.case_number": "2 O 999/25"} → use this value
|
||||
// variables = {"project.case_number": ""} → force [KEIN WERT: …]
|
||||
// key absent → fall back to bag
|
||||
//
|
||||
// Visibility flows through ProjectService.GetByID — every read and
|
||||
// write gates on paliad.can_see_project. RLS in the DB enforces the
|
||||
// owner-scoped UPDATE/DELETE constraint independently of the Go layer.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"maps"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionDraft mirrors a row in paliad.submission_drafts.
|
||||
//
|
||||
// ProjectID is nullable since t-paliad-243 — a draft started from the
|
||||
// global /submissions/new picker without picking a project is private
|
||||
// to its creator and carries an empty variable bag (no project /
|
||||
// parties / deadline state to resolve). All callers must check for nil
|
||||
// before treating it as a uuid.
|
||||
type SubmissionDraft struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
ProjectID *uuid.UUID `db:"project_id" json:"project_id,omitempty"`
|
||||
SubmissionCode string `db:"submission_code" json:"submission_code"`
|
||||
UserID uuid.UUID `db:"user_id" json:"user_id"`
|
||||
Name string `db:"name" json:"name"`
|
||||
VariablesRaw []byte `db:"variables" json:"-"`
|
||||
LastExportedAt *time.Time `db:"last_exported_at" json:"last_exported_at,omitempty"`
|
||||
LastExportedSHA *string `db:"last_exported_sha" json:"last_exported_sha,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
|
||||
// Variables is the decoded overrides map; populated on read by the
|
||||
// service so callers don't have to unmarshal manually.
|
||||
Variables PlaceholderMap `json:"variables"`
|
||||
}
|
||||
|
||||
// SubmissionDraftService handles CRUD on submission_drafts and exposes
|
||||
// the render/preview/export entry points the handler layer calls.
|
||||
type SubmissionDraftService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
vars *SubmissionVarsService
|
||||
renderer *SubmissionRenderer
|
||||
}
|
||||
|
||||
// NewSubmissionDraftService wires the service.
|
||||
func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *SubmissionVarsService, renderer *SubmissionRenderer) *SubmissionDraftService {
|
||||
return &SubmissionDraftService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
vars: vars,
|
||||
renderer: renderer,
|
||||
}
|
||||
}
|
||||
|
||||
// DraftPatch carries optional fields for Update. nil pointer = "no
|
||||
// change"; non-nil = "set to this". Variables is replace-semantics —
|
||||
// the lawyer's sidebar sends the full map every save.
|
||||
//
|
||||
// ProjectID uses a two-level pointer (t-paliad-243) so we can encode
|
||||
// the three operations the global drafts flow needs:
|
||||
//
|
||||
// patch.ProjectID == nil → no change
|
||||
// *patch.ProjectID == nil → detach (re-set to NULL)
|
||||
// **patch.ProjectID → attach (assign a project)
|
||||
//
|
||||
// The detach path stays as scope for symmetry with attach even though
|
||||
// the current frontend only exposes attach.
|
||||
type DraftPatch struct {
|
||||
Name *string
|
||||
Variables *PlaceholderMap
|
||||
ProjectID **uuid.UUID
|
||||
}
|
||||
|
||||
// ErrSubmissionDraftNotFound is the sentinel for "no draft with that id
|
||||
// visible to this user". Maps to 404 in the handler.
|
||||
var ErrSubmissionDraftNotFound = errors.New("submission draft: not found")
|
||||
|
||||
// ErrSubmissionDraftNameTaken is the sentinel for duplicate names per
|
||||
// (project, submission_code, user). Maps to 409 in the handler.
|
||||
var ErrSubmissionDraftNameTaken = errors.New("submission draft: name already taken")
|
||||
|
||||
// draftColumns is the canonical select list — kept in one place so
|
||||
// every fetch stays in sync.
|
||||
const draftColumns = `id, project_id, submission_code, user_id, name,
|
||||
variables, last_exported_at, last_exported_sha,
|
||||
created_at, updated_at`
|
||||
|
||||
// List returns every draft for (project, submission_code, user)
|
||||
// ordered by updated_at DESC. Visibility flows through projects.GetByID.
|
||||
func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uuid.UUID, submissionCode string) ([]SubmissionDraft, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var rows []SubmissionDraft
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
||||
ORDER BY updated_at DESC`,
|
||||
projectID, submissionCode, userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list submission drafts: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// DraftWithProject is the row shape for the global /submissions index —
|
||||
// a draft joined with the minimal project metadata the table needs.
|
||||
// Visibility is gated by paliad.can_see_project in the SELECT itself.
|
||||
//
|
||||
// ProjectTitle / ProjectReference are pointer-nullable since
|
||||
// t-paliad-243 — project-less drafts surface in the same list with a
|
||||
// NULL project ref, and the frontend renders them with a dedicated
|
||||
// "kein Projekt" label.
|
||||
type DraftWithProject struct {
|
||||
SubmissionDraft
|
||||
ProjectTitle *string `db:"project_title" json:"project_title,omitempty"`
|
||||
ProjectReference *string `db:"project_reference" json:"project_reference,omitempty"`
|
||||
}
|
||||
|
||||
// ListAllForUser returns every draft the user owns across visible
|
||||
// projects PLUS every project-less draft the user owns, ordered by
|
||||
// updated_at DESC. LEFT JOIN on paliad.projects keeps project-less rows
|
||||
// in the result set; the WHERE clause permits project_id IS NULL or a
|
||||
// visible can_see_project hit, so a draft on a project the user no
|
||||
// longer has access to is silently dropped.
|
||||
func (s *SubmissionDraftService) ListAllForUser(ctx context.Context, userID uuid.UUID) ([]DraftWithProject, error) {
|
||||
var rows []DraftWithProject
|
||||
err := s.db.SelectContext(ctx, &rows,
|
||||
`SELECT d.id, d.project_id, d.submission_code, d.user_id, d.name,
|
||||
d.variables, d.last_exported_at, d.last_exported_sha,
|
||||
d.created_at, d.updated_at,
|
||||
p.title AS project_title,
|
||||
p.reference AS project_reference
|
||||
FROM paliad.submission_drafts d
|
||||
LEFT JOIN paliad.projects p ON p.id = d.project_id
|
||||
WHERE d.user_id = $1
|
||||
AND (
|
||||
d.project_id IS NULL
|
||||
OR paliad.can_see_project(d.project_id)
|
||||
)
|
||||
ORDER BY d.updated_at DESC`,
|
||||
userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list all submission drafts for user: %w", err)
|
||||
}
|
||||
for i := range rows {
|
||||
if err := rows[i].decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
// Get returns a single draft by id, gated on project visibility AND
|
||||
// owner-only — the caller can only fetch drafts they own. RLS in the
|
||||
// DB enforces this independently; the Go check makes the 404 semantics
|
||||
// explicit at the service boundary.
|
||||
//
|
||||
// A project-less draft (ProjectID == nil) skips the can_see_project
|
||||
// gate — the owner-only constraint is the entire visibility check.
|
||||
func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.UUID) (*SubmissionDraft, error) {
|
||||
var d SubmissionDraft
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE id = $1 AND user_id = $2`,
|
||||
draftID, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get submission draft: %w", err)
|
||||
}
|
||||
if d.ProjectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *d.ProjectID); err != nil {
|
||||
// Project no longer visible → behave as not-found rather than
|
||||
// leaking the draft's existence. ON DELETE CASCADE keeps this
|
||||
// rare in practice.
|
||||
if errors.Is(err, ErrNotVisible) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// EnsureLatest returns the user's most-recently-updated draft for
|
||||
// (project, submission_code). Creates "Entwurf 1" / "Draft 1" if none
|
||||
// exists. Idempotent on repeat calls — once a draft exists, EnsureLatest
|
||||
// always returns the freshest one rather than spawning new rows.
|
||||
func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, projectID uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if _, err := s.projects.GetByID(ctx, userID, projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d SubmissionDraft
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT `+draftColumns+`
|
||||
FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3
|
||||
ORDER BY updated_at DESC
|
||||
LIMIT 1`,
|
||||
projectID, submissionCode, userID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return s.Create(ctx, userID, &projectID, submissionCode, lang)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Create makes a new draft with an auto-incremented "Entwurf N" name
|
||||
// ("Draft N" for English locale). Lawyer can rename via Update.
|
||||
//
|
||||
// A nil projectID creates a project-less draft (t-paliad-243); the
|
||||
// visibility check is skipped — the caller is the owner and the row is
|
||||
// private to them.
|
||||
func (s *SubmissionDraftService) Create(ctx context.Context, userID uuid.UUID, projectID *uuid.UUID, submissionCode, lang string) (*SubmissionDraft, error) {
|
||||
if projectID != nil {
|
||||
if _, err := s.projects.GetByID(ctx, userID, *projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
name, err := s.nextDraftName(ctx, projectID, submissionCode, userID, lang)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d,
|
||||
`INSERT INTO paliad.submission_drafts
|
||||
(project_id, submission_code, user_id, name)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
RETURNING `+draftColumns,
|
||||
projectID, submissionCode, userID, name)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// nextDraftName returns "Entwurf N" / "Draft N" with N = (highest
|
||||
// existing N + 1), or N=1 if no draft yet. Falls back to a unique
|
||||
// suffix if two callers race; the unique constraint on the table is
|
||||
// the final guard.
|
||||
//
|
||||
// A nil projectID scopes the search to the user's project-less drafts
|
||||
// for this submission_code — matches the row-uniqueness contract on
|
||||
// the DB side (project_id, submission_code, user_id, name) where
|
||||
// project_id IS NULL is its own equivalence class.
|
||||
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID *uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
|
||||
prefix := "Entwurf"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "Draft"
|
||||
}
|
||||
var names []string
|
||||
var err error
|
||||
if projectID == nil {
|
||||
err = s.db.SelectContext(ctx, &names,
|
||||
`SELECT name FROM paliad.submission_drafts
|
||||
WHERE project_id IS NULL AND submission_code = $1 AND user_id = $2`,
|
||||
submissionCode, userID)
|
||||
} else {
|
||||
err = s.db.SelectContext(ctx, &names,
|
||||
`SELECT name FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2 AND user_id = $3`,
|
||||
*projectID, submissionCode, userID)
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("scan existing draft names: %w", err)
|
||||
}
|
||||
highest := 0
|
||||
for _, n := range names {
|
||||
var idx int
|
||||
if _, scanErr := fmt.Sscanf(n, prefix+" %d", &idx); scanErr == nil && idx > highest {
|
||||
highest = idx
|
||||
}
|
||||
}
|
||||
return fmt.Sprintf("%s %d", prefix, highest+1), nil
|
||||
}
|
||||
|
||||
// Update patches the draft. Variables is replace-semantics — pass the
|
||||
// full map. Name patches go through a uniqueness check to surface
|
||||
// ErrSubmissionDraftNameTaken cleanly instead of a raw constraint
|
||||
// violation.
|
||||
func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uuid.UUID, patch DraftPatch) (*SubmissionDraft, error) {
|
||||
existing, err := s.Get(ctx, userID, draftID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
setParts := []string{}
|
||||
args := []any{}
|
||||
idx := 1
|
||||
|
||||
if patch.Name != nil {
|
||||
newName := strings.TrimSpace(*patch.Name)
|
||||
if newName == "" {
|
||||
return nil, ErrInvalidInput
|
||||
}
|
||||
if newName != existing.Name {
|
||||
// Pre-check for the unique constraint so we can return a
|
||||
// typed error instead of a raw PG conflict. NULL project_id
|
||||
// is its own equivalence class in the unique index (NULLs
|
||||
// don't collide), so the no-project flow checks `IS NULL`.
|
||||
var dup int
|
||||
var qErr error
|
||||
if existing.ProjectID == nil {
|
||||
qErr = s.db.GetContext(ctx, &dup,
|
||||
`SELECT COUNT(*) FROM paliad.submission_drafts
|
||||
WHERE project_id IS NULL AND submission_code = $1
|
||||
AND user_id = $2 AND name = $3 AND id <> $4`,
|
||||
existing.SubmissionCode, userID, newName, draftID)
|
||||
} else {
|
||||
qErr = s.db.GetContext(ctx, &dup,
|
||||
`SELECT COUNT(*) FROM paliad.submission_drafts
|
||||
WHERE project_id = $1 AND submission_code = $2
|
||||
AND user_id = $3 AND name = $4 AND id <> $5`,
|
||||
*existing.ProjectID, existing.SubmissionCode, userID, newName, draftID)
|
||||
}
|
||||
if qErr != nil {
|
||||
return nil, fmt.Errorf("check name uniqueness: %w", qErr)
|
||||
}
|
||||
if dup > 0 {
|
||||
return nil, ErrSubmissionDraftNameTaken
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("name = $%d", idx))
|
||||
args = append(args, newName)
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.Variables != nil {
|
||||
raw, err := json.Marshal(*patch.Variables)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal variables: %w", err)
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("variables = $%d::jsonb", idx))
|
||||
args = append(args, string(raw))
|
||||
idx++
|
||||
}
|
||||
|
||||
if patch.ProjectID != nil {
|
||||
newPID := *patch.ProjectID // *uuid.UUID — nil means detach
|
||||
if newPID != nil {
|
||||
// Caller must be able to see the project they're attaching
|
||||
// the draft to; same gate as Create.
|
||||
if _, err := s.projects.GetByID(ctx, userID, *newPID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
setParts = append(setParts, fmt.Sprintf("project_id = $%d", idx))
|
||||
args = append(args, newPID)
|
||||
idx++
|
||||
}
|
||||
|
||||
if len(setParts) == 0 {
|
||||
return existing, nil
|
||||
}
|
||||
|
||||
args = append(args, draftID, userID)
|
||||
q := fmt.Sprintf(
|
||||
`UPDATE paliad.submission_drafts
|
||||
SET %s
|
||||
WHERE id = $%d AND user_id = $%d
|
||||
RETURNING %s`,
|
||||
strings.Join(setParts, ", "), idx, idx+1, draftColumns,
|
||||
)
|
||||
|
||||
var d SubmissionDraft
|
||||
err = s.db.GetContext(ctx, &d, q, args...)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionDraftNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("update submission draft: %w", err)
|
||||
}
|
||||
if err := d.decodeVariables(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// Delete removes the draft. Visibility-gated via Get; the DELETE itself
|
||||
// is owner-scoped (user_id = caller).
|
||||
func (s *SubmissionDraftService) Delete(ctx context.Context, userID, draftID uuid.UUID) error {
|
||||
if _, err := s.Get(ctx, userID, draftID); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`DELETE FROM paliad.submission_drafts WHERE id = $1 AND user_id = $2`,
|
||||
draftID, userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("delete submission draft: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkExported updates the last_exported_* columns after a successful
|
||||
// export. Background-context safe.
|
||||
func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.UUID, templateSHA string) error {
|
||||
var sha any
|
||||
if templateSHA != "" {
|
||||
sha = templateSHA
|
||||
}
|
||||
_, err := s.db.ExecContext(ctx,
|
||||
`UPDATE paliad.submission_drafts
|
||||
SET last_exported_at = now(),
|
||||
last_exported_sha = $1
|
||||
WHERE id = $2`,
|
||||
sha, draftID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("mark submission draft exported: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// BuildRenderBag composes the placeholder map for a draft — pulls
|
||||
// project/parties/rule/deadline state from SubmissionVarsService, then
|
||||
// layers the lawyer's overrides on top.
|
||||
//
|
||||
// Override semantics:
|
||||
//
|
||||
// variables[key] = "" → delete the key (force [KEIN WERT: key])
|
||||
// variables[key] = "X" → bag[key] = "X"
|
||||
// key absent → bag[key] unchanged (falls back to resolved value)
|
||||
//
|
||||
// Returns the final PlaceholderMap along with the SubmissionVarsResult
|
||||
// so callers (export, file naming) get the resolved entities too. A
|
||||
// project-less draft (ProjectID == nil, t-paliad-243) skips project /
|
||||
// parties / deadline lookups — the resolved bag carries only the
|
||||
// user-independent variables (firm, today) plus the user.* group; the
|
||||
// lawyer's overrides fill the rest.
|
||||
func (s *SubmissionDraftService) BuildRenderBag(ctx context.Context, draft *SubmissionDraft) (PlaceholderMap, *SubmissionVarsResult, error) {
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: draft.UserID,
|
||||
ProjectID: draft.ProjectID,
|
||||
SubmissionCode: draft.SubmissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
maps.Copy(bag, resolved.Placeholders)
|
||||
for k, v := range draft.Variables {
|
||||
if v == "" {
|
||||
delete(bag, k)
|
||||
continue
|
||||
}
|
||||
bag[k] = v
|
||||
}
|
||||
return bag, resolved, nil
|
||||
}
|
||||
|
||||
// RenderPreview returns the HTML preview of the merged document body
|
||||
// for the draft-editor preview pane. Read-only; emits one <p> per <w:p>
|
||||
// with <strong>/<em> spans for runs flagged bold/italic.
|
||||
func (s *SubmissionDraftService) RenderPreview(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) (string, error) {
|
||||
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return s.renderer.RenderHTML(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
||||
}
|
||||
|
||||
// Export renders the merged .docx for download. Returns the bytes, the
|
||||
// resolved bag (for audit row + file naming), and the variables result
|
||||
// (lang, rule.Name, project.case_number). Callers wire MarkExported and
|
||||
// the audit writes.
|
||||
func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDraft, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
||||
bag, resolved, err := s.BuildRenderBag(ctx, draft)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out, err := s.renderer.Render(templateBytes, bag, DefaultMissingMarker(resolved.Lang))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// RenderProjectSubmission renders the given .docx template with a fresh
|
||||
// variable bag for (user, project, submissionCode). No lawyer overrides
|
||||
// — the output reflects exactly what SubmissionVarsService resolves
|
||||
// from project state. Used by the one-click /api/projects/{id}/
|
||||
// submissions/{code}/generate path which has no saved draft row.
|
||||
//
|
||||
// Returns the merged bytes plus the resolved bag (for audit row + file
|
||||
// naming). Visibility is enforced by SubmissionVarsService.Build via
|
||||
// ProjectService.GetByID — callers get ErrNotFound on no-access.
|
||||
// ErrSubmissionRuleNotFound surfaces when no published rule matches the
|
||||
// requested submission_code.
|
||||
func (s *SubmissionDraftService) RenderProjectSubmission(ctx context.Context, userID, projectID uuid.UUID, submissionCode string, templateBytes []byte) ([]byte, *SubmissionVarsResult, error) {
|
||||
pid := projectID
|
||||
resolved, err := s.vars.Build(ctx, SubmissionVarsContext{
|
||||
UserID: userID,
|
||||
ProjectID: &pid,
|
||||
SubmissionCode: submissionCode,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
out, err := s.renderer.Render(templateBytes, resolved.Placeholders, DefaultMissingMarker(resolved.Lang))
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return out, resolved, nil
|
||||
}
|
||||
|
||||
// decodeVariables turns the raw jsonb bytes into the PlaceholderMap.
|
||||
// Called by every fetch path so the caller sees a populated Variables.
|
||||
func (d *SubmissionDraft) decodeVariables() error {
|
||||
if len(d.VariablesRaw) == 0 {
|
||||
d.Variables = PlaceholderMap{}
|
||||
return nil
|
||||
}
|
||||
out := PlaceholderMap{}
|
||||
if err := json.Unmarshal(d.VariablesRaw, &out); err != nil {
|
||||
return fmt.Errorf("decode submission draft variables: %w", err)
|
||||
}
|
||||
d.Variables = out
|
||||
return nil
|
||||
}
|
||||
|
||||
// Compile-time guard: ensure the *models.User reference in the import
|
||||
// graph doesn't get optimised away by linters. The service doesn't
|
||||
// dereference User directly — that happens in SubmissionVarsService —
|
||||
// but the import keeps the package compile-time-aware of the dependency
|
||||
// chain that wires us into the bundle.
|
||||
var _ = (*models.User)(nil)
|
||||
447
internal/services/submission_merge.go
Normal file
447
internal/services/submission_merge.go
Normal file
@@ -0,0 +1,447 @@
|
||||
package services
|
||||
|
||||
// Submission template renderer — in-house engine for the submission
|
||||
// draft editor (t-paliad-238, design doc
|
||||
// docs/design-submission-page-2026-05-22.md §3 / §6.2).
|
||||
//
|
||||
// Resurrected from commit 8ea3509 (the original t-paliad-215 Slice 1
|
||||
// "in-house .docx render engine"). Kept in a separate file from the
|
||||
// format-only converter (submission_render.go) so the t-paliad-230
|
||||
// /generate one-click path stays unchanged and the merge engine doesn't
|
||||
// have to share zip-helper names with it.
|
||||
//
|
||||
// Why not lukasjarosch/go-docx: the library's "nested placeholder" guard
|
||||
// treats sibling placeholders inside the same <w:t> run (e.g.
|
||||
// "{{a}} ./. {{b}}") as nested and refuses to replace either. Patent
|
||||
// submissions routinely have multiple placeholders per paragraph (party
|
||||
// blocks especially), so the library is a non-starter. This renderer
|
||||
// handles single-run placeholders (preserving run-level formatting) AND
|
||||
// cross-run placeholders (rewriting the paragraph as one run when Word
|
||||
// has fragmented the placeholder across runs).
|
||||
//
|
||||
// Placeholder grammar: {{[A-Za-z][A-Za-z0-9_.]*}} with optional
|
||||
// whitespace inside braces ({{ project.case_number }} ≡
|
||||
// {{project.case_number}}).
|
||||
//
|
||||
// Missing-value behaviour: when a placeholder has no binding in the
|
||||
// PlaceholderMap, the renderer emits a marker token so the lawyer sees
|
||||
// the gap in Word rather than failing the request.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PlaceholderMap is the variable bag built by SubmissionVarsService.
|
||||
// Keys are dotted paths without braces (e.g. "project.case_number").
|
||||
// Values are the substituted text — already locale-aware, pretty-
|
||||
// printed, and sanitised by the caller.
|
||||
type PlaceholderMap map[string]string
|
||||
|
||||
// MissingPlaceholderFn translates an unbound placeholder key into the
|
||||
// in-document marker token. The default in DefaultMissingMarker is
|
||||
// "[KEIN WERT: <key>]" / "[NO VALUE: <key>]" depending on lang.
|
||||
type MissingPlaceholderFn func(key string) string
|
||||
|
||||
// DefaultMissingMarker returns the standard missing-value marker for
|
||||
// the given UI language.
|
||||
func DefaultMissingMarker(lang string) MissingPlaceholderFn {
|
||||
prefix := "KEIN WERT"
|
||||
if strings.EqualFold(lang, "en") {
|
||||
prefix = "NO VALUE"
|
||||
}
|
||||
return func(key string) string {
|
||||
return "[" + prefix + ": " + key + "]"
|
||||
}
|
||||
}
|
||||
|
||||
// placeholderRegex matches a single placeholder. The capture group
|
||||
// extracts the key name without braces or surrounding whitespace.
|
||||
//
|
||||
// Restricted to [A-Za-z][A-Za-z0-9_.]* so that stray "{{" sequences in
|
||||
// legal prose don't get mistaken for placeholders. A genuine placeholder
|
||||
// always starts with an ASCII letter.
|
||||
var placeholderRegex = regexp.MustCompile(`\{\{\s*([A-Za-z][A-Za-z0-9_.]*)\s*\}\}`)
|
||||
|
||||
// SubmissionRenderer renders a .docx template into a .docx output by
|
||||
// substituting {{placeholder}} tokens with values from a PlaceholderMap.
|
||||
// Stateless; safe for concurrent use.
|
||||
type SubmissionRenderer struct{}
|
||||
|
||||
// NewSubmissionRenderer constructs the renderer.
|
||||
func NewSubmissionRenderer() *SubmissionRenderer {
|
||||
return &SubmissionRenderer{}
|
||||
}
|
||||
|
||||
// Render reads the .docx template at templateBytes, substitutes every
|
||||
// placeholder from vars (or emits the missing-marker token), and returns
|
||||
// the merged .docx bytes. Unknown placeholders never fail the render —
|
||||
// the lawyer sees the marker in Word and fixes it.
|
||||
//
|
||||
// Pre-pass: ConvertDotmToDocx is called on the input so a .dotm
|
||||
// template (macro-bearing) is downgraded to a plain .docx before the
|
||||
// merge step runs. Idempotent on inputs that are already plain .docx.
|
||||
func (r *SubmissionRenderer) Render(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) ([]byte, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: pre-pass convert: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: open zip: %w", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
zw := zip.NewWriter(&out)
|
||||
|
||||
for _, entry := range zr.File {
|
||||
body, err := readMergeZipEntry(entry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: read %s: %w", entry.Name, err)
|
||||
}
|
||||
if isWordXMLEntry(entry.Name) {
|
||||
body = substituteInDocumentXML(body, vars, missing)
|
||||
}
|
||||
w, err := zw.CreateHeader(&zip.FileHeader{
|
||||
Name: entry.Name,
|
||||
Method: entry.Method,
|
||||
Modified: entry.Modified,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("submission render: write header %s: %w", entry.Name, err)
|
||||
}
|
||||
if _, err := w.Write(body); err != nil {
|
||||
return nil, fmt.Errorf("submission render: write %s: %w", entry.Name, err)
|
||||
}
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("submission render: finalise zip: %w", err)
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
// RenderHTML produces a read-only HTML rendering of the merged document
|
||||
// body for the draft-editor preview pane. Walks the SAME placeholder
|
||||
// substitution as Render, then extracts the body text from word/document.xml
|
||||
// and emits semantic HTML — one <p> per <w:p>, with <strong>/<em> spans
|
||||
// for runs that carry <w:b>/<w:i> formatting. Tables, lists, and complex
|
||||
// formatting collapse to plain paragraphs (the preview is a fidelity
|
||||
// guide, not a WYSIWYG editor — final formatting comes from Word at
|
||||
// export).
|
||||
//
|
||||
// Returns escaped HTML safe to inject into the page via dangerouslySet
|
||||
// or innerHTML. The caller is responsible for wrapping in an outer
|
||||
// container; this method emits only the body fragment.
|
||||
func (r *SubmissionRenderer) RenderHTML(templateBytes []byte, vars PlaceholderMap, missing MissingPlaceholderFn) (string, error) {
|
||||
if missing == nil {
|
||||
missing = DefaultMissingMarker("de")
|
||||
}
|
||||
cleanBytes, err := ConvertDotmToDocx(templateBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: pre-pass convert: %w", err)
|
||||
}
|
||||
zr, err := zip.NewReader(bytes.NewReader(cleanBytes), int64(len(cleanBytes)))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: open zip: %w", err)
|
||||
}
|
||||
var docXML []byte
|
||||
for _, entry := range zr.File {
|
||||
if entry.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
docXML, err = readMergeZipEntry(entry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("submission render html: read document.xml: %w", err)
|
||||
}
|
||||
break
|
||||
}
|
||||
if docXML == nil {
|
||||
return "", fmt.Errorf("submission render html: word/document.xml missing")
|
||||
}
|
||||
merged := substituteInDocumentXML(docXML, vars, missing)
|
||||
return docXMLToHTML(merged), nil
|
||||
}
|
||||
|
||||
// isWordXMLEntry returns true for the .docx parts that contain
|
||||
// substitutable text. We touch document.xml plus header*.xml and
|
||||
// footer*.xml (templates may put firm letterhead in a header) but
|
||||
// skip styles, theme, settings, comments, footnotes — none of which
|
||||
// should carry merge placeholders in a well-formed template.
|
||||
func isWordXMLEntry(name string) bool {
|
||||
switch {
|
||||
case name == "word/document.xml":
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/header") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
case strings.HasPrefix(name, "word/footer") && strings.HasSuffix(name, ".xml"):
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// readMergeZipEntry slurps a zip entry's bytes. Named distinctly from
|
||||
// the helper in submission_render.go (readZipFile) to keep this file
|
||||
// self-contained — the two are functionally identical.
|
||||
func readMergeZipEntry(f *zip.File) ([]byte, error) {
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rc.Close()
|
||||
return io.ReadAll(rc)
|
||||
}
|
||||
|
||||
// substituteInDocumentXML walks document XML and replaces every
|
||||
// {{placeholder}} occurrence inside <w:t> text nodes. Handles both
|
||||
// single-run placeholders (the common case for freshly authored
|
||||
// templates) and cross-run placeholders (where Word's autocorrect or
|
||||
// manual editing has split a placeholder across runs).
|
||||
//
|
||||
// Two-pass strategy:
|
||||
//
|
||||
// 1. Pass 1: replace placeholders that fit entirely within one
|
||||
// <w:t>…</w:t>. This is the 99% case and preserves all run-level
|
||||
// formatting (bold, italic, font runs).
|
||||
// 2. Pass 2: for paragraphs that still contain orphan "{{" or "}}"
|
||||
// markers after pass 1, merge the text of every <w:t> inside the
|
||||
// paragraph, run the replacement on the merged text, and rewrite
|
||||
// the paragraph's runs as a single <w:r><w:t>…</w:t></w:r> using
|
||||
// the formatting properties of the first run.
|
||||
func substituteInDocumentXML(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
replaced := substituteInTextNodes(body, vars, missing)
|
||||
if !needsCrossRunMerge(replaced) {
|
||||
return replaced
|
||||
}
|
||||
return substituteAcrossRuns(replaced, vars, missing)
|
||||
}
|
||||
|
||||
// wTextNodeRegex matches one <w:t …>contents</w:t> element, capturing
|
||||
// the contents.
|
||||
var wTextNodeRegex = regexp.MustCompile(`<w:t(\s[^>]*)?>([^<]*)</w:t>`)
|
||||
|
||||
// substituteInTextNodes runs the placeholder replacement inside each
|
||||
// <w:t> text node independently. Format-preserving for single-run
|
||||
// placeholders.
|
||||
func substituteInTextNodes(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wTextNodeRegex.ReplaceAllFunc(body, func(match []byte) []byte {
|
||||
sub := wTextNodeRegex.FindSubmatch(match)
|
||||
attrs := string(sub[1])
|
||||
contents := xmlDecode(string(sub[2]))
|
||||
replaced := replacePlaceholders(contents, vars, missing)
|
||||
if replaced == contents {
|
||||
return match
|
||||
}
|
||||
if !strings.Contains(attrs, "xml:space") &&
|
||||
(strings.HasPrefix(replaced, " ") || strings.HasSuffix(replaced, " ")) {
|
||||
attrs += ` xml:space="preserve"`
|
||||
}
|
||||
return []byte(`<w:t` + attrs + `>` + xmlEncode(replaced) + `</w:t>`)
|
||||
})
|
||||
}
|
||||
|
||||
// needsCrossRunMerge returns true when the body still contains an
|
||||
// unmatched "{{" or "}}" inside any <w:t> after pass 1.
|
||||
func needsCrossRunMerge(body []byte) bool {
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(body, -1) {
|
||||
t := string(m[2])
|
||||
if strings.Contains(t, "{{") || strings.Contains(t, "}}") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// wParagraphRegex matches one <w:p>…</w:p> paragraph block. Greedy
|
||||
// inner-content match is safe — <w:p> elements do not nest.
|
||||
var wParagraphRegex = regexp.MustCompile(`(?s)<w:p\b[^>]*>.*?</w:p>`)
|
||||
|
||||
// wRunPropsRegex pulls the first <w:rPr>…</w:rPr> block from a paragraph.
|
||||
var wRunPropsRegex = regexp.MustCompile(`(?s)<w:rPr>.*?</w:rPr>`)
|
||||
|
||||
// wParagraphPropsRegex pulls the optional <w:pPr>…</w:pPr>.
|
||||
var wParagraphPropsRegex = regexp.MustCompile(`(?s)<w:pPr>.*?</w:pPr>`)
|
||||
|
||||
// substituteAcrossRuns is pass 2: concatenate every text node in a
|
||||
// fragmented-placeholder paragraph, run replacement, rewrite as one run.
|
||||
func substituteAcrossRuns(body []byte, vars PlaceholderMap, missing MissingPlaceholderFn) []byte {
|
||||
return wParagraphRegex.ReplaceAllFunc(body, func(para []byte) []byte {
|
||||
textNodes := wTextNodeRegex.FindAllSubmatch(para, -1)
|
||||
if len(textNodes) == 0 {
|
||||
return para
|
||||
}
|
||||
var merged strings.Builder
|
||||
for _, m := range textNodes {
|
||||
merged.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
original := merged.String()
|
||||
if !strings.Contains(original, "{{") {
|
||||
return para
|
||||
}
|
||||
replaced := replacePlaceholders(original, vars, missing)
|
||||
if replaced == original {
|
||||
return para
|
||||
}
|
||||
pPr := wParagraphPropsRegex.Find(para)
|
||||
rPr := wRunPropsRegex.Find(para)
|
||||
var rebuilt bytes.Buffer
|
||||
rebuilt.WriteString(`<w:p>`)
|
||||
if pPr != nil {
|
||||
rebuilt.Write(pPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:r>`)
|
||||
if rPr != nil {
|
||||
rebuilt.Write(rPr)
|
||||
}
|
||||
rebuilt.WriteString(`<w:t xml:space="preserve">`)
|
||||
rebuilt.WriteString(xmlEncode(replaced))
|
||||
rebuilt.WriteString(`</w:t></w:r></w:p>`)
|
||||
return rebuilt.Bytes()
|
||||
})
|
||||
}
|
||||
|
||||
// replacePlaceholders performs the actual substitution on a plain
|
||||
// string. Unbound placeholders render the missing marker.
|
||||
func replacePlaceholders(s string, vars PlaceholderMap, missing MissingPlaceholderFn) string {
|
||||
return placeholderRegex.ReplaceAllStringFunc(s, func(match string) string {
|
||||
sub := placeholderRegex.FindStringSubmatch(match)
|
||||
if len(sub) < 2 {
|
||||
return match
|
||||
}
|
||||
key := sub[1]
|
||||
if value, ok := vars[key]; ok {
|
||||
return value
|
||||
}
|
||||
return missing(key)
|
||||
})
|
||||
}
|
||||
|
||||
// xmlDecode reverses the five standard XML entities Word emits in
|
||||
// <w:t> content.
|
||||
func xmlDecode(s string) string {
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, """, `"`)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
return s
|
||||
}
|
||||
|
||||
// xmlEncode escapes for safe insertion back into <w:t> content. & first
|
||||
// to avoid double-encoding the entity prefixes.
|
||||
func xmlEncode(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
|
||||
// docXMLToHTML walks the post-merge document XML and emits HTML for
|
||||
// the preview pane. One <p> per <w:p>; <strong>/<em> spans for runs
|
||||
// carrying <w:b>/<w:i>. Tables/lists/images collapse to text. Output
|
||||
// is HTML-escaped except for the structural <p>/<strong>/<em> tags
|
||||
// this function emits.
|
||||
func docXMLToHTML(docXML []byte) string {
|
||||
paragraphs := wParagraphRegex.FindAll(docXML, -1)
|
||||
var out bytes.Buffer
|
||||
for _, para := range paragraphs {
|
||||
out.WriteString("<p>")
|
||||
out.WriteString(paragraphToHTML(para))
|
||||
out.WriteString("</p>\n")
|
||||
}
|
||||
if out.Len() == 0 {
|
||||
return "<p></p>"
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// wRunRegex matches one <w:r>…</w:r> run. Greedy match safe — <w:r>
|
||||
// elements do not nest.
|
||||
var wRunRegex = regexp.MustCompile(`(?s)<w:r\b[^>]*>.*?</w:r>`)
|
||||
|
||||
// wBoldRegex / wItalicRegex detect the bold/italic flags inside a run's
|
||||
// <w:rPr>. Word emits <w:b/> or <w:b w:val="true"/>; matching the open
|
||||
// tag covers both forms.
|
||||
var (
|
||||
wBoldRegex = regexp.MustCompile(`<w:b\b[^>]*/?>`)
|
||||
wItalicRegex = regexp.MustCompile(`<w:i\b[^>]*/?>`)
|
||||
)
|
||||
|
||||
// paragraphToHTML extracts the text from each <w:r> inside a paragraph,
|
||||
// wraps runs flagged bold/italic with the corresponding HTML tags, and
|
||||
// HTML-escapes the text content.
|
||||
func paragraphToHTML(para []byte) string {
|
||||
runs := wRunRegex.FindAll(para, -1)
|
||||
if len(runs) == 0 {
|
||||
// Empty paragraph (line break).
|
||||
return ""
|
||||
}
|
||||
var out bytes.Buffer
|
||||
for _, run := range runs {
|
||||
text := extractRunText(run)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
// Check for bold/italic on the run's <w:rPr>.
|
||||
rPr := wRunPropsRegex.Find(run)
|
||||
bold := rPr != nil && wBoldRegex.Match(rPr) && !isFalseFlag(rPr, wBoldRegex)
|
||||
italic := rPr != nil && wItalicRegex.Match(rPr) && !isFalseFlag(rPr, wItalicRegex)
|
||||
|
||||
if bold {
|
||||
out.WriteString("<strong>")
|
||||
}
|
||||
if italic {
|
||||
out.WriteString("<em>")
|
||||
}
|
||||
out.WriteString(htmlEscape(text))
|
||||
if italic {
|
||||
out.WriteString("</em>")
|
||||
}
|
||||
if bold {
|
||||
out.WriteString("</strong>")
|
||||
}
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// extractRunText concatenates every <w:t> inside a run, XML-decoding
|
||||
// the content as it goes.
|
||||
func extractRunText(run []byte) string {
|
||||
var out strings.Builder
|
||||
for _, m := range wTextNodeRegex.FindAllSubmatch(run, -1) {
|
||||
out.WriteString(xmlDecode(string(m[2])))
|
||||
}
|
||||
return out.String()
|
||||
}
|
||||
|
||||
// isFalseFlag returns true if the matched tag explicitly carries
|
||||
// w:val="false" or w:val="0" — Word's way of turning off an inherited
|
||||
// format. The default match (just `<w:b/>` or `<w:b w:val="true"/>`)
|
||||
// is "on".
|
||||
func isFalseFlag(rPr []byte, rx *regexp.Regexp) bool {
|
||||
match := rx.Find(rPr)
|
||||
if match == nil {
|
||||
return false
|
||||
}
|
||||
s := string(match)
|
||||
return strings.Contains(s, `w:val="false"`) || strings.Contains(s, `w:val="0"`)
|
||||
}
|
||||
|
||||
// htmlEscape escapes the five HTML-significant characters for safe
|
||||
// insertion into the preview pane.
|
||||
func htmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
307
internal/services/submission_merge_test.go
Normal file
307
internal/services/submission_merge_test.go
Normal file
@@ -0,0 +1,307 @@
|
||||
package services
|
||||
|
||||
// Submission merge-engine tests — resurrected from the original
|
||||
// t-paliad-215 Slice 1 (commit 8ea3509) + Slice 2 (commit 1765d5e).
|
||||
// Adapted: helper names suffixed with "Merge" so they don't collide
|
||||
// with the convert tests in submission_render_test.go (minimalDOTM,
|
||||
// unzipEntries) that test the format-only ConvertDotmToDocx path.
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// minimalMergeDOCX builds a tiny .docx zip with one document.xml that
|
||||
// contains the given body. Just enough to exercise the merge engine.
|
||||
func minimalMergeDOCX(t *testing.T, documentBody string) []byte {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
w, err := zw.Create("word/document.xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create document.xml: %v", err)
|
||||
}
|
||||
if _, err := io.WriteString(w, documentBody); err != nil {
|
||||
t.Fatalf("write document.xml: %v", err)
|
||||
}
|
||||
w2, err := zw.Create("[Content_Types].xml")
|
||||
if err != nil {
|
||||
t.Fatalf("create content types: %v", err)
|
||||
}
|
||||
// Use a docx-compatible content type so the convert pre-pass treats
|
||||
// the input as already-clean (no .dotm rewrites needed).
|
||||
body := `<?xml version="1.0"?><Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
|
||||
`<Override PartName="/word/document.xml" ContentType="` + docxMainContentType + `"/></Types>`
|
||||
if _, err := io.WriteString(w2, body); err != nil {
|
||||
t.Fatalf("write content types: %v", err)
|
||||
}
|
||||
if err := zw.Close(); err != nil {
|
||||
t.Fatalf("close zip: %v", err)
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// readMergeDocumentXML pulls word/document.xml out of a rendered .docx.
|
||||
func readMergeDocumentXML(t *testing.T, b []byte) string {
|
||||
t.Helper()
|
||||
zr, err := zip.NewReader(bytes.NewReader(b), int64(len(b)))
|
||||
if err != nil {
|
||||
t.Fatalf("open rendered zip: %v", err)
|
||||
}
|
||||
for _, f := range zr.File {
|
||||
if f.Name != "word/document.xml" {
|
||||
continue
|
||||
}
|
||||
rc, err := f.Open()
|
||||
if err != nil {
|
||||
t.Fatalf("open document.xml: %v", err)
|
||||
}
|
||||
defer rc.Close()
|
||||
body, err := io.ReadAll(rc)
|
||||
if err != nil {
|
||||
t.Fatalf("read document.xml: %v", err)
|
||||
}
|
||||
return string(body)
|
||||
}
|
||||
t.Fatal("rendered .docx had no word/document.xml")
|
||||
return ""
|
||||
}
|
||||
|
||||
func TestRender_SingleRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{firm.name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, ">HLC<") {
|
||||
t.Errorf("expected HLC in body, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MultiplePlaceholdersPerRun(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{parties.claimant.name}}, vertreten durch {{parties.claimant.representative}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"parties.claimant.name": "Acme Inc.",
|
||||
"parties.claimant.representative": "Kanzlei Müller",
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Acme Inc.") || !strings.Contains(body, "Kanzlei Müller") {
|
||||
t.Errorf("expected both party values, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("unreplaced placeholder marker in body: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_MissingMarker(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{project.case_number}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("de"))
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "[KEIN WERT: project.case_number]") {
|
||||
t.Errorf("expected KEIN WERT marker, got %q", body)
|
||||
}
|
||||
outEN, err := r.Render(tmpl, PlaceholderMap{}, DefaultMissingMarker("en"))
|
||||
if err != nil {
|
||||
t.Fatalf("render en: %v", err)
|
||||
}
|
||||
bodyEN := readMergeDocumentXML(t, outEN)
|
||||
if !strings.Contains(bodyEN, "[NO VALUE: project.case_number]") {
|
||||
t.Errorf("expected NO VALUE marker, got %q", bodyEN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_CrossRunPlaceholder(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>Hello {{</w:t></w:r><w:r><w:t>project</w:t></w:r><w:r><w:t>.case_number}}!</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{"project.case_number": "7 O 1234/26"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "7 O 1234/26") {
|
||||
t.Errorf("expected case number after cross-run merge, got %q", body)
|
||||
}
|
||||
if strings.Contains(body, "{{") {
|
||||
t.Errorf("orphan placeholder marker remained: %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRender_XMLEscaping(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
out, err := r.Render(tmpl, PlaceholderMap{
|
||||
"user.display_name": `Müller & Söhne <GmbH> "Special"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render: %v", err)
|
||||
}
|
||||
body := readMergeDocumentXML(t, out)
|
||||
if !strings.Contains(body, "Müller & Söhne <GmbH> "Special"") {
|
||||
t.Errorf("expected escaped value, got %q", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceholderRegex_Boundaries(t *testing.T) {
|
||||
tests := []struct {
|
||||
in string
|
||||
matches []string
|
||||
}{
|
||||
{"plain text", nil},
|
||||
{"{{foo}}", []string{"{{foo}}"}},
|
||||
{"{{ foo }}", []string{"{{ foo }}"}},
|
||||
{"{{foo.bar}}", []string{"{{foo.bar}}"}},
|
||||
{"{{ foo.bar_baz }}", []string{"{{ foo.bar_baz }}"}},
|
||||
{"{{1bad}}", nil},
|
||||
{"{{ foo }} and {{ bar }}", []string{"{{ foo }}", "{{ bar }}"}},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := placeholderRegex.FindAllString(tc.in, -1)
|
||||
if len(got) != len(tc.matches) {
|
||||
t.Fatalf("got %d matches, want %d (in=%q)", len(got), len(tc.matches), tc.in)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.matches[i] {
|
||||
t.Errorf("match %d: got %q, want %q", i, got[i], tc.matches[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLegalSourcePretty(t *testing.T) {
|
||||
tests := []struct {
|
||||
src, lang, want string
|
||||
}{
|
||||
{"DE.ZPO.276.1", "de", "§ 276 Abs. 1 ZPO"},
|
||||
{"DE.ZPO.276.1", "en", "Section 276(1) ZPO"},
|
||||
{"DE.ZPO.253", "de", "§ 253 ZPO"},
|
||||
{"DE.ZPO.253", "en", "Section 253 ZPO"},
|
||||
{"UPC.RoP.23.1", "de", "Regel 23.1 VerfO UPC"},
|
||||
{"UPC.RoP.23.1", "en", "Rule 23.1 RoP UPC"},
|
||||
{"UPC.RoP.198", "de", "Regel 198 VerfO UPC"},
|
||||
{"DE.PatG.83", "de", "§ 83 PatG"},
|
||||
{"EPC.123", "de", "Art. 123 EPÜ"},
|
||||
{"EPC.123", "en", "Art. 123 EPC"},
|
||||
{"FOO.BAR.123", "de", "FOO.BAR.123"},
|
||||
{"", "de", ""},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.src+"/"+tc.lang, func(t *testing.T) {
|
||||
got := legalSourcePretty(tc.src, tc.lang)
|
||||
if got != tc.want {
|
||||
t.Errorf("legalSourcePretty(%q, %q) = %q, want %q", tc.src, tc.lang, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOurSideTranslations(t *testing.T) {
|
||||
cases := []struct {
|
||||
in, wantDE, wantEN string
|
||||
}{
|
||||
{"claimant", "Klägerin", "Claimant"},
|
||||
{"defendant", "Beklagte", "Defendant"},
|
||||
{"court", "Gericht", "Court"},
|
||||
{"both", "Klägerin und Beklagte", "Claimant and Defendant"},
|
||||
{"", "", ""},
|
||||
{"unknown", "", ""},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
if got := ourSideDE(tc.in); got != tc.wantDE {
|
||||
t.Errorf("ourSideDE(%q) = %q, want %q", tc.in, got, tc.wantDE)
|
||||
}
|
||||
if got := ourSideEN(tc.in); got != tc.wantEN {
|
||||
t.Errorf("ourSideEN(%q) = %q, want %q", tc.in, got, tc.wantEN)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPatentNumberUPC(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"EP 1 234 567 B1", "EP 1 234 567 (B1)"},
|
||||
{"EP 4 056 049 A1", "EP 4 056 049 (A1)"},
|
||||
{"DE 10 2020 123 456 A1", "DE 10 2020 123 456 (A1)"},
|
||||
{"EP 1 234 567", "EP 1 234 567"},
|
||||
{" EP 1 234 567 B1 ", "EP 1 234 567 (B1)"},
|
||||
{"", ""},
|
||||
{"WO/2023/123456", "WO/2023/123456"},
|
||||
{"EP 1 234 567 B12", "EP 1 234 567 B12"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.in, func(t *testing.T) {
|
||||
got := patentNumberUPC(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("patentNumberUPC(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_ExtractsParagraphsAndFormatting verifies the preview
|
||||
// HTML emitter walks <w:p> / <w:r> / <w:t> correctly and carries
|
||||
// bold/italic through to <strong>/<em>.
|
||||
func TestRenderHTML_ExtractsParagraphsAndFormatting(t *testing.T) {
|
||||
doc := `<w:document><w:body>` +
|
||||
`<w:p><w:r><w:t>Hello {{firm.name}}</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:rPr><w:b/></w:rPr><w:t>Bold line</w:t></w:r></w:p>` +
|
||||
`<w:p><w:r><w:rPr><w:i/></w:rPr><w:t>Italic line</w:t></w:r></w:p>` +
|
||||
`</w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{"firm.name": "HLC"}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "<p>Hello HLC</p>") {
|
||||
t.Errorf("expected merged paragraph, got %q", html)
|
||||
}
|
||||
if !strings.Contains(html, "<strong>Bold line</strong>") {
|
||||
t.Errorf("expected bold span, got %q", html)
|
||||
}
|
||||
if !strings.Contains(html, "<em>Italic line</em>") {
|
||||
t.Errorf("expected italic span, got %q", html)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRenderHTML_EscapesContent confirms the preview emitter HTML-escapes
|
||||
// special characters in placeholder values.
|
||||
func TestRenderHTML_EscapesContent(t *testing.T) {
|
||||
doc := `<w:document><w:body><w:p><w:r><w:t>{{user.display_name}}</w:t></w:r></w:p></w:body></w:document>`
|
||||
tmpl := minimalMergeDOCX(t, doc)
|
||||
r := NewSubmissionRenderer()
|
||||
html, err := r.RenderHTML(tmpl, PlaceholderMap{
|
||||
"user.display_name": `M&S <Inc> "X"`,
|
||||
}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("render html: %v", err)
|
||||
}
|
||||
if !strings.Contains(html, "M&S <Inc> "X"") {
|
||||
t.Errorf("expected escaped value in HTML, got %q", html)
|
||||
}
|
||||
}
|
||||
554
internal/services/submission_vars.go
Normal file
554
internal/services/submission_vars.go
Normal file
@@ -0,0 +1,554 @@
|
||||
package services
|
||||
|
||||
// Submission variable bag — builds the PlaceholderMap that
|
||||
// SubmissionRenderer fills into a template (t-paliad-215, design doc
|
||||
// docs/design-submission-generator-2026-05-19.md §6.2).
|
||||
//
|
||||
// Variables span six namespaces:
|
||||
//
|
||||
// firm.* process-wide (branding.Name)
|
||||
// user.* caller's user row
|
||||
// today.* server time in Europe/Berlin, locale-aware
|
||||
// project.* paliad.projects + joined proceeding type
|
||||
// parties.* paliad.parties grouped by role
|
||||
// rule.* paliad.deadline_rules row keyed by submission_code
|
||||
// deadline.* next open paliad.deadlines row for (project, rule), if any
|
||||
//
|
||||
// Locale handling: every long-form date string is computed in both DE
|
||||
// and EN; the renderer picks based on the user's lang preference. The
|
||||
// rule pretty-printer (legalSourcePretty) also has DE/EN variants.
|
||||
//
|
||||
// Visibility: caller passes userID; ProjectService.GetByID enforces
|
||||
// paliad.can_see_project — unauthorised callers get the standard
|
||||
// ErrNotFound before any variable construction runs.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jmoiron/sqlx"
|
||||
|
||||
"mgit.msbls.de/m/paliad/internal/branding"
|
||||
"mgit.msbls.de/m/paliad/internal/models"
|
||||
)
|
||||
|
||||
// SubmissionVarsService assembles the placeholder map.
|
||||
type SubmissionVarsService struct {
|
||||
db *sqlx.DB
|
||||
projects *ProjectService
|
||||
parties *PartyService
|
||||
users *UserService
|
||||
}
|
||||
|
||||
// NewSubmissionVarsService wires the service.
|
||||
func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *PartyService, users *UserService) *SubmissionVarsService {
|
||||
return &SubmissionVarsService{
|
||||
db: db,
|
||||
projects: projects,
|
||||
parties: parties,
|
||||
users: users,
|
||||
}
|
||||
}
|
||||
|
||||
// SubmissionVarsContext is the input bundle that produces a render.
|
||||
//
|
||||
// ProjectID is optional since t-paliad-243 — a global Schriftsatz draft
|
||||
// started from /submissions/new without picking a project carries
|
||||
// nil here and the project / parties / deadline lookups are skipped.
|
||||
type SubmissionVarsContext struct {
|
||||
UserID uuid.UUID
|
||||
ProjectID *uuid.UUID
|
||||
SubmissionCode string
|
||||
}
|
||||
|
||||
// SubmissionVarsResult bundles the placeholder map with the lookup
|
||||
// values the handler needs for the audit row + file naming
|
||||
// (rule.Name, project.case_number, etc.).
|
||||
type SubmissionVarsResult struct {
|
||||
Placeholders PlaceholderMap
|
||||
|
||||
// Resolved entities for audit + naming.
|
||||
User *models.User
|
||||
Project *models.Project
|
||||
Rule *models.DeadlineRule
|
||||
ProceedingType *models.ProceedingType
|
||||
Parties []models.Party
|
||||
NextDeadline *models.Deadline
|
||||
|
||||
// Lang is the user's UI language used to pick locale-aware values
|
||||
// during the build. Returned so the renderer can use the matching
|
||||
// missing-marker function.
|
||||
Lang string
|
||||
}
|
||||
|
||||
// ErrSubmissionRuleNotFound is returned when no published deadline_rule
|
||||
// matches the requested submission_code. Maps to 404 in the handler.
|
||||
var ErrSubmissionRuleNotFound = errors.New("submission generator: no rule found for submission_code")
|
||||
|
||||
// Build resolves every entity and assembles the placeholder map. A nil
|
||||
// ProjectID skips project / parties / deadline lookups — the resolved
|
||||
// bag carries only firm.*, today.*, user.* and rule.* in that case;
|
||||
// every other placeholder falls through to the lawyer's overrides via
|
||||
// SubmissionDraftService.BuildRenderBag.
|
||||
func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsContext) (*SubmissionVarsResult, error) {
|
||||
if s.projects == nil || s.users == nil {
|
||||
return nil, fmt.Errorf("submission vars: required services not wired")
|
||||
}
|
||||
|
||||
user, err := s.users.GetByID(ctx, in.UserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if user == nil {
|
||||
return nil, ErrNotVisible
|
||||
}
|
||||
|
||||
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
lang := user.Lang
|
||||
if lang == "" {
|
||||
lang = "de"
|
||||
}
|
||||
bag := PlaceholderMap{}
|
||||
addFirmVars(bag)
|
||||
addTodayVars(bag, time.Now())
|
||||
addUserVars(bag, user)
|
||||
addRuleVars(bag, rule, lang)
|
||||
|
||||
out := &SubmissionVarsResult{
|
||||
Placeholders: bag,
|
||||
User: user,
|
||||
Rule: rule,
|
||||
Lang: lang,
|
||||
}
|
||||
|
||||
if in.ProjectID == nil {
|
||||
// Project-less draft (t-paliad-243): no project / parties /
|
||||
// deadline state to resolve. The lawyer's overrides will fill
|
||||
// the placeholder map; missing keys render as
|
||||
// [KEIN WERT: …] / [NO VALUE: …] in the preview.
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// Visibility gate — GetByID returns ErrNotFound when the user
|
||||
// can't see the project, which is exactly the 404 the handler
|
||||
// wants to propagate.
|
||||
project, err := s.projects.GetByID(ctx, in.UserID, *in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pt, err := s.loadProceedingType(ctx, project.ProceedingTypeID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
parties, err := s.parties.ListForProject(ctx, in.UserID, *in.ProjectID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
next, err := s.nextOpenDeadline(ctx, *in.ProjectID, rule.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
addProjectVars(bag, project, pt, lang)
|
||||
addPartyVars(bag, parties)
|
||||
addDeadlineVars(bag, next, project, lang)
|
||||
|
||||
out.Project = project
|
||||
out.ProceedingType = pt
|
||||
out.Parties = parties
|
||||
out.NextDeadline = next
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// loadPublishedRule fetches the deadline_rule that owns the given
|
||||
// submission_code. Restricts to lifecycle_state='published' so drafts
|
||||
// never end up shaping a real submission.
|
||||
func (s *SubmissionVarsService) loadPublishedRule(ctx context.Context, submissionCode string) (*models.DeadlineRule, error) {
|
||||
if submissionCode == "" {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
var rule models.DeadlineRule
|
||||
err := s.db.GetContext(ctx, &rule,
|
||||
`SELECT `+ruleColumns+`
|
||||
FROM paliad.deadline_rules
|
||||
WHERE submission_code = $1
|
||||
AND lifecycle_state = 'published'
|
||||
AND is_active = true
|
||||
ORDER BY sequence_order
|
||||
LIMIT 1`, submissionCode)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, ErrSubmissionRuleNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load rule by submission_code %q: %w", submissionCode, err)
|
||||
}
|
||||
return &rule, nil
|
||||
}
|
||||
|
||||
// loadProceedingType fetches the proceeding type row for the project's
|
||||
// proceeding_type_id. Tolerates a nil id (returns nil, nil) so projects
|
||||
// without a bound proceeding still render a meaningful template — the
|
||||
// {{project.proceeding.*}} placeholders just resolve to the missing
|
||||
// marker.
|
||||
func (s *SubmissionVarsService) loadProceedingType(ctx context.Context, id *int) (*models.ProceedingType, error) {
|
||||
if id == nil {
|
||||
return nil, nil
|
||||
}
|
||||
var pt models.ProceedingType
|
||||
err := s.db.GetContext(ctx, &pt,
|
||||
`SELECT `+proceedingTypeColumns+`
|
||||
FROM paliad.proceeding_types
|
||||
WHERE id = $1`, *id)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load proceeding type %d: %w", *id, err)
|
||||
}
|
||||
return &pt, nil
|
||||
}
|
||||
|
||||
// nextOpenDeadline finds the earliest pending paliad.deadlines row on
|
||||
// the given project that maps to the chosen rule. Returns (nil, nil)
|
||||
// when no matching deadline exists — common when the lawyer is drafting
|
||||
// the submission before the system has computed its deadline row.
|
||||
func (s *SubmissionVarsService) nextOpenDeadline(ctx context.Context, projectID, ruleID uuid.UUID) (*models.Deadline, error) {
|
||||
var d models.Deadline
|
||||
err := s.db.GetContext(ctx, &d,
|
||||
`SELECT id, project_id, title, description, due_date, original_due_date,
|
||||
warning_date, source, rule_id, rule_code, status, completed_at,
|
||||
caldav_uid, caldav_etag, notes, created_by, created_at, updated_at,
|
||||
approval_status, pending_request_id, approved_by, approved_at
|
||||
FROM paliad.deadlines
|
||||
WHERE project_id = $1
|
||||
AND rule_id = $2
|
||||
AND status = 'pending'
|
||||
ORDER BY due_date ASC
|
||||
LIMIT 1`, projectID, ruleID)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load next deadline (project=%s rule=%s): %w", projectID, ruleID, err)
|
||||
}
|
||||
return &d, nil
|
||||
}
|
||||
|
||||
// addFirmVars populates the firm.* namespace.
|
||||
func addFirmVars(bag PlaceholderMap) {
|
||||
bag["firm.name"] = branding.Name
|
||||
// firm.signature_block is reserved for Phase 2; emit empty so
|
||||
// templates that already reference it don't render the missing
|
||||
// marker (less noisy for the lawyer).
|
||||
bag["firm.signature_block"] = ""
|
||||
}
|
||||
|
||||
// addTodayVars populates today.* in both DE and EN long forms. ISO
|
||||
// short form is the default {{today}}.
|
||||
func addTodayVars(bag PlaceholderMap, now time.Time) {
|
||||
loc, _ := time.LoadLocation("Europe/Berlin")
|
||||
if loc != nil {
|
||||
now = now.In(loc)
|
||||
}
|
||||
bag["today"] = now.Format("2006-01-02")
|
||||
bag["today.iso"] = now.Format("2006-01-02")
|
||||
bag["today.long_de"] = formatLongDateDE(now)
|
||||
bag["today.long_en"] = formatLongDateEN(now)
|
||||
}
|
||||
|
||||
// addUserVars populates user.*.
|
||||
func addUserVars(bag PlaceholderMap, u *models.User) {
|
||||
bag["user.display_name"] = u.DisplayName
|
||||
bag["user.email"] = u.Email
|
||||
bag["user.office"] = u.Office
|
||||
}
|
||||
|
||||
// addProjectVars populates project.* — title / case_number / court /
|
||||
// patent_number / dates / our_side / proceeding metadata.
|
||||
func addProjectVars(bag PlaceholderMap, p *models.Project, pt *models.ProceedingType, lang string) {
|
||||
bag["project.title"] = p.Title
|
||||
bag["project.reference"] = derefString(p.Reference)
|
||||
bag["project.case_number"] = derefString(p.CaseNumber)
|
||||
bag["project.court"] = derefString(p.Court)
|
||||
bag["project.patent_number"] = derefString(p.PatentNumber)
|
||||
// project.patent_number_upc is the UPC-brief convention — kind code
|
||||
// parenthesised ("EP 1 234 567 (B1)") instead of the DE form
|
||||
// ("EP 1 234 567 B1"). Pure-function rewrite; pass-through when no
|
||||
// kind code is present so the lawyer's draft never sees a worse
|
||||
// number than the source value.
|
||||
bag["project.patent_number_upc"] = patentNumberUPC(derefString(p.PatentNumber))
|
||||
bag["project.filing_date"] = formatDatePtr(p.FilingDate, "2006-01-02")
|
||||
bag["project.grant_date"] = formatDatePtr(p.GrantDate, "2006-01-02")
|
||||
bag["project.our_side"] = derefString(p.OurSide)
|
||||
bag["project.our_side_de"] = ourSideDE(derefString(p.OurSide))
|
||||
bag["project.our_side_en"] = ourSideEN(derefString(p.OurSide))
|
||||
bag["project.instance_level"] = derefString(p.InstanceLevel)
|
||||
bag["project.client_number"] = derefString(p.ClientNumber)
|
||||
bag["project.matter_number"] = derefString(p.MatterNumber)
|
||||
if pt != nil {
|
||||
bag["project.proceeding.code"] = pt.Code
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["project.proceeding.name"] = pt.NameEN
|
||||
} else {
|
||||
bag["project.proceeding.name"] = pt.Name
|
||||
}
|
||||
bag["project.proceeding.name_de"] = pt.Name
|
||||
bag["project.proceeding.name_en"] = pt.NameEN
|
||||
}
|
||||
}
|
||||
|
||||
// addPartyVars populates parties.* using the first row of each role.
|
||||
// Multi-claimant / multi-defendant suits use the first row in Slice 1
|
||||
// per design §13.6; expanded grouping is Phase 2.
|
||||
func addPartyVars(bag PlaceholderMap, parties []models.Party) {
|
||||
var claimant, defendant, other *models.Party
|
||||
for i := range parties {
|
||||
role := strings.ToLower(strings.TrimSpace(derefString(parties[i].Role)))
|
||||
switch role {
|
||||
case "claimant", "kläger", "klaeger":
|
||||
if claimant == nil {
|
||||
claimant = &parties[i]
|
||||
}
|
||||
case "defendant", "beklagter", "beklagte":
|
||||
if defendant == nil {
|
||||
defendant = &parties[i]
|
||||
}
|
||||
default:
|
||||
if other == nil {
|
||||
other = &parties[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
if claimant != nil {
|
||||
bag["parties.claimant.name"] = claimant.Name
|
||||
bag["parties.claimant.representative"] = derefString(claimant.Representative)
|
||||
}
|
||||
if defendant != nil {
|
||||
bag["parties.defendant.name"] = defendant.Name
|
||||
bag["parties.defendant.representative"] = derefString(defendant.Representative)
|
||||
}
|
||||
if other != nil {
|
||||
bag["parties.other.name"] = other.Name
|
||||
bag["parties.other.representative"] = derefString(other.Representative)
|
||||
}
|
||||
}
|
||||
|
||||
// addRuleVars populates rule.* — submission_code, name(_en),
|
||||
// legal_source (+ pretty form), primary_party, event_type.
|
||||
func addRuleVars(bag PlaceholderMap, r *models.DeadlineRule, lang string) {
|
||||
bag["rule.submission_code"] = derefString(r.SubmissionCode)
|
||||
if strings.EqualFold(lang, "en") {
|
||||
bag["rule.name"] = r.NameEN
|
||||
} else {
|
||||
bag["rule.name"] = r.Name
|
||||
}
|
||||
bag["rule.name_de"] = r.Name
|
||||
bag["rule.name_en"] = r.NameEN
|
||||
bag["rule.legal_source"] = derefString(r.LegalSource)
|
||||
bag["rule.legal_source_pretty"] = legalSourcePretty(derefString(r.LegalSource), lang)
|
||||
bag["rule.primary_party"] = derefString(r.PrimaryParty)
|
||||
bag["rule.event_type"] = derefString(r.EventType)
|
||||
}
|
||||
|
||||
// addDeadlineVars populates deadline.* from the next pending row. When
|
||||
// no row exists the values fall through to the missing marker — the
|
||||
// lawyer sees [KEIN WERT: deadline.due_date] in Word and knows to fix.
|
||||
func addDeadlineVars(bag PlaceholderMap, d *models.Deadline, p *models.Project, lang string) {
|
||||
if d == nil {
|
||||
return
|
||||
}
|
||||
bag["deadline.due_date"] = d.DueDate.Format("2006-01-02")
|
||||
bag["deadline.due_date_long_de"] = formatLongDateDE(d.DueDate)
|
||||
bag["deadline.due_date_long_en"] = formatLongDateEN(d.DueDate)
|
||||
if d.OriginalDueDate != nil {
|
||||
bag["deadline.original_due_date"] = d.OriginalDueDate.Format("2006-01-02")
|
||||
}
|
||||
// computed_from carries the human-readable anchor description
|
||||
// (e.g. "Klagezustellung am 14.05.2026 + 6 Wochen"). Notes is
|
||||
// the closest existing field — the calculator stores anchor
|
||||
// metadata there. If empty we leave the placeholder unresolved.
|
||||
if d.Notes != nil && strings.TrimSpace(*d.Notes) != "" {
|
||||
bag["deadline.computed_from"] = strings.TrimSpace(*d.Notes)
|
||||
}
|
||||
bag["deadline.title"] = d.Title
|
||||
bag["deadline.source"] = d.Source
|
||||
_ = p // reserved for future shape decisions where the deadline
|
||||
// var depends on project context.
|
||||
_ = lang
|
||||
}
|
||||
|
||||
// derefString returns *s or "" when s is nil.
|
||||
func derefString(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// formatDatePtr formats a *time.Time, returning "" for nil.
|
||||
func formatDatePtr(t *time.Time, layout string) string {
|
||||
if t == nil {
|
||||
return ""
|
||||
}
|
||||
return t.Format(layout)
|
||||
}
|
||||
|
||||
// ourSideDE returns the German legal-prose form of an our_side value.
|
||||
func ourSideDE(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Klägerin"
|
||||
case "defendant":
|
||||
return "Beklagte"
|
||||
case "court":
|
||||
return "Gericht"
|
||||
case "both":
|
||||
return "Klägerin und Beklagte"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ourSideEN returns the English legal-prose form of an our_side value.
|
||||
func ourSideEN(side string) string {
|
||||
switch strings.ToLower(side) {
|
||||
case "claimant":
|
||||
return "Claimant"
|
||||
case "defendant":
|
||||
return "Defendant"
|
||||
case "court":
|
||||
return "Court"
|
||||
case "both":
|
||||
return "Claimant and Defendant"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// formatLongDateDE renders a date in the German long form
|
||||
// ("19. Mai 2026"). Pure function for unit testing.
|
||||
func formatLongDateDE(t time.Time) string {
|
||||
months := []string{
|
||||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||||
}
|
||||
idx := int(t.Month()) - 1
|
||||
if idx < 0 || idx >= len(months) {
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
return fmt.Sprintf("%d. %s %d", t.Day(), months[idx], t.Year())
|
||||
}
|
||||
|
||||
// formatLongDateEN renders a date in the English long form
|
||||
// ("19 May 2026").
|
||||
func formatLongDateEN(t time.Time) string {
|
||||
return t.Format("2 January 2006")
|
||||
}
|
||||
|
||||
// legalSourcePretty rewrites the shorthand stored on deadline_rules
|
||||
// (DE.ZPO.276.1, UPC.RoP.23.1, …) into the form a lawyer would type
|
||||
// in a brief ("§ 276 Abs. 1 ZPO", "Rule 23.1 RoP UPC"). Unknown
|
||||
// prefixes pass through unchanged — preferring the raw shorthand over
|
||||
// an incorrect prettification.
|
||||
//
|
||||
// Lang controls the language of connective words (Abs / Section,
|
||||
// Regel / Rule, …). The pretty table covers the prefixes used by the
|
||||
// 254 published rules in the corpus today; new prefixes default to
|
||||
// pass-through and a follow-up CL extends the table.
|
||||
func legalSourcePretty(src, lang string) string {
|
||||
src = strings.TrimSpace(src)
|
||||
if src == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.Split(src, ".")
|
||||
en := strings.EqualFold(lang, "en")
|
||||
|
||||
switch {
|
||||
case len(parts) == 4 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s(%s) ZPO", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("§ %s Abs. %s ZPO", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "DE" && parts[1] == "ZPO":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s ZPO", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s ZPO", parts[2])
|
||||
case len(parts) == 4 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s.%s RoP UPC", parts[2], parts[3])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s.%s VerfO UPC", parts[2], parts[3])
|
||||
case len(parts) == 3 && parts[0] == "UPC" && parts[1] == "RoP":
|
||||
if en {
|
||||
return fmt.Sprintf("Rule %s RoP UPC", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("Regel %s VerfO UPC", parts[2])
|
||||
case len(parts) >= 3 && parts[0] == "DE" && parts[1] == "PatG":
|
||||
if en {
|
||||
return fmt.Sprintf("Section %s PatG", parts[2])
|
||||
}
|
||||
return fmt.Sprintf("§ %s PatG", parts[2])
|
||||
case len(parts) == 2 && parts[0] == "EPC":
|
||||
if en {
|
||||
return fmt.Sprintf("Art. %s EPC", parts[1])
|
||||
}
|
||||
return fmt.Sprintf("Art. %s EPÜ", parts[1])
|
||||
}
|
||||
return src
|
||||
}
|
||||
|
||||
// patentNumberKindCodeRegex matches a trailing kind code on a patent
|
||||
// number: a whitespace-separated single uppercase letter followed by
|
||||
// a single digit (B1, A1, A2, B2, B9, C1, T2, U1, …). Capturing
|
||||
// groups split the base from the kind code so the formatter can
|
||||
// parenthesise the kind without touching the rest of the number.
|
||||
var patentNumberKindCodeRegex = regexp.MustCompile(`^(.*?)\s+([A-Z]\d)$`)
|
||||
|
||||
// patentNumberUPC reformats a patent number from the DE convention
|
||||
// ("EP 1 234 567 B1") to the UPC-brief convention
|
||||
// ("EP 1 234 567 (B1)"). The kind code is parenthesised; everything
|
||||
// else is preserved verbatim. Numbers without a recognised trailing
|
||||
// kind code pass through unchanged so a lawyer's draft never sees a
|
||||
// number worse than the source value.
|
||||
//
|
||||
// Recognised inputs:
|
||||
//
|
||||
// "EP 1 234 567 B1" → "EP 1 234 567 (B1)"
|
||||
// "EP 4 056 049 A1" → "EP 4 056 049 (A1)"
|
||||
// "DE 10 2020 123 456 A1" → "DE 10 2020 123 456 (A1)"
|
||||
// " EP 1 234 567 B1 " → "EP 1 234 567 (B1)" (trimmed)
|
||||
//
|
||||
// Pass-through:
|
||||
//
|
||||
// "EP 1 234 567" → "EP 1 234 567"
|
||||
// "WO/2023/123456" → "WO/2023/123456" (no kind code shape)
|
||||
// "" → ""
|
||||
//
|
||||
// Pure function; unit-tested in submission_vars_test.go.
|
||||
func patentNumberUPC(s string) string {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return ""
|
||||
}
|
||||
if m := patentNumberKindCodeRegex.FindStringSubmatch(s); m != nil {
|
||||
base := strings.TrimSpace(m[1])
|
||||
kind := m[2]
|
||||
if base == "" {
|
||||
return s
|
||||
}
|
||||
return base + " (" + kind + ")"
|
||||
}
|
||||
return s
|
||||
}
|
||||
346
scripts/gen-demo-submission-template/main.go
Normal file
346
scripts/gen-demo-submission-template/main.go
Normal file
@@ -0,0 +1,346 @@
|
||||
// Demo submission template generator (t-paliad-241).
|
||||
//
|
||||
// One-shot authoring tool that emits a minimal but Word-compatible
|
||||
// .docx file exercising every placeholder SubmissionVarsService
|
||||
// resolves. Drop the output into m/mWorkRepo at
|
||||
//
|
||||
// 6 - material/Templates/Word/Paliad/HLC/de.inf.lg.erwidg.docx
|
||||
//
|
||||
// so paliad's submission-draft editor (t-paliad-238 Slice A) can fetch
|
||||
// it via the per-submission_code fallback chain wired into
|
||||
// handlers/files.go. The structure is a fake Klageerwiderung skeleton
|
||||
// in German — fake legal prose, real placeholder tokens.
|
||||
//
|
||||
// Why a generator instead of authoring in Word: the per-placeholder
|
||||
// docx grammar is `{{[A-Za-z][A-Za-z0-9_.]*}}` and Word's autocorrect
|
||||
// happily fragments such tokens across <w:r> runs ({{ → "{", "{",
|
||||
// project.case_number, "}", "}"). A programmatic emitter writes each
|
||||
// placeholder as a single run so the renderer's pass-1 substitution
|
||||
// (format-preserving) catches it cleanly. The merge engine handles
|
||||
// cross-run cases too (pass 2) but pass 1 is the cheaper path.
|
||||
//
|
||||
// Run:
|
||||
//
|
||||
// go run ./scripts/gen-demo-submission-template -out /tmp/de.inf.lg.erwidg.docx
|
||||
//
|
||||
// Output is deterministic so re-generating to the same path produces a
|
||||
// byte-identical file (modulo zip mtime — we pin those to a fixed UTC
|
||||
// timestamp so the bytes are reproducible).
|
||||
package main
|
||||
|
||||
import (
|
||||
"archive/zip"
|
||||
"bytes"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func main() {
|
||||
out := flag.String("out", "de.inf.lg.erwidg.docx", "output .docx path")
|
||||
flag.Parse()
|
||||
|
||||
docx, err := buildDocx()
|
||||
if err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-demo-submission-template:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
if err := os.WriteFile(*out, docx, 0o644); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "gen-demo-submission-template: write:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Printf("wrote %s (%d bytes)\n", *out, len(docx))
|
||||
}
|
||||
|
||||
// fixedTime is the zip mtime stamp baked into every entry so the output
|
||||
// is byte-reproducible.
|
||||
var fixedTime = time.Date(2026, 5, 23, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
// buildDocx assembles the four-part .docx zip Word needs to open the
|
||||
// file cleanly: Content_Types, root rels, document.xml, and document
|
||||
// rels. Everything else (styles, theme, fonts) is optional — Word
|
||||
// supplies sane defaults when absent.
|
||||
func buildDocx() ([]byte, error) {
|
||||
var buf bytes.Buffer
|
||||
zw := zip.NewWriter(&buf)
|
||||
|
||||
add := func(name, body string) error {
|
||||
hdr := &zip.FileHeader{
|
||||
Name: name,
|
||||
Method: zip.Deflate,
|
||||
Modified: fixedTime,
|
||||
}
|
||||
w, err := zw.CreateHeader(hdr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create %s: %w", name, err)
|
||||
}
|
||||
if _, err := w.Write([]byte(body)); err != nil {
|
||||
return fmt.Errorf("write %s: %w", name, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := add("[Content_Types].xml", contentTypesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("_rels/.rels", rootRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/_rels/document.xml.rels", documentRelsXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/styles.xml", stylesXML); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := add("word/document.xml", buildDocumentXML()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := zw.Close(); err != nil {
|
||||
return nil, fmt.Errorf("finalise zip: %w", err)
|
||||
}
|
||||
return buf.Bytes(), nil
|
||||
}
|
||||
|
||||
const contentTypesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
|
||||
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
|
||||
<Default Extension="xml" ContentType="application/xml"/>
|
||||
<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
|
||||
<Override PartName="/word/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml"/>
|
||||
</Types>`
|
||||
|
||||
const rootRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
const documentRelsXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
|
||||
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
|
||||
</Relationships>`
|
||||
|
||||
// stylesXML provides minimal Heading1 + Heading2 paragraph styles so
|
||||
// the section headings render with visual weight. Body text falls
|
||||
// through to Word's Normal style.
|
||||
const stylesXML = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<w:styles xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">
|
||||
<w:style w:type="paragraph" w:styleId="Heading1">
|
||||
<w:name w:val="heading 1"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="360" w:after="120"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="28"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:styleId="Heading2">
|
||||
<w:name w:val="heading 2"/>
|
||||
<w:basedOn w:val="Normal"/>
|
||||
<w:pPr><w:spacing w:before="240" w:after="80"/></w:pPr>
|
||||
<w:rPr><w:b/><w:sz w:val="24"/></w:rPr>
|
||||
</w:style>
|
||||
<w:style w:type="paragraph" w:default="1" w:styleId="Normal">
|
||||
<w:name w:val="Normal"/>
|
||||
</w:style>
|
||||
</w:styles>`
|
||||
|
||||
// Document body — a fake Klageerwiderung skeleton with every placeholder
|
||||
// SubmissionVarsService resolves embedded in natural positions. Each
|
||||
// placeholder is in its own run so pass-1 substitution catches it without
|
||||
// fragmentation worries. The DEMO marker in the header makes it obvious
|
||||
// this is not approved firm content.
|
||||
//
|
||||
// Structure mirrors a real submission:
|
||||
//
|
||||
// 1. Firm letterhead + author block (firm.*, user.*, today.*)
|
||||
// 2. Court caption (project.*, project.proceeding.*)
|
||||
// 3. Parties block (parties.*)
|
||||
// 4. Submission title + legal source (rule.*)
|
||||
// 5. Deadline (deadline.*)
|
||||
// 6. Boilerplate body + signature
|
||||
//
|
||||
// Order matches what a lawyer drafting a real Klageerwiderung would put
|
||||
// at the top of the document, so when the lawyer customises this
|
||||
// template later they don't have to restructure.
|
||||
func buildDocumentXML() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>`)
|
||||
b.WriteString(`<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main">`)
|
||||
b.WriteString(`<w:body>`)
|
||||
|
||||
demoBanner(&b)
|
||||
|
||||
heading1(&b, "{{firm.name}} — Patentstreitsachen")
|
||||
plain(&b, "Bearbeiter: {{user.display_name}}")
|
||||
plain(&b, "E-Mail: {{user.email}} · Büro: {{user.office}}")
|
||||
plain(&b, "Datum: {{today.long_de}} ({{today.iso}})")
|
||||
|
||||
heading1(&b, "{{project.court}}")
|
||||
plain(&b, "Aktenzeichen: {{project.case_number}}")
|
||||
plain(&b, "Verfahrensart: {{project.proceeding.name}} ({{project.proceeding.code}})")
|
||||
plain(&b, "Instanz: {{project.instance_level}}")
|
||||
|
||||
heading2(&b, "In der Patentstreitsache")
|
||||
plain(&b, "{{parties.claimant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.claimant.representative}}")
|
||||
bold(&b, "— Klägerin —")
|
||||
plain(&b, "")
|
||||
plain(&b, "gegen")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{parties.defendant.name}}")
|
||||
plain(&b, "vertreten durch {{parties.defendant.representative}}")
|
||||
bold(&b, "— Beklagte —")
|
||||
plainOptional(&b, "Weitere Beteiligte: {{parties.other.name}}, vertreten durch {{parties.other.representative}}")
|
||||
|
||||
heading2(&b, "Betreff")
|
||||
plain(&b, "Streitpatent: {{project.patent_number}} (UPC: {{project.patent_number_upc}})")
|
||||
plain(&b, "Anmeldung: {{project.filing_date}} · Erteilung: {{project.grant_date}}")
|
||||
plain(&b, "Projekttitel: {{project.title}}")
|
||||
plain(&b, "Unsere Seite: {{project.our_side_de}} ({{project.our_side}})")
|
||||
plain(&b, "Mandant: {{project.client_number}} · Matter: {{project.matter_number}}")
|
||||
plain(&b, "Internes Aktenzeichen: {{project.reference}}")
|
||||
|
||||
heading1(&b, "{{rule.name}}")
|
||||
plain(&b, "(Schriftsatz-Code: {{rule.submission_code}})")
|
||||
plain(&b, "Rechtsgrundlage: {{rule.legal_source_pretty}} ({{rule.legal_source}})")
|
||||
plain(&b, "Typische Partei: {{rule.primary_party}} · Schriftsatz-Typ: {{rule.event_type}}")
|
||||
|
||||
heading2(&b, "Frist")
|
||||
plain(&b, "Diese Frist wurde berechnet aus: {{deadline.computed_from}}")
|
||||
plain(&b, "Fälligkeit: {{deadline.due_date_long_de}} ({{deadline.due_date}})")
|
||||
plainOptional(&b, "Ursprüngliche Frist: {{deadline.original_due_date}}")
|
||||
plain(&b, "Frist-Bezeichnung: {{deadline.title}} · Quelle: {{deadline.source}}")
|
||||
|
||||
heading2(&b, "I. Anträge")
|
||||
plain(&b, "Die Beklagte beantragt,")
|
||||
plain(&b, "")
|
||||
plain(&b, "1. die Klage abzuweisen;")
|
||||
plain(&b, "2. der Klägerin die Kosten des Rechtsstreits aufzuerlegen.")
|
||||
|
||||
heading2(&b, "II. Sachverhalt")
|
||||
plain(&b, "[DEMO-Platzhalter] Hier folgt der Sachvortrag der Beklagten zum Streitpatent {{project.patent_number}} und zu den von der Klägerin geltend gemachten Ansprüchen.")
|
||||
|
||||
heading2(&b, "III. Rechtsausführungen")
|
||||
plain(&b, "[DEMO-Platzhalter] Die Beklagte tritt der Klage aus den nachfolgenden Gründen entgegen.")
|
||||
|
||||
heading2(&b, "Schlussformel")
|
||||
plain(&b, "{{today.long_de}}")
|
||||
plain(&b, "")
|
||||
plain(&b, "{{user.display_name}}")
|
||||
plain(&b, "{{firm.name}}")
|
||||
plainOptional(&b, "{{firm.signature_block}}")
|
||||
|
||||
// English-locale exercise — lets the lawyer verify the EN long-form
|
||||
// date and EN proceeding name resolve correctly when the user's
|
||||
// preference is en. Also exercises the bare {{today}} alias
|
||||
// (identical to {{today.iso}}; included so every key the variable
|
||||
// bag carries appears at least once in this demo template).
|
||||
heading2(&b, "Locale-aware variants (DEMO)")
|
||||
plain(&b, "EN long date: {{today.long_en}} · Deadline EN: {{deadline.due_date_long_en}}")
|
||||
plain(&b, "Project our side (EN): {{project.our_side_en}} · Proceeding (EN): {{project.proceeding.name_en}}")
|
||||
plain(&b, "Rule name (EN): {{rule.name_en}} · Project our side (DE): {{project.our_side_de}}")
|
||||
plain(&b, "Proceeding (DE): {{project.proceeding.name_de}} · Rule name (DE): {{rule.name_de}}")
|
||||
plain(&b, "Today (bare alias): {{today}}")
|
||||
|
||||
b.WriteString(`</w:body></w:document>`)
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// demoBanner writes a clearly-marked DEMO header so the file can't be
|
||||
// mistaken for approved firm content (HLC branding compliance has not
|
||||
// reviewed this — it's a developer-authored placeholder fixture).
|
||||
func demoBanner(b *strings.Builder) {
|
||||
b.WriteString(`<w:p><w:pPr><w:pStyle w:val="Heading1"/></w:pPr><w:r><w:rPr><w:b/><w:color w:val="C00000"/></w:rPr><w:t xml:space="preserve">DEMO — interne Vorlage (nicht freigegeben)</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
// heading1 emits a styled "Heading 1" paragraph with placeholder runs
|
||||
// emitted intact (one run per placeholder so pass-1 substitution works).
|
||||
func heading1(b *strings.Builder, text string) { paragraph(b, "Heading1", text, false) }
|
||||
|
||||
// heading2 emits a "Heading 2" paragraph.
|
||||
func heading2(b *strings.Builder, text string) { paragraph(b, "Heading2", text, false) }
|
||||
|
||||
// plain emits a Normal-style paragraph.
|
||||
func plain(b *strings.Builder, text string) { paragraph(b, "", text, false) }
|
||||
|
||||
// plainOptional is a Normal paragraph rendered as italic so the lawyer
|
||||
// recognises rows that contain placeholders which may be empty
|
||||
// (parties.other.*, deadline.original_due_date, firm.signature_block).
|
||||
// Visual cue only; the merge engine still substitutes the same way.
|
||||
func plainOptional(b *strings.Builder, text string) { paragraph(b, "", text, true) }
|
||||
|
||||
// bold emits a Normal paragraph with bold run formatting.
|
||||
func bold(b *strings.Builder, text string) {
|
||||
b.WriteString(`<w:p>`)
|
||||
b.WriteString(`<w:r><w:rPr><w:b/></w:rPr><w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(text))
|
||||
b.WriteString(`</w:t></w:r></w:p>`)
|
||||
}
|
||||
|
||||
// paragraph splits text on placeholder boundaries and emits one <w:r>
|
||||
// per segment. Each placeholder occupies a dedicated run so the
|
||||
// renderer's pass-1 substitution (format-preserving, single-run) hits
|
||||
// the placeholder without the cross-run fallback. Italic runs are
|
||||
// flagged via the italic argument.
|
||||
func paragraph(b *strings.Builder, style, text string, italic bool) {
|
||||
b.WriteString(`<w:p>`)
|
||||
if style != "" {
|
||||
b.WriteString(`<w:pPr><w:pStyle w:val="`)
|
||||
b.WriteString(style)
|
||||
b.WriteString(`"/></w:pPr>`)
|
||||
}
|
||||
for _, seg := range splitOnPlaceholders(text) {
|
||||
b.WriteString(`<w:r>`)
|
||||
if italic {
|
||||
b.WriteString(`<w:rPr><w:i/></w:rPr>`)
|
||||
}
|
||||
b.WriteString(`<w:t xml:space="preserve">`)
|
||||
b.WriteString(xmlEscape(seg))
|
||||
b.WriteString(`</w:t></w:r>`)
|
||||
}
|
||||
b.WriteString(`</w:p>`)
|
||||
}
|
||||
|
||||
// splitOnPlaceholders returns the input split into alternating text /
|
||||
// placeholder segments while keeping each placeholder intact in its own
|
||||
// segment. Empty input yields a single empty segment so the paragraph
|
||||
// still emits a (visible) blank line.
|
||||
func splitOnPlaceholders(s string) []string {
|
||||
if s == "" {
|
||||
return []string{""}
|
||||
}
|
||||
var out []string
|
||||
for {
|
||||
open := strings.Index(s, "{{")
|
||||
if open < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
close := strings.Index(s[open:], "}}")
|
||||
if close < 0 {
|
||||
out = append(out, s)
|
||||
return out
|
||||
}
|
||||
end := open + close + 2
|
||||
if open > 0 {
|
||||
out = append(out, s[:open])
|
||||
}
|
||||
out = append(out, s[open:end])
|
||||
s = s[end:]
|
||||
if s == "" {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// xmlEscape handles the five XML-significant characters for <w:t>
|
||||
// content. Whitespace is preserved by the xml:space="preserve" attr we
|
||||
// always emit on text runs.
|
||||
func xmlEscape(s string) string {
|
||||
s = strings.ReplaceAll(s, "&", "&")
|
||||
s = strings.ReplaceAll(s, "<", "<")
|
||||
s = strings.ReplaceAll(s, ">", ">")
|
||||
s = strings.ReplaceAll(s, `"`, """)
|
||||
s = strings.ReplaceAll(s, "'", "'")
|
||||
return s
|
||||
}
|
||||
Reference in New Issue
Block a user