Compare commits

..

16 Commits

Author SHA1 Message Date
mAi
228ae1b263 mAi: #85 - sidebar scroll position persists across nav
Sidebar nav clicks trigger a full page reload, which rebuilds the
sidebar from scratch and snaps .sidebar-nav back to scrollTop=0.
Persist scrollTop to sessionStorage (paliad.sidebar.scroll) on every
scroll and restore on initSidebar(). Re-apply once after
/api/user-views resolves so the async layout shift doesn't leave the
user a few rows off.

sessionStorage scopes the value to the tab: Cmd-click / right-click
"open in new tab" still produces a fresh tab that starts at the top.
2026-05-25 14:03:03 +02:00
mAi
cdd3747c2b Merge: t-paliad-250 — Browse-a-proceeding side+appellant selectors + 'appealable decision' trigger label (m/paliad#81) 2026-05-25 14:00:02 +02:00
mAi
02255c4234 mAi: #81 - verfahrensablauf side+appellant selectors + UPC Appeal trigger label
Concerns A + B + C from m/paliad#81:

A. Browse-a-proceeding (/tools/verfahrensablauf) gains a side selector
   (Kläger/Beklagter/Beide) and an appellant selector. The side selector
   swaps which column labels which user-side; the appellant selector
   collapses party='both' rules into the appellant's column (no mirror)
   so role-swap proceedings (Appeal, etc.) stop showing every row
   twice in the timeline. Both selectors are URL-driven (?side= +
   ?appellant=) and re-render without a backend round-trip.

   The appellant row hides itself for proceedings without an appellant
   axis (first-instance Inf/Rev/Opp) via a small allowlist.

B. UPC Appeal trigger-event caption now reads "Anfechtbare Entscheidung"
   / "Appealable Decision" instead of falling back to the proceeding
   name ("Berufungsverfahren" / "Appeal"). Implemented as an optional
   trigger_event_label_{de,en} column on paliad.proceeding_types (mig
   121); the frontend prefers it over the proceedingName fallback that
   fires when no rule has IsRootEvent=true. No new deadline rules, no
   slug changes (hard rule from the issue).

C. Parameter contract for the column projection is unified in
   bucketDeadlinesIntoColumns(deadlines, {side, appellant}) — a pure
   helper extracted from renderColumnsBody so the routing behaviour
   stays unit-testable without a DOM. Tests cover the default mirror,
   appellant-collapse for both sides, side-swap of column ownership,
   the combined case, and row alignment by dueDate.

Verification

- go build ./...                        clean
- go test ./...                         all green
- bun run build (frontend)              clean
- bun test (frontend/src)               110/110 pass (12 new + 98 prior)
- Migration 121 applied to paliad schema; UPC Appeal proceeding now
  carries the curated trigger label pair.

Out of scope (filed for follow-up): per-rule role tagging so
respondent-side filings (Response to Appeal, Cross-Appeal) land in
the respondent's column when an appellant is selected. The current
issue scope (one-row-per-deadline collapse) is delivered; the
realistic-per-row routing needs a deadline_rules schema bump that
the hard rules of #81 excluded.
2026-05-25 13:57:38 +02:00
mAi
206f2917ea Merge: t-paliad-253 — Submissions /generate runs the merge engine (m/paliad#84) 2026-05-25 13:55:14 +02:00
mAi
5df87f4129 fix(submissions): t-paliad-253 — /generate runs the merge engine
The "Generieren" button on the project Schriftsätze tab posts to
/api/projects/{id}/submissions/{code}/generate. Pre-fix that handler
called `fetchHLPatentsStyleBytes` unconditionally and streamed the
result after a format-only .dotm→.docx convert — it never touched
`submissionTemplateRegistry` (added in t-paliad-241 for the draft
editor) and never ran the SubmissionRenderer merge. m's report on
m/paliad#84 ("the document generator still has no variables in the
template") was the lawyer-facing manifestation: HL Patents Style has
no {{…}} placeholders, so the downloaded .docx had nothing to
substitute and looked like a generic firm-style fixture.

The "Bearbeiten" path (/projects/{id}/submissions/{code}/draft) was
unaffected — it uses `resolveSubmissionTemplate` + the renderer
already, which is why the editor preview shows the 48 placeholders
resolved correctly. Only the one-click /generate side missed the
wire-up.

Fix:

- `internal/services/submission_draft_service.go` — add
  `RenderProjectSubmission(ctx, userID, projectID, submissionCode,
  templateBytes)` that wraps `vars.Build` + `renderer.Render` for the
  no-saved-draft path. Returns the merged bytes plus the resolved
  SubmissionVarsResult (rule, project, user, lang) so the handler can
  derive filename + audit metadata without a second DB round-trip.

- `internal/handlers/submissions.go` — rewrite
  `handleGenerateProjectSubmission` to resolve the template via
  `resolveSubmissionTemplate` (per-firm slug → HL Patents Style
  fallback, same as the editor draft) and run the new service method.
  Visibility / rule-not-found semantics route through
  `SubmissionVarsService` errors so the gate behavior matches every
  other project endpoint. Removed `loadPublishedRuleByCode` and
  `errRuleNotFound` — both were only used by the old handler.

- `scripts/gen-demo-submission-template/main.go` + the regenerated
  `de.inf.lg.erwidg.docx` on mWorkRepo (HL/mWorkRepo @ 3e3e828f) now
  exercise the bare `{{today}}` alias too. The demo template covers
  every one of the 48 keys SubmissionVarsService can resolve (firm 2,
  today 4, user 3, project 18, parties 6, rule 8, deadline 7).

The renderer is a no-op on placeholder substitution when the
fallback HL Patents Style is fetched (it has none) — but it still
runs the .dotm→.docx pre-pass via `ConvertDotmToDocx`, so the
non-per-firm code path streams a byte-for-byte equivalent download.

Build + vet + tests clean (go test ./internal/...; bun run build).
2026-05-25 13:51:45 +02:00
mAi
898348a64a Merge: t-paliad-245 — Daten Exportieren demoted into Verwaltung tab (m/paliad#76) 2026-05-25 13:34:53 +02:00
mAi
1714b788d2 feat(projects-detail): t-paliad-245 — demote Daten Export into Verwaltung tab
m/paliad#76. The export button no longer pokes out of the tabs nav with a
non-tab styling — instead it lives inside a new "Verwaltung" tab (last in
the project tab list) as a normal section with heading, description, and a
plain btn-secondary trigger. Same gate as before (canExportProject).

Archive co-locates in the same tab as a pointer to the Edit-modal danger
zone: click "Bearbeiten öffnen" → modal opens scrolled to the archive
button. Single source of truth for the destructive action stays in the
modal; the Verwaltung pointer just gives it discoverability.

If neither sub-section is visible to the caller (no export entitlement,
not global_admin), the Verwaltung tab hides itself — an empty tab is
worse UX than no tab.
2026-05-25 13:33:14 +02:00
mAi
db8335253b Merge: t-paliad-244 — Team View mailto: link for non-admin members (m/paliad#75) 2026-05-25 13:31:52 +02:00
mAi
5589cbb477 mAi: #75 - team view mailto: link for non-admin members
t-paliad-244 / m/paliad#75. Both "E-Mail an Auswahl senden" actions on
/team (filter-bar + bottom selection footer) now branch on canBroadcast():
- Admin path keeps the in-app compose modal (POST /api/team/broadcast).
- Non-admin path renders a native <a href="mailto:..."> with the
  recipient list pre-filled, comma-joined and URL-encoded via
  buildMailtoHref (already exported from broadcast.ts).

Filter-bar button used to hide for non-admins; it now shows as the
mailto: anchor and its href refreshes on every filter change so the link
always matches what's visible. Empty visible set disables the affordance
visually (aria-disabled + pointer-events:none) so a click can't open an
empty composer. Bottom selection footer mirrors the same shape.

No new i18n keys, no backend changes, admin compose flow untouched.
2026-05-25 13:30:32 +02:00
mAi
0059e3f15b Merge: t-paliad-243 — project-optional Schriftsätze drafts + /submissions/new picker 2026-05-23 02:20:26 +02:00
mAi
a911a2d0ee feat(submissions): t-paliad-243 — global Schriftsätze drafts without project
Adds an end-to-end project-optional path for Schriftsatz drafts:

- Migration 120 drops NOT NULL on paliad.submission_drafts.project_id
  and rewrites the four RLS policies to gate purely on user_id when
  project_id IS NULL, otherwise on paliad.can_see_project. Down
  refuses to run if project-less rows exist (safer than silent
  data corruption).

- SubmissionDraft.ProjectID becomes *uuid.UUID end-to-end. Service
  layer skips project/parties/deadline lookups when nil and exposes
  DraftPatch.ProjectID for the "Projekt zuweisen" affordance.
  ListAllForUser LEFT JOINs paliad.projects so project-less drafts
  surface in the global index next to project-scoped ones.

- New HTTP surface:
    GET  /submissions/new                 (picker page)
    GET  /submissions/draft/{draft_id}    (editor for any draft)
    GET  /api/submissions/catalog         (catalog without project)
    POST /api/submission-drafts           (project-less or attached)
    GET/PATCH/DELETE /api/submission-drafts/{draft_id}
    POST /api/submission-drafts/{draft_id}/export
  Existing /api/projects/{id}/submissions/... routes remain bit-
  identical so the project-scoped flow keeps working unchanged.

- Frontend: /submissions/new lists the full cross-proceeding catalog
  grouped by proceeding, filterable by text + chip. Each row offers
  "Ohne Projekt" (instant draft) or "Mit Projekt…" (modal picker
  with autocomplete over visible projects). /submissions index gains
  a prominent "Neuer Entwurf" CTA and an empty-state CTA pointing at
  the picker. The editor renders a banner + "Projekt zuweisen"
  action when project_id is null; assigning persists project_id and
  redirects to the project-scoped URL.

Audit + project-event writes detect d.ProjectID == nil; the audit
row's scope flips to 'user' (scope_root = user_id) and the
project_events row is skipped entirely.
2026-05-23 02:19:55 +02:00
mAi
b26f04ffe0 Merge: t-paliad-242 — Schriftsätze tab full catalog grouped by proceeding 2026-05-23 01:56:44 +02:00
mAi
8e195cb497 feat(submissions): t-paliad-242 — Schriftsätze tab shows full catalog grouped by proceeding
Per m's 2026-05-23 ask: from any project, surface every available
template/generator instead of just the project's own proceeding.

Backend (GET /api/projects/{id}/submissions):
- drop the proceeding_type_id filter; JOIN deadline_rules with
  proceeding_types to return every active+published filing rule
  across every active proceeding
- response gains proceeding_code, proceeding_name, proceeding_name_en
  per row plus project_proceeding_code at the top so the frontend
  can pin the project's own group
- has_template now reflects "per-submission .docx wired in
  submissionTemplateRegistry"; the editor still falls back to the
  universal HL Patents Style for everything else (t-paliad-238)
- can_see_project gate unchanged; rules are static reference data
- sorted by (proceeding_code, submission_code)

Frontend:
- client/submissions.ts renders a grouped table: project's own
  proceeding pinned to the top with a lime border + "(dieses
  Projekt)" suffix, every other proceeding alphabetised below
- "Generieren" + "Bearbeiten" buttons stay on every row (editor
  handles missing variables via [KEIN WERT: …])
- "universell"/"universal" badge surfaces for rules without a
  per-submission template — informational, not blocking
- soften the no_proceeding hint so the catalog still renders below
- entity-table-group-header CSS, including --own modifier and a
  read-only override so group rows don't pretend to be clickable

Verified: 103 filing rules across 19 proceedings surface (de.inf.lg,
upc.inf.cfi, epa.opp.opd, etc.). go build + go vet + go test
./internal/... + bun run build clean.
2026-05-23 01:55:32 +02:00
mAi
1f7de99493 Merge: t-paliad-241 — demo Klageerwiderung template + placeholder wiring 2026-05-23 01:33:22 +02:00
mAi
0adcc2c826 Merge: t-paliad-240 — Schriftsätze sidebar + global drafts index 2026-05-23 01:30:32 +02:00
mAi
436c1b41bb feat(submissions): t-paliad-240 — Schriftsätze sidebar + global drafts index
Add a top-level Schriftsätze entry under the Werkzeuge sidebar group
plus a new /submissions page that lists every draft the caller owns
across visible projects. Each row links to the per-project editor at
/projects/{id}/submissions/{code}/draft/{draft_id}.

Backend: SubmissionDraftService.ListAllForUser joins paliad.submission_drafts
with paliad.projects, gated by paliad.can_see_project for visibility. New
GET /api/user/submission-drafts endpoint exposes the rows; the page route
GET /submissions is gateOnboarded'd alongside the other project surfaces.

Frontend: submissions-index.tsx renders an entity-table; submissions-index.ts
hydrates from /api/user/submission-drafts and wires the row-click contract
(skip clicks on inner a/button). DE primary, EN secondary i18n.
2026-05-23 01:29:56 +02:00
31 changed files with 3157 additions and 388 deletions

View File

@@ -19,6 +19,8 @@ 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";
@@ -254,6 +256,8 @@ async function build() {
join(import.meta.dir, "src/client/projects-detail.ts"),
join(import.meta.dir, "src/client/projects-chart.ts"),
join(import.meta.dir, "src/client/submission-draft.ts"),
join(import.meta.dir, "src/client/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"),
@@ -379,6 +383,8 @@ async function build() {
await Bun.write(join(DIST, "projects-detail.html"), renderProjectsDetail());
await Bun.write(join(DIST, "projects-chart.html"), renderProjectsChart());
await Bun.write(join(DIST, "submission-draft.html"), renderSubmissionDraft());
await Bun.write(join(DIST, "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

View File

@@ -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",
@@ -301,9 +302,11 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Zeitstrahl",
"deadlines.view.columns": "Spalten",
"deadlines.notes.show": "Hinweise anzeigen",
"deadlines.col.proactive": "Proaktiv",
"deadlines.col.proactive": "Proaktiv (Klägerseite)",
"deadlines.col.proactive.defendant": "Proaktiv (Beklagtenseite)",
"deadlines.col.court": "Gericht",
"deadlines.col.reactive": "Reaktiv",
"deadlines.col.reactive": "Reaktiv (Beklagtenseite)",
"deadlines.col.reactive.claimant": "Reaktiv (Klägerseite)",
"deadlines.col.both": "Beide Parteien",
// Trigger-event mode (PR-2 \u2014 youpc-parity)
"deadlines.mode.procedure": "Verfahrensablauf",
@@ -416,6 +419,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.defendant.title": "Beklagtenseite — versteckt typische Kläger-Schriftsätze",
"deadlines.perspective.appeal_filed_by.label": "Berufung eingelegt durch:",
"deadlines.perspective.predefined_hint": "vorgegeben durch Akte",
"deadlines.side.label": "Seite:",
"deadlines.side.claimant": "Klägerseite",
"deadlines.side.defendant": "Beklagtenseite",
"deadlines.side.both": "Beide",
"deadlines.appellant.label": "Berufung durch:",
"deadlines.appellant.claimant": "Klägerseite",
"deadlines.appellant.defendant": "Beklagtenseite",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Zusammengesetzt:",
"deadlines.event.unit.days.one": "Tag",
"deadlines.event.unit.days.many": "Tage",
@@ -1425,10 +1436,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",
@@ -1450,6 +1467,36 @@ const translations: Record<Lang, Record<string, string>> = {
"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).
@@ -2939,6 +2986,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",
@@ -3210,9 +3258,11 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.view.timeline": "Timeline",
"deadlines.view.columns": "Columns",
"deadlines.notes.show": "Show details",
"deadlines.col.proactive": "Proactive",
"deadlines.col.proactive": "Proactive (Claimant side)",
"deadlines.col.proactive.defendant": "Proactive (Defendant side)",
"deadlines.col.court": "Court",
"deadlines.col.reactive": "Reactive",
"deadlines.col.reactive": "Reactive (Defendant side)",
"deadlines.col.reactive.claimant": "Reactive (Claimant side)",
"deadlines.col.both": "Both parties",
"deadlines.adjusted": "Adjusted",
"deadlines.adjusted.reason": "weekend/holiday",
@@ -3332,6 +3382,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.perspective.defendant.title": "Defendant side — hides typical claimant submissions",
"deadlines.perspective.appeal_filed_by.label": "Appeal filed by:",
"deadlines.perspective.predefined_hint": "predefined from project",
"deadlines.side.label": "Side:",
"deadlines.side.claimant": "Claimant",
"deadlines.side.defendant": "Defendant",
"deadlines.side.both": "Both",
"deadlines.appellant.label": "Appeal filed by:",
"deadlines.appellant.claimant": "Claimant",
"deadlines.appellant.defendant": "Defendant",
"deadlines.appellant.none": "—",
"deadlines.event.composite.label": "Composite:",
"deadlines.event.unit.days.one": "day",
"deadlines.event.unit.days.many": "days",
@@ -4315,10 +4373,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",
@@ -4340,6 +4404,35 @@ const translations: Record<Lang, Record<string, string>> = {
"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.",

View File

@@ -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
@@ -1185,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
@@ -2045,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;
@@ -2991,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`;
@@ -3012,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;

View File

@@ -11,6 +11,13 @@ const WIDTH_KEY = "paliad-sidebar-width";
const SIDEBAR_WIDTH_MIN = 180;
const SIDEBAR_WIDTH_MAX = 480;
const SIDEBAR_WIDTH_DEFAULT = 240;
// Per-tab scroll position of the .sidebar-nav scroll container. Persisted
// on every scroll event, restored on initSidebar() so a full-page nav
// click doesn't bounce the user back to the top of a long sidebar
// (Werkzeuge + projects + user views can easily overflow). sessionStorage
// scopes it to the tab — opening a sidebar link in a new tab (Cmd-click)
// starts that tab fresh at the top, which matches user expectation.
const SCROLL_KEY = "paliad.sidebar.scroll";
// toggleMobileSidebar opens or closes the slide-out drawer. Exposed so the
// BottomNav menu slot can call it without duplicating the open/close
@@ -49,6 +56,23 @@ function applySidebarWidth(px: number): void {
document.documentElement.style.setProperty("--sidebar-width", `${px}px`);
}
// readStoredScroll returns the persisted scrollTop or 0 when missing /
// malformed. Bounds are checked at apply time against the actual
// scrollHeight, so a stale value pointing past the current scroll range
// is harmless (the browser clamps assignments to [0, max]).
function readStoredScroll(): number {
const raw = sessionStorage.getItem(SCROLL_KEY);
if (raw === null) return 0;
const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 0) return 0;
return n;
}
function applySidebarScroll(nav: HTMLElement, px: number): void {
if (px <= 0) return;
nav.scrollTop = px;
}
// migrateLegacyPinKey copies the pre-rebrand pin state into the new key on
// first load and removes the stale entry. Drop this fallback once the rename
// grace period is over.
@@ -79,6 +103,7 @@ export function initSidebar() {
const sidebar = document.querySelector<HTMLElement>(".sidebar");
if (!sidebar) return;
initSidebarResize(sidebar);
initSidebarScrollRestore(sidebar);
const pinBtn = sidebar.querySelector<HTMLButtonElement>(".sidebar-pin");
const hamburger = document.querySelector<HTMLButtonElement>(".sidebar-hamburger");
@@ -293,6 +318,29 @@ function initSidebarResize(sidebar: HTMLElement): void {
});
}
// initSidebarScrollRestore wires the .sidebar-nav scroll container to
// sessionStorage so the user's scroll position survives a full-page
// navigation (every sidebar link click is a real reload — see m/paliad#85).
// Restore is synchronous on init so the first paint is already at the
// right offset; the passive scroll listener persists subsequent moves.
// reapplySidebarScroll() exists so callers that mutate sidebar content
// async (initUserViewsGroup appending /api/user-views into the Ansichten
// group) can nudge the scroll back to where it was after the layout shift.
function initSidebarScrollRestore(sidebar: HTMLElement): void {
const nav = sidebar.querySelector<HTMLElement>(".sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
nav.addEventListener("scroll", () => {
sessionStorage.setItem(SCROLL_KEY, String(nav.scrollTop));
}, { passive: true });
}
function reapplySidebarScroll(): void {
const nav = document.querySelector<HTMLElement>(".sidebar .sidebar-nav");
if (!nav) return;
applySidebarScroll(nav, readStoredScroll());
}
// Changelog badge — fetches the count of entries newer than the locally
// stored "last seen" stamp and renders a dot + number on the Neuigkeiten
// link. Skipped on the changelog page itself because changelog.ts stamps
@@ -432,6 +480,11 @@ function initUserViewsGroup(): void {
for (const view of views) {
items.appendChild(renderUserViewItem(view, currentPath));
}
// The synchronous restore in initSidebarScrollRestore() happened
// before these views were appended, so a saved scrollTop that
// pointed below the Ansichten group would now sit on the wrong
// row. Re-apply once the layout has stabilised.
reapplySidebarScroll();
// After rendering, kick off count refresh for views that opted in.
for (const view of views) {
if (view.show_count) {

View File

@@ -16,7 +16,7 @@ import { initSidebar } from "./sidebar";
interface SubmissionDraftJSON {
id: string;
project_id: string;
project_id: string | null;
submission_code: string;
user_id: string;
name: string;
@@ -55,21 +55,40 @@ interface SubmissionDraftListResponse {
}
interface ParsedPath {
projectID: string;
submissionCode: string;
// 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 PATH_RE = /^\/projects\/([0-9a-fA-F-]{36})\/submissions\/([^/]+)\/draft(?:\/([0-9a-fA-F-]{36}))?\/?$/;
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 {
const m = PATH_RE.exec(window.location.pathname);
if (!m) return null;
return {
projectID: m[1],
submissionCode: decodeURIComponent(m[2]),
draftID: m[3],
};
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 {
@@ -266,6 +285,41 @@ async function boot(): Promise<void> {
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;
@@ -283,13 +337,13 @@ async function boot(): Promise<void> {
window.history.replaceState({}, "", url);
state.parsed = { ...parsed, draftID: draft.id };
} else {
draft = await createDraft(parsed);
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);
const view = await fetchView(state.parsed.projectID!, state.parsed.submissionCode!, draft.id);
state.view = view;
paint();
} catch (err) {
@@ -303,13 +357,15 @@ async function boot(): Promise<void> {
// ─────────────────────────────────────────────────────────────────────
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 createDraft(p: ParsedPath): Promise<SubmissionDraftJSON> {
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}`);
@@ -324,7 +380,13 @@ async function fetchView(projectID: string, code: string, draftID: string): Prom
return resp.json();
}
async function patchDraft(payload: { name?: string; variables?: Record<string, string> }): Promise<SubmissionDraftView> {
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) {
@@ -333,16 +395,17 @@ async function patchDraft(payload: { name?: string; variables?: Record<string, s
}
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(
`/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts/${p.draftID}`,
{
method: "PATCH",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
signal: ctl.signal,
},
);
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 {
@@ -353,10 +416,7 @@ async function patchDraft(payload: { name?: string; variables?: Record<string, s
async function deleteDraft(): Promise<void> {
const p = state.parsed;
if (!p.draftID) return;
const resp = await fetch(
`/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts/${p.draftID}`,
{ method: "DELETE" },
);
const resp = await fetch(`/api/submission-drafts/${p.draftID}`, { method: "DELETE" });
if (!resp.ok && resp.status !== 204) throw new Error(`delete draft ${resp.status}`);
}
@@ -373,6 +433,7 @@ function paint(): void {
paintHeader();
paintBackLink();
paintNoProjectBanner();
paintSwitcher();
paintNameRow();
paintVariables();
@@ -398,11 +459,58 @@ function paintHeader(): void {
function paintBackLink(): void {
const back = document.getElementById("submission-draft-back-link") as HTMLAnchorElement | null;
if (back && state.view) {
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;
@@ -569,9 +677,17 @@ async function renameDraft(newName: string): Promise<void> {
}
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 createDraft(state.parsed);
const url = `/projects/${state.parsed.projectID}/submissions/${encodeURIComponent(state.parsed.submissionCode)}/draft/${fresh.id}`;
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);
@@ -587,8 +703,10 @@ async function onDelete(): Promise<void> {
if (!window.confirm(msg)) return;
try {
await deleteDraft();
// Navigate back to the draft list (other drafts of this project / code).
const url = `/projects/${state.parsed.projectID}/submissions/${encodeURIComponent(state.parsed.submissionCode)}/draft`;
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);
@@ -604,7 +722,10 @@ async function onExport(btn: HTMLButtonElement): Promise<void> {
btn.disabled = true;
btn.textContent = isEN() ? "Exporting…" : "Exportiert…";
try {
const url = `/api/projects/${p.projectID}/submissions/${encodeURIComponent(p.submissionCode)}/drafts/${p.draftID}/export`;
// 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 = "";
@@ -624,6 +745,152 @@ async function onExport(btn: HTMLButtonElement): Promise<void> {
}
}
// ─────────────────────────────────────────────────────────────────────
// 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
// ─────────────────────────────────────────────────────────────────────

View 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();
});

View 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();
});

View File

@@ -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,36 +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 draftHref = `/projects/${encodeURIComponent(data.project_id)}/submissions/${encodeURIComponent(entry.submission_code)}/draft`;
const editBtn = entry.has_template
? `<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 = entry.has_template
? `<button type="button" class="btn-secondary 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>`;
const action = `${editBtn}${editBtn && generateBtn ? " " : ""}${generateBtn}`;
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();
@@ -129,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");

View File

@@ -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

View File

@@ -12,6 +12,7 @@ import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
import { initSidebar } from "./sidebar";
import {
type DeadlineResponse,
type Side,
calculateDeadlines,
escHtml,
formatDate,
@@ -24,6 +25,70 @@ import {
let selectedType = "";
let lastResponse: DeadlineResponse | null = null;
// Perspective state (t-paliad-250 / m/paliad#81). URL-driven so the
// view is shareable and survives reload:
// ?side=claimant|defendant → swaps which column owns the user's
// side (proactive vs reactive label).
// Default null = claimant-on-the-left.
// ?appellant=claimant|defendant → collapses party=both rows into the
// appellant's column (no mirror).
// Only meaningful for role-swap
// proceedings (Appeal etc.). Default
// null = legacy mirror behaviour.
let currentSide: Side = null;
let currentAppellant: Side = null;
// Proceedings where one party initiates and "both" rows are role-swap
// (i.e. either party files depending on who acted at the lower
// instance). For these proceedings the appellant selector is meaningful
// — when set, "both" rows collapse to a single row in the appellant's
// column. For first-instance proceedings (Inf, Rev, …) the selector is
// hidden because there's no appellant axis.
//
// Today: every upc.apl.* family member plus dpma.appeal.* and
// de.inf.olg / de.inf.bgh / de.null.bgh (DE Berufung / Revision).
// Conservative — false negatives just hide a control; false positives
// would show an irrelevant control.
const APPELLANT_AXIS_PROCEEDINGS = new Set([
"upc.apl.merits",
"upc.apl.cost",
"upc.apl.order",
"de.inf.olg",
"de.inf.bgh",
"de.null.bgh",
"dpma.appeal.bpatg",
"dpma.appeal.bgh",
"epa.opp.boa",
]);
function hasAppellantAxis(proceedingType: string): boolean {
return APPELLANT_AXIS_PROCEEDINGS.has(proceedingType);
}
function readSideFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("side");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function readAppellantFromURL(): Side {
const raw = new URLSearchParams(window.location.search).get("appellant");
return raw === "claimant" || raw === "defendant" ? raw : null;
}
function writeSideToURL(s: Side) {
const url = new URL(window.location.href);
if (s === null) url.searchParams.delete("side");
else url.searchParams.set("side", s);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
function writeAppellantToURL(a: Side) {
const url = new URL(window.location.href);
if (a === null) url.searchParams.delete("appellant");
else url.searchParams.set("appellant", a);
window.history.replaceState(null, "", url.pathname + (url.search ? url.search : "") + url.hash);
}
// Per-rule anchor overrides set by the click-to-edit affordance on
// timeline / column date cells. Posted as `anchorOverrides` to the
// /api/tools/fristenrechner calc so downstream rules re-anchor off the
@@ -154,20 +219,31 @@ async function doCalc() {
}
// triggerEventLabelFor picks the user-facing "Auslösendes Ereignis"
// label from the calc response. The root rule (isRootEvent=true) is
// the first event in the proceeding — e.g. Klageerhebung for
// upc.inf.cfi, Nichtigkeitsklage for upc.rev.cfi. Falls back to the
// active proceeding name if no root rule fires (shouldn't happen for
// healthy data, but safer than a blank). Fallback respects language —
// proceedingNameEN is consulted on EN before the DE proceedingName
// (m/paliad#58: prior fallback rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
// label from the calc response. Precedence:
//
// 1. Server-supplied triggerEventLabel from proceeding_types
// (mig 121, m/paliad#81). UPC Appeal sets this to
// "Anfechtbare Entscheidung" / "Appealable Decision" — its rules
// all carry a non-zero duration off the trigger date so none is
// the root, and the proceedingName fallback ("Berufungsverfahren")
// misnamed the input as the proceeding itself.
// 2. Root rule (isRootEvent=true) — the first event in the
// proceeding, e.g. Klageerhebung for upc.inf.cfi,
// Nichtigkeitsklage for upc.rev.cfi.
// 3. Active proceeding name — last-resort fallback. Language-aware
// (m/paliad#58: prior code rendered DE on EN for sub-track
// proceedings like upc.ccr.cfi which had no rules → no root).
function triggerEventLabelFor(data: DeadlineResponse): string {
const lang = getLang();
const curated = lang === "en"
? (data.triggerEventLabelEN || data.triggerEventLabel)
: (data.triggerEventLabel || data.triggerEventLabelEN);
if (curated) return curated;
const root = data.deadlines.find((d) => d.isRootEvent);
if (root) {
return getLang() === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
return lang === "en" ? (root.nameEN || root.name) : (root.name || root.nameEN);
}
if (getLang() === "en") {
if (lang === "en") {
return data.proceedingNameEN || data.proceedingName || "";
}
return data.proceedingName || data.proceedingNameEN || "";
@@ -213,7 +289,12 @@ function renderResults(data: DeadlineResponse) {
: "";
const bodyHtml = procedureView === "columns"
? renderColumnsBody(data, { editable: true, showNotes })
? renderColumnsBody(data, {
editable: true,
showNotes,
side: currentSide,
appellant: hasAppellantAxis(selectedType) ? currentAppellant : null,
})
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
container.innerHTML = headerHtml + noteHtml + bodyHtml;
@@ -276,6 +357,7 @@ function selectProceeding(btn: HTMLButtonElement) {
void populateCourtPicker("court-picker-row", "court-picker", selectedType);
syncFlagRows();
syncAppellantRowVisibility();
setProceedingPickerCollapsed(true, proceedingDisplayName(btn));
@@ -283,6 +365,29 @@ function selectProceeding(btn: HTMLButtonElement) {
scheduleCalc(0);
}
// syncAppellantRowVisibility hides the appellant selector for
// proceedings that have no appellant axis (first-instance Inf, Rev,
// …). Clears the in-memory state and the URL param when hidden so a
// shared link with ?appellant= doesn't leak into an unrelated
// proceeding's render.
function syncAppellantRowVisibility() {
const row = document.getElementById("appellant-row");
if (!row) return;
const visible = hasAppellantAxis(selectedType);
row.style.display = visible ? "" : "none";
if (!visible && currentAppellant !== null) {
currentAppellant = null;
writeAppellantToURL(null);
syncRadioGroup("appellant", "");
}
}
function syncRadioGroup(name: string, value: string) {
document.querySelectorAll<HTMLInputElement>(`input[type=radio][name=${name}]`).forEach((input) => {
input.checked = input.value === value;
});
}
function applyVerfahrensablaufViewBodyClass(view: ProcedureView) {
// Mirrors the events.ts pattern (body.events-view-*). The print
// stylesheet keys `body.verfahrensablauf-view-timeline` to
@@ -321,6 +426,38 @@ function initViewToggle() {
toggle.style.display = "none";
}
// initPerspectiveControls hydrates side+appellant from the URL,
// reflects state into the radio inputs, and wires onchange handlers
// that update state + URL + re-render. Re-render path skips the
// /api/tools/fristenrechner round-trip — perspective is a pure
// projection of the last response, no backend involved.
function initPerspectiveControls() {
currentSide = readSideFromURL();
currentAppellant = readAppellantFromURL();
syncRadioGroup("side", currentSide ?? "");
syncRadioGroup("appellant", currentAppellant ?? "");
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=side]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentSide = (v === "claimant" || v === "defendant") ? v : null;
writeSideToURL(currentSide);
if (lastResponse) renderResults(lastResponse);
});
});
document.querySelectorAll<HTMLInputElement>("input[type=radio][name=appellant]").forEach((input) => {
input.addEventListener("change", () => {
if (!input.checked) return;
const v = input.value;
currentAppellant = (v === "claimant" || v === "defendant") ? v : null;
writeAppellantToURL(currentAppellant);
if (lastResponse) renderResults(lastResponse);
});
});
}
document.addEventListener("DOMContentLoaded", () => {
initI18n();
initSidebar();
@@ -390,6 +527,7 @@ document.addEventListener("DOMContentLoaded", () => {
}
initViewToggle();
initPerspectiveControls();
onLangChange(() => {
// Active-button name updates with language change (the data-i18n

View File

@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test";
import {
type CalculatedDeadline,
bucketDeadlinesIntoColumns,
deadlineCardHtml,
} from "./verfahrensablauf-core";
@@ -65,3 +66,116 @@ describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
expect(html).not.toContain("data-rule-code=");
});
});
// Pure column-routing behaviour pinned by m/paliad#81. Hits
// bucketDeadlinesIntoColumns directly so the assertions stay in
// pure-Node territory (renderColumnsBody goes through escHtml ->
// document.createElement which isn't available in plain bun test).
//
// Scenario fixture mirrors the UPC Appeal "both parties" case m
// pasted into #81: every filing rule carries party='both' so the
// legacy mirror path duplicates every row across proactive +
// reactive. With ?appellant= set, the duplicate must collapse to a
// single row in the appellant's column.
describe("bucketDeadlinesIntoColumns — side+appellant column routing (m/paliad#81)", () => {
const both = (name: string, due: string): CalculatedDeadline => ({
code: name,
name,
nameEN: name,
party: "both",
priority: "mandatory",
ruleRef: "",
dueDate: due,
originalDate: due,
wasAdjusted: false,
isRootEvent: false,
isCourtSet: false,
});
const partySpecific = (party: string, name: string, due: string): CalculatedDeadline => ({
...both(name, due),
party,
});
test("default (no opts) mirrors 'both' rules into proactive AND reactive — legacy behaviour preserved", () => {
const rows = bucketDeadlinesIntoColumns([both("Notice of Appeal", "2026-07-23")]);
expect(rows).toHaveLength(1);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].court).toHaveLength(0);
});
test("appellant=claimant collapses 'both' rules into proactive only — no mirror", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23"), both("Statement of Grounds", "2026-09-23")],
{ appellant: "claimant" },
);
expect(rows.map((r) => r.proactive.map((d) => d.name))).toEqual([
["Notice of Appeal"],
["Statement of Grounds"],
]);
rows.forEach((r) => expect(r.reactive).toHaveLength(0));
});
test("appellant=defendant collapses 'both' rules into reactive only", () => {
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ appellant: "defendant" },
);
expect(rows[0].proactive).toHaveLength(0);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
});
test("side=defendant swaps which column owns claimant vs defendant rules", () => {
// claimant filing must land in REACTIVE (claimant is the opposing
// side from the defendant user's perspective), defendant filing in
// PROACTIVE. Court rules always go to court.
const rows = bucketDeadlinesIntoColumns(
[
partySpecific("claimant", "Klageschrift", "2026-01-01"),
partySpecific("defendant", "Klageerwiderung", "2026-04-01"),
partySpecific("court", "Urteil", "2026-10-01"),
],
{ side: "defendant" },
);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["Klageschrift"]);
expect(rows[1].proactive.map((d) => d.name)).toEqual(["Klageerwiderung"]);
expect(rows[2].court.map((d) => d.name)).toEqual(["Urteil"]);
});
test("side=defendant + appellant=defendant routes 'both' into PROACTIVE (user's own column)", () => {
// The user is the defendant AND the appellant, so the appellant's
// column == the user's own column == proactive after the swap.
const rows = bucketDeadlinesIntoColumns(
[both("Notice of Appeal", "2026-07-23")],
{ side: "defendant", appellant: "defendant" },
);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["Notice of Appeal"]);
expect(rows[0].reactive).toHaveLength(0);
});
test("rows align across columns by dueDate so same-day events stay on one grid row", () => {
const sameDate = "2026-07-23";
const rows = bucketDeadlinesIntoColumns([
partySpecific("claimant", "A", sameDate),
partySpecific("defendant", "B", sameDate),
partySpecific("court", "C", sameDate),
]);
expect(rows).toHaveLength(1);
expect(rows[0].proactive.map((d) => d.name)).toEqual(["A"]);
expect(rows[0].reactive.map((d) => d.name)).toEqual(["B"]);
expect(rows[0].court.map((d) => d.name)).toEqual(["C"]);
});
test("unscheduled rows (no dueDate) trail dated rows, preserving declaration order", () => {
const rows = bucketDeadlinesIntoColumns([
partySpecific("court", "Oral Hearing", ""),
partySpecific("claimant", "Statement of Claim", "2026-01-01"),
partySpecific("court", "Decision", ""),
]);
expect(rows.map((r) => [r.proactive, r.court, r.reactive].flat().map((d) => d.name))).toEqual([
["Statement of Claim"],
["Oral Hearing"],
["Decision"],
]);
});
});

View File

@@ -110,6 +110,16 @@ export interface DeadlineResponse {
// explains the framing. (m/paliad#58)
contextualNote?: string;
contextualNoteEN?: string;
// triggerEventLabel / triggerEventLabelEN: optional caption for the
// "Auslösendes Ereignis" / "Triggering event" field on
// /tools/verfahrensablauf. Populated from paliad.proceeding_types
// when set (mig 121). The page prefers this over the proceedingName
// fallback that fires when no rule has isRootEvent=true. UPC Appeal
// uses this so the field reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// (m/paliad#81)
triggerEventLabel?: string;
triggerEventLabelEN?: string;
}
export interface CourtRow {
@@ -412,42 +422,116 @@ export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { sh
return html;
}
// Three-column timeline layout: Proactive (claimant) | Court | Reactive
// (defendant). Each grid row shares a dueDate so same-day events line up
// across columns; party=both renders in BOTH the Proactive and Reactive
// cells of the row. Undated rows (Urteil etc.) trail the dated tail, each
// keyed by sequence-order so e.g. Urteil precedes Berufungseinlegung.
export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "showParty"> = {}): string {
type Cell = CalculatedDeadline[];
type Row = { proactive: Cell; court: Cell; reactive: Cell };
// Three-column timeline layout: Proactive | Court | Reactive.
//
// Column assignment per deadline (see m/paliad#81):
//
// - party=claimant → proactive
// - party=defendant → reactive
// - party=court → court
// - party=both → BOTH proactive AND reactive (mirror).
//
// When `opts.appellant` is set (claimant|defendant), "both" rows
// collapse to a single row in the appellant's column. The intent is
// role-swap proceedings (UPC Appeal, Counterclaim, …) where the
// "both" tag really means "either party files, depending on who
// initiated" — once you pick the initiator, the duplicate goes away.
// Hard rule from the issue: "When set, 'both parties' rows collapse
// to one row in the appellant's column." This is a UI projection
// only; the deadline_rules schema is unchanged. A follow-up issue
// can enrich per-rule role tagging so respondent-side filings
// (Response to Appeal, Cross-Appeal) land in the respondent's
// column — out of scope for #81.
//
// `opts.side` controls the column LABELS: side=defendant swaps the
// "Proactive (Klägerseite)" / "Reactive (Beklagtenseite)" headers
// so the user's own side is the proactive (= "your filings") column.
// It does NOT filter deadlines — the user still sees all deadlines
// in the proceeding. Default `side=null` keeps the legacy
// claimant-on-the-left layout. Unscheduled (court-set) rows trail
// the dated tail, each keyed by sequence-order so e.g. Urteil
// precedes Berufungseinlegung.
export type Side = "claimant" | "defendant" | null;
export interface ColumnsBodyOpts {
editable?: boolean;
showNotes?: boolean;
// side: which side the user is on. Drives column-label swap;
// does NOT filter rows. Default null = claimant-on-the-left.
side?: Side;
// appellant: which side initiated the appeal / counterclaim.
// When set, party=both rows go to the appellant's column ONLY
// (no mirror). Default null = mirror "both" into both cells
// (legacy behaviour). Independent of `side`.
appellant?: Side;
}
// ColumnsRow is the per-due-date bucket the renderer consumes. Public
// so unit tests can hit the pure routing logic without going through
// document.createElement (no jsdom in this repo).
export interface ColumnsRow {
key: string;
proactive: CalculatedDeadline[];
court: CalculatedDeadline[];
reactive: CalculatedDeadline[];
}
export interface BucketingOpts {
side?: Side;
appellant?: Side;
}
// bucketDeadlinesIntoColumns is the pure routing primitive that
// renderColumnsBody uses. Extracted as its own export so the per-row
// column placement (including the side-swap + appellant-collapse
// logic from m/paliad#81) is unit-testable without a DOM. The
// returned rows are sorted: dated rows ascending by dueDate, then
// unscheduled rows in declaration order (each keyed by sequence).
export function bucketDeadlinesIntoColumns(
deadlines: CalculatedDeadline[],
opts: BucketingOpts = {},
): ColumnsRow[] {
const userSide: Side = opts.side ?? null;
const claimantColumn: "proactive" | "reactive" = userSide === "defendant" ? "reactive" : "proactive";
const defendantColumn: "proactive" | "reactive" = claimantColumn === "proactive" ? "reactive" : "proactive";
const appellantColumn: "proactive" | "reactive" | null =
opts.appellant === "claimant" ? claimantColumn
: opts.appellant === "defendant" ? defendantColumn
: null;
const UNSCHEDULED_PREFIX = "__unscheduled__";
const rowsMap = new Map<string, Row>();
const ensureRow = (key: string): Row => {
const rowsMap = new Map<string, ColumnsRow>();
const ensureRow = (key: string): ColumnsRow => {
let r = rowsMap.get(key);
if (!r) {
r = { proactive: [], court: [], reactive: [] };
r = { key, proactive: [], court: [], reactive: [] };
rowsMap.set(key, r);
}
return r;
};
data.deadlines.forEach((dl, idx) => {
deadlines.forEach((dl, idx) => {
const key = dl.dueDate || `${UNSCHEDULED_PREFIX}${String(idx).padStart(4, "0")}`;
const row = ensureRow(key);
switch (dl.party) {
case "claimant":
row.proactive.push(dl);
row[claimantColumn].push(dl);
break;
case "defendant":
row.reactive.push(dl);
row[defendantColumn].push(dl);
break;
case "court":
row.court.push(dl);
break;
case "both":
row.proactive.push(dl);
row.reactive.push(dl);
if (appellantColumn !== null) {
// Role-swap collapse: appellant initiated → both → one row
// in appellant's column. Mirror suppressed.
row[appellantColumn].push(dl);
} else {
row.proactive.push(dl);
row.reactive.push(dl);
}
break;
default:
row.court.push(dl);
@@ -462,17 +546,31 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
}
datedKeys.sort();
unscheduledKeys.sort();
const keys = [...datedKeys, ...unscheduledKeys];
return [...datedKeys, ...unscheduledKeys].map((k) => rowsMap.get(k)!);
}
export function renderColumnsBody(data: DeadlineResponse, opts: ColumnsBodyOpts = {}): string {
const userSide: Side = opts.side ?? null;
const rows = bucketDeadlinesIntoColumns(data.deadlines, { side: userSide, appellant: opts.appellant });
const appellantColumn: "proactive" | "reactive" | null =
opts.appellant === "claimant" ? (userSide === "defendant" ? "reactive" : "proactive")
: opts.appellant === "defendant" ? (userSide === "defendant" ? "proactive" : "reactive")
: null;
const cardOpts: CardOpts = { showParty: false, editable: opts.editable, showNotes: opts.showNotes };
// Collapsed "both" rows lose their mirror tag — there's no longer
// a sibling row to mirror to, so the "↔ beide Seiten" hint would
// be misleading. Keep it for the legacy mirror path.
const showMirrorTag = appellantColumn === null;
const renderCell = (items: CalculatedDeadline[]): string => {
if (items.length === 0) {
return `<div class="fr-col-cell fr-col-cell--empty"></div>`;
}
const cards = items
.map((dl) => {
const mirrorTag = dl.party === "both"
const mirrorTag = showMirrorTag && dl.party === "both"
? `<div class="fr-col-mirror">↔ ${escHtml(t("deadlines.party.both.label"))}</div>`
: "";
return `<div class="fr-col-item ${dl.isRootEvent ? "fr-col-root" : ""}">
@@ -487,13 +585,22 @@ export function renderColumnsBody(data: DeadlineResponse, opts: Omit<CardOpts, "
const headerCell = (label: string, cls: string) =>
`<div class="fr-col-header ${cls}">${escHtml(label)}</div>`;
let html = '<div class="fr-columns-view">';
html += headerCell(t("deadlines.col.proactive"), "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(t("deadlines.col.reactive"), "fr-col-reactive");
// Column-label swap when side=defendant: the user's own side stays
// labelled "Proaktiv" (their filings) and the opposing side is
// "Reaktiv". Default keeps the legacy claimant=proactive labels.
const proactiveLabel = userSide === "defendant"
? t("deadlines.col.proactive.defendant")
: t("deadlines.col.proactive");
const reactiveLabel = userSide === "defendant"
? t("deadlines.col.reactive.claimant")
: t("deadlines.col.reactive");
for (const key of keys) {
const row = rowsMap.get(key)!;
let html = '<div class="fr-columns-view">';
html += headerCell(proactiveLabel, "fr-col-proactive");
html += headerCell(t("deadlines.col.court"), "fr-col-court");
html += headerCell(reactiveLabel, "fr-col-reactive");
for (const row of rows) {
html += renderCell(row.proactive);
html += renderCell(row.court);
html += renderCell(row.reactive);

View File

@@ -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) +

View File

@@ -1112,6 +1112,10 @@ export type I18nKey =
| "deadlines.adjusted.weekend"
| "deadlines.adjusted.weekend.saturday"
| "deadlines.adjusted.weekend.sunday"
| "deadlines.appellant.claimant"
| "deadlines.appellant.defendant"
| "deadlines.appellant.label"
| "deadlines.appellant.none"
| "deadlines.calculate"
| "deadlines.card.calc.add_to_project"
| "deadlines.card.calc.add_to_project.disabled"
@@ -1139,7 +1143,9 @@ export type I18nKey =
| "deadlines.col.due"
| "deadlines.col.event_type"
| "deadlines.col.proactive"
| "deadlines.col.proactive.defendant"
| "deadlines.col.reactive"
| "deadlines.col.reactive.claimant"
| "deadlines.col.rule"
| "deadlines.col.status"
| "deadlines.col.title"
@@ -1366,6 +1372,10 @@ export type I18nKey =
| "deadlines.search.placeholder"
| "deadlines.search.results.count"
| "deadlines.search.results.count_one"
| "deadlines.side.both"
| "deadlines.side.claimant"
| "deadlines.side.defendant"
| "deadlines.side.label"
| "deadlines.source.caldav"
| "deadlines.source.fristenrechner"
| "deadlines.source.imported"
@@ -1904,6 +1914,7 @@ export type I18nKey =
| "nav.paliadin"
| "nav.projekte"
| "nav.soon.tooltip"
| "nav.submissions"
| "nav.team"
| "nav.termine"
| "nav.user_views.new"
@@ -2187,6 +2198,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"
@@ -2276,6 +2292,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"
@@ -2507,6 +2524,34 @@ export type I18nKey =
| "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"

View File

@@ -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.
@@ -625,17 +614,18 @@ export function renderProjectsDetail(): string {
</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"
@@ -646,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">
@@ -665,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. */}

View File

@@ -3548,6 +3548,30 @@ input[type="range"]::-moz-range-thumb {
cursor: pointer;
}
/* Verfahrensablauf — perspective strip (side + appellant selectors,
t-paliad-250 / m/paliad#81). Two rows so the labels stack cleanly on
narrow viewports; each row reuses .fristen-view-toggle for the
chip-radio cluster so the visual language matches the view-toggle
above it. The appellant row hides for proceedings without an
appellant axis (Inf / Rev first-instance). */
.verfahrensablauf-perspective {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 0.75rem;
}
.verfahrensablauf-perspective-row {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
}
.verfahrensablauf-perspective-row .fristen-view-toggle {
margin-bottom: 0;
}
/* Compact note hint — sits in the timeline-meta line when the notes
toggle is off. Native browser tooltip via title= attribute carries
the full text on hover; tabindex=0 + aria-label make it
@@ -5586,6 +5610,71 @@ 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;
}
@@ -5780,6 +5869,111 @@ dialog.modal::backdrop {
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;
@@ -7121,6 +7315,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;

View 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 &mdash; 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&uuml;rfe &uuml;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&auml;dt&hellip;</p>
<div className="entity-empty" id="submissions-index-empty" style="display:none">
<p data-i18n="submissions.index.empty">
Noch keine Entw&uuml;rfe. Beginnen Sie mit einem neuen Entwurf &mdash; 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&auml;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&auml;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>
);
}

View 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 &mdash; 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">&larr; Zur&uuml;ck zur &Uuml;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&auml;hlen Sie eine Vorlage. Optional verkn&uuml;pfen Sie den
Entwurf mit einem Projekt &mdash; sonst f&uuml;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&auml;dt&hellip;</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&auml;tze. Filter zur&uuml;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&auml;hlen</h2>
<button type="button" id="submissions-new-project-modal-close"
className="modal-close" aria-label="Close">&times;</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&auml;dt Projekte&hellip;</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>
);
}

View File

@@ -210,6 +210,53 @@ export function renderVerfahrensablauf(): string {
Fristen berechnen
</button>
</div>
{/* Perspective strip (t-paliad-250 / m/paliad#81). Side
swaps the column LABELS so the user's own side is
proactive (= "your filings"). Appellant collapses
party=both rows to a single column when set — only
relevant for role-swap proceedings (Appeal etc.);
the row hides itself when the picked proceeding has
no appellant axis (see hasAppellantAxis() in the
client). Both selectors are URL-driven (?side= +
?appellant=) so the perspective survives reload
and is shareable. */}
<div className="verfahrensablauf-perspective" id="verfahrensablauf-perspective">
<div className="verfahrensablauf-perspective-row" id="side-row">
<span className="date-label" data-i18n="deadlines.side.label">Seite:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Side">
<label className="fristen-view-option">
<input type="radio" name="side" value="claimant" />
<span data-i18n="deadlines.side.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="defendant" />
<span data-i18n="deadlines.side.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="side" value="" checked />
<span data-i18n="deadlines.side.both">Beide</span>
</label>
</div>
</div>
<div className="verfahrensablauf-perspective-row" id="appellant-row" style="display:none">
<span className="date-label" data-i18n="deadlines.appellant.label">Berufung durch:</span>
<div className="fristen-view-toggle" role="radiogroup" aria-label="Appellant">
<label className="fristen-view-option">
<input type="radio" name="appellant" value="claimant" />
<span data-i18n="deadlines.appellant.claimant">Klägerseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="defendant" />
<span data-i18n="deadlines.appellant.defendant">Beklagtenseite</span>
</label>
<label className="fristen-view-option">
<input type="radio" name="appellant" value="" checked />
<span data-i18n="deadlines.appellant.none"></span>
</label>
</div>
</div>
</div>
</div>
<div className="wizard-step" id="step-3" style="display:none">

View File

@@ -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));

View File

@@ -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)
)
);

View File

@@ -0,0 +1,7 @@
-- Drop the optional trigger-event label columns added in
-- 121_proceeding_trigger_event_label.up.sql. Any populated rows lose
-- their override; the frontend falls back to proceedingName.
ALTER TABLE paliad.proceeding_types
DROP COLUMN IF EXISTS trigger_event_label_en,
DROP COLUMN IF EXISTS trigger_event_label_de;

View File

@@ -0,0 +1,27 @@
-- t-paliad-250 / m/paliad#81 — Concern B: UPC Appeal trigger-event label.
--
-- The /tools/verfahrensablauf "Auslösendes Ereignis" caption falls back
-- to `paliad.proceeding_types.name` whenever the calculator finds no
-- root rule (duration_value=0 + parent_id=NULL + !is_court_set). For
-- UPC Appeal (upc.apl.merits) all rules carry a non-zero duration off
-- the trigger date, so the caption reads "Berufungsverfahren" /
-- "Appeal" — the proceeding itself — instead of the appealable
-- decision that actually starts the clock.
--
-- Fix: add an optional `trigger_event_label_de` / `trigger_event_label_en`
-- pair on proceeding_types. When set, the calculator surfaces it on the
-- response (TriggerEventLabel{,EN}) and the frontend prefers it over
-- proceedingName. No deadline-rule additions, no slug changes; existing
-- proceeding_type.code stays stable (hard rule from the issue).
ALTER TABLE paliad.proceeding_types
ADD COLUMN IF NOT EXISTS trigger_event_label_de text,
ADD COLUMN IF NOT EXISTS trigger_event_label_en text;
-- UPC Appeal: the trigger date is the date of the appealable first-instance
-- decision (per UPC RoP R.224(1)(a) the 2-month appeal clock runs from
-- service of the decision per R.220.1(a)/(b)).
UPDATE paliad.proceeding_types
SET trigger_event_label_de = 'Anfechtbare Entscheidung',
trigger_event_label_en = 'Appealable Decision'
WHERE code = 'upc.apl.merits';

View File

@@ -276,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)
@@ -329,6 +334,21 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
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)
@@ -491,6 +511,11 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
// 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.

View File

@@ -72,7 +72,7 @@ type submissionDraftView struct {
type submissionDraftJSON struct {
ID uuid.UUID `json:"id"`
ProjectID uuid.UUID `json:"project_id"`
ProjectID *uuid.UUID `json:"project_id"`
SubmissionCode string `json:"submission_code"`
UserID uuid.UUID `json:"user_id"`
Name string `json:"name"`
@@ -109,6 +109,76 @@ type submissionDraftPatchInput struct {
// Handlers
// ─────────────────────────────────────────────────────────────────────
// userSubmissionDraftRow is the on-the-wire shape for the global
// /submissions index — each draft enriched with the project's title +
// reference for the row.
//
// ProjectID / ProjectTitle / ProjectReference are nullable since
// t-paliad-243 — a global Schriftsatz draft started from
// /submissions/new without binding a project surfaces here with all
// three set to null, and the frontend renders a dedicated
// "Ohne Projekt" label for the project column.
type userSubmissionDraftRow struct {
ID uuid.UUID `json:"id"`
ProjectID *uuid.UUID `json:"project_id"`
ProjectTitle *string `json:"project_title"`
ProjectReference *string `json:"project_reference,omitempty"`
SubmissionCode string `json:"submission_code"`
Name string `json:"name"`
LastExportedAt *time.Time `json:"last_exported_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
CreatedAt time.Time `json:"created_at"`
}
// handleListUserSubmissionDrafts returns every draft the caller owns
// across every visible project, ordered by updated_at DESC. Backs the
// global /submissions index page (t-paliad-240).
func handleListUserSubmissionDrafts(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submission drafts not configured",
})
return
}
rows, err := dbSvc.submissionDraft.ListAllForUser(r.Context(), uid)
if err != nil {
writeServiceError(w, err)
return
}
out := make([]userSubmissionDraftRow, 0, len(rows))
for i := range rows {
d := &rows[i]
out = append(out, userSubmissionDraftRow{
ID: d.ID,
ProjectID: d.ProjectID,
ProjectTitle: d.ProjectTitle,
ProjectReference: d.ProjectReference,
SubmissionCode: d.SubmissionCode,
Name: d.Name,
LastExportedAt: d.LastExportedAt,
UpdatedAt: d.UpdatedAt,
CreatedAt: d.CreatedAt,
})
}
writeJSON(w, http.StatusOK, map[string]any{"drafts": out})
}
// handleSubmissionsIndexPage serves dist/submissions-index.html for the
// global /submissions index — lists every draft the caller owns across
// visible projects. Sits at top level alongside /checklists, /courts etc.
func handleSubmissionsIndexPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/submissions-index.html")
}
// handleListSubmissionDrafts returns every draft the caller owns for
// the given (project, submission_code).
func handleListSubmissionDrafts(w http.ResponseWriter, r *http.Request) {
@@ -179,7 +249,7 @@ func handleCreateSubmissionDraft(w http.ResponseWriter, r *http.Request) {
user, _ := dbSvc.users.GetByID(r.Context(), uid)
lang := userLang(user)
d, err := dbSvc.submissionDraft.Create(r.Context(), uid, projectID, code, lang)
d, err := dbSvc.submissionDraft.Create(r.Context(), uid, &projectID, code, lang)
if err != nil {
writeServiceError(w, err)
return
@@ -465,6 +535,307 @@ func handleSubmissionDraftPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/submission-draft.html")
}
// handleSubmissionDraftGlobalPage serves dist/submission-draft.html for
// project-less drafts at /submissions/draft/{draft_id} (t-paliad-243).
// The page shell is identical to the project-scoped one; the client
// bundle parses the URL and switches to no-project mode when no
// /projects/{id}/ prefix is present.
//
// Owner check happens at the API layer when the client fetches the
// draft view; this handler only guards the page chrome and leaves the
// 404-on-not-found semantics to the API.
func handleSubmissionDraftGlobalPage(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
if _, ok := requireUser(w, r); !ok {
return
}
if _, err := uuid.Parse(r.PathValue("draft_id")); err != nil {
serveSubmissionDraftNotFound(w)
return
}
http.ServeFile(w, r, "dist/submission-draft.html")
}
// handleSubmissionsNewPage serves dist/submissions-new.html — the
// global "Neuer Entwurf" picker (t-paliad-243). The page lists the
// full submission catalog grouped by proceeding and lets the lawyer
// pick a template with or without binding it to a project.
func handleSubmissionsNewPage(w http.ResponseWriter, r *http.Request) {
http.ServeFile(w, r, "dist/submissions-new.html")
}
// globalCreateDraftInput is the body shape for POST /api/submission-drafts.
// project_id is optional; when omitted or null the draft is created
// without a project binding (t-paliad-243).
type globalCreateDraftInput struct {
SubmissionCode string `json:"submission_code"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
}
// handleCreateGlobalSubmissionDraft creates a draft from the global
// /submissions/new picker. Compared to the project-scoped sibling, the
// project_id comes from the JSON body (optional) instead of the URL
// path. Used by the picker to spawn a draft and redirect to the editor.
func handleCreateGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{
"error": "submission drafts not configured",
})
return
}
var in globalCreateDraftInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
code := strings.TrimSpace(in.SubmissionCode)
if code == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "submission_code required"})
return
}
user, _ := dbSvc.users.GetByID(r.Context(), uid)
lang := userLang(user)
d, err := dbSvc.submissionDraft.Create(r.Context(), uid, in.ProjectID, code, lang)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
view, err := buildSubmissionDraftView(r.Context(), d, lang)
if err != nil {
log.Printf("submission_drafts: build view after global create (code=%s): %v", code, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusCreated, view)
}
// handleGetGlobalSubmissionDraft returns the editor payload by draft
// id alone (t-paliad-243). The project-less editor at
// /submissions/draft/{draft_id} doesn't have a project segment to
// route through the existing project-scoped endpoint; this is the
// global counterpart. Works for both project-less AND project-scoped
// drafts since the draft row carries its own project_id.
func handleGetGlobalSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
return
}
d, err := dbSvc.submissionDraft.Get(r.Context(), uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
user, _ := dbSvc.users.GetByID(r.Context(), uid)
lang := userLang(user)
view, err := buildSubmissionDraftView(r.Context(), d, lang)
if err != nil {
log.Printf("submission_drafts: build view (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, view)
}
// globalDraftPatchInput is PATCH input for /api/submission-drafts/{draft_id}.
// Same Name + Variables semantics as the project-scoped patch, plus
// ProjectID for the "Projekt zuweisen" affordance — the lawyer can
// attach (assign a UUID) or detach (set null) at any time. A missing
// `project_id` key is treated as "no change"; a present-but-null value
// detaches.
type globalDraftPatchInput struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
// projectIDProvided is true when the JSON included the "project_id"
// key (regardless of value); needed to distinguish "no change" from
// "set to null". Set by the custom UnmarshalJSON below.
ProjectID *uuid.UUID `json:"project_id,omitempty"`
projectIDProvided bool
}
func (g *globalDraftPatchInput) UnmarshalJSON(data []byte) error {
type alias struct {
Name *string `json:"name,omitempty"`
Variables *services.PlaceholderMap `json:"variables,omitempty"`
ProjectID *uuid.UUID `json:"project_id,omitempty"`
}
var a alias
if err := json.Unmarshal(data, &a); err != nil {
return err
}
g.Name = a.Name
g.Variables = a.Variables
g.ProjectID = a.ProjectID
// Detect whether "project_id" was present in the JSON object.
var raw map[string]json.RawMessage
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
_, g.projectIDProvided = raw["project_id"]
return nil
}
// handleGlobalPatchSubmissionDraft updates a draft by id (t-paliad-243).
// Supports the project_id mutation in addition to name + variables so
// the project-less editor can offer "Projekt zuweisen" and persist the
// chosen project on the same row.
func handleGlobalPatchSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
return
}
var in globalDraftPatchInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
patch := services.DraftPatch{Name: in.Name, Variables: in.Variables}
if in.projectIDProvided {
pid := in.ProjectID // may be nil → detach
patch.ProjectID = &pid
}
d, err := dbSvc.submissionDraft.Update(r.Context(), uid, draftID, patch)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
user, _ := dbSvc.users.GetByID(r.Context(), uid)
lang := userLang(user)
view, err := buildSubmissionDraftView(r.Context(), d, lang)
if err != nil {
log.Printf("submission_drafts: build view after global patch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "internal error"})
return
}
writeJSON(w, http.StatusOK, view)
}
// handleGlobalDeleteSubmissionDraft removes a draft by id.
func handleGlobalDeleteSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
return
}
if err := dbSvc.submissionDraft.Delete(r.Context(), uid, draftID); err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
// handleGlobalExportSubmissionDraft renders and streams the .docx for
// the draft. Shares writeSubmissionDraftAuditRow + writeSubmissionDraftProjectEvent
// with the project-scoped sibling — both handle a nil ProjectID
// correctly (audit scope flips to 'user', project-event is skipped).
func handleGlobalExportSubmissionDraft(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
draftID, ok := parseUUIDPath(w, r, "draft_id", "draft id")
if !ok {
return
}
if dbSvc.submissionDraft == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "submission drafts not configured"})
return
}
ctx, cancel := context.WithTimeout(r.Context(), submissionDraftExportTimeout)
defer cancel()
d, err := dbSvc.submissionDraft.Get(ctx, uid, draftID)
if err != nil {
writeSubmissionDraftServiceError(w, err)
return
}
tplBytes, tplSHA, err := resolveSubmissionTemplate(ctx, d.SubmissionCode)
if err != nil {
log.Printf("submission_drafts: export template fetch (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "template upstream unreachable"})
return
}
docx, resolved, err := dbSvc.submissionDraft.Export(ctx, d, tplBytes)
if err != nil {
log.Printf("submission_drafts: export render (draft=%s): %v", draftID, err)
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "render failed"})
return
}
filename := submissionFileName(resolved.Rule, resolved.Project, resolved.Lang)
bgCtx, cancelBG := context.WithTimeout(context.Background(), 10*time.Second)
defer cancelBG()
if err := dbSvc.submissionDraft.MarkExported(bgCtx, d.ID, tplSHA); err != nil {
log.Printf("submission_drafts: mark exported (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftAuditRow(bgCtx, resolved.User, d, filename, tplSHA); err != nil {
log.Printf("submission_drafts: audit insert failed (draft=%s): %v", draftID, err)
}
if err := writeSubmissionDraftProjectEvent(bgCtx, d, resolved, filename); err != nil {
log.Printf("submission_drafts: project event insert failed (draft=%s): %v", draftID, err)
}
w.Header().Set("Content-Type", docxMime)
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename=%q`, filename))
w.Header().Set("Content-Length", strconv.Itoa(len(docx)))
if _, err := w.Write(docx); err != nil {
log.Printf("submission_drafts: response write failed (draft=%s): %v", draftID, err)
}
}
// serveSubmissionDraftNotFound writes the standard notfound chrome
// with a 404 status — same shape the chart page uses.
func serveSubmissionDraftNotFound(w http.ResponseWriter) {
@@ -587,6 +958,11 @@ func draftToJSON(d *services.SubmissionDraft) submissionDraftJSON {
// writeSubmissionDraftAuditRow logs one row in paliad.system_audit_log
// per export. Distinct event_type from the format-only 'submission.generated'
// so admins can tell the two paths apart in the feed.
//
// For a project-less draft (t-paliad-243) the scope is widened to
// 'user' with scope_root = draft.user_id; the audit feed therefore
// surfaces these exports on the user's row rather than against a
// (non-existent) project.
func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *services.SubmissionDraft, filename, templateSHA string) error {
meta := map[string]any{
"submission_code": d.SubmissionCode,
@@ -604,19 +980,32 @@ func writeSubmissionDraftAuditRow(ctx context.Context, user *models.User, d *ser
actorID = user.ID
actorEmail = user.Email
}
scope := "project"
scopeRoot := ""
if d.ProjectID != nil {
scopeRoot = d.ProjectID.String()
} else {
scope = "user"
scopeRoot = d.UserID.String()
}
_, err := dbSvc.projects.DB().ExecContext(ctx,
`INSERT INTO paliad.system_audit_log
(event_type, actor_id, actor_email, scope, scope_root, metadata)
VALUES ('submission.exported', $1, $2, 'project', $3, $4::jsonb)`,
actorID, actorEmail, d.ProjectID.String(), string(body),
VALUES ('submission.exported', $1, $2, $3, $4, $5::jsonb)`,
actorID, actorEmail, scope, scopeRoot, string(body),
)
return err
}
// writeSubmissionDraftProjectEvent records a project-level Verlauf
// entry so the export surfaces on the project's timeline. Uses the
// same event_type convention as other custom milestones.
// same event_type convention as other custom milestones. A project-less
// draft (t-paliad-243) has no timeline to write to — the caller skips
// this row entirely; we no-op defensively here for safety.
func writeSubmissionDraftProjectEvent(ctx context.Context, d *services.SubmissionDraft, resolved *services.SubmissionVarsResult, filename string) error {
if d.ProjectID == nil {
return nil
}
ruleName := ""
if resolved.Rule != nil {
ruleName = resolved.Rule.Name
@@ -633,7 +1022,7 @@ func writeSubmissionDraftProjectEvent(ctx context.Context, d *services.Submissio
`INSERT INTO paliad.project_events
(project_id, event_type, title, metadata, created_by, event_date, timeline_kind)
VALUES ($1, 'submission_exported', $2, $3::jsonb, $4, now(), 'custom_milestone')`,
d.ProjectID, fmt.Sprintf("%s exportiert", strings.TrimSpace(ruleName)),
*d.ProjectID, fmt.Sprintf("%s exportiert", strings.TrimSpace(ruleName)),
string(body), d.UserID,
)
return err

View File

@@ -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 —

View File

@@ -721,6 +721,14 @@ type ProceedingType struct {
DefaultColor string `db:"default_color" json:"default_color"`
SortOrder int `db:"sort_order" json:"sort_order"`
IsActive bool `db:"is_active" json:"is_active"`
// TriggerEventLabel{DE,EN}: optional caption for /tools/verfahrensablauf
// "Auslösendes Ereignis". When set, overrides the proceedingName fallback
// that fires when no rule has IsRootEvent=true. Populated for UPC Appeal
// (mig 121) so the caption reads "Anfechtbare Entscheidung" /
// "Appealable Decision" instead of "Berufungsverfahren" / "Appeal".
// NULL on most proceedings — they already carry a root rule.
TriggerEventLabelDE *string `db:"trigger_event_label_de" json:"trigger_event_label_de,omitempty"`
TriggerEventLabelEN *string `db:"trigger_event_label_en" json:"trigger_event_label_en,omitempty"`
}
// TriggerEvent is a UPC procedural event that can start one or more deadlines

View File

@@ -115,6 +115,16 @@ type UIResponse struct {
// note explaining the framing.
ContextualNote string `json:"contextualNote,omitempty"`
ContextualNoteEN string `json:"contextualNoteEN,omitempty"`
// TriggerEventLabel / TriggerEventLabelEN: optional caption for the
// /tools/verfahrensablauf "Auslösendes Ereignis" field. Populated
// from paliad.proceeding_types.trigger_event_label_{de,en} (mig 121).
// The frontend prefers this over the proceedingName fallback that
// fires when no rule has IsRootEvent=true — UPC Appeal needed it
// because all its rules carry a non-zero duration off the trigger
// date so no rule is the "anchor". The trigger event for UPC Appeal
// is the appealable first-instance decision (m/paliad#81).
TriggerEventLabel string `json:"triggerEventLabel,omitempty"`
TriggerEventLabelEN string `json:"triggerEventLabelEN,omitempty"`
}
// ErrUnknownProceedingType is returned when the UI sends an unrecognised code.
@@ -237,14 +247,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
// Look up proceeding type metadata.
var pt struct {
ID int `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
Jurisdiction *string `db:"jurisdiction"`
ID int `db:"id"`
Code string `db:"code"`
Name string `db:"name"`
NameEN string `db:"name_en"`
Jurisdiction *string `db:"jurisdiction"`
TriggerEventLabelDE *string `db:"trigger_event_label_de"`
TriggerEventLabelEN *string `db:"trigger_event_label_en"`
}
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
`SELECT id, code, name, name_en, jurisdiction,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, proceedingCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -271,7 +284,8 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
hasSubTrackNote = true
// Re-resolve to the parent proceeding for rule lookup.
err = s.rules.db.GetContext(ctx, &pt,
`SELECT id, code, name, name_en, jurisdiction
`SELECT id, code, name, name_en, jurisdiction,
trigger_event_label_de, trigger_event_label_en
FROM paliad.proceeding_types
WHERE code = $1 AND is_active = true`, route.ParentCode)
if errors.Is(err, sql.ErrNoRows) {
@@ -604,6 +618,17 @@ func (s *FristenrechnerService) Calculate(ctx context.Context, proceedingCode, t
TriggerDate: triggerDateStr,
Deadlines: deadlines,
}
// Sub-track routing keeps the user-picked proceeding's identity,
// so the trigger-event label rides on `pickedProceeding` (e.g.
// upc.ccr.cfi inherits whatever upc.inf.cfi's caption is, not
// upc.ccr.cfi's own — which is fine: the sub-track note already
// explains the framing).
if pickedProceeding.TriggerEventLabelDE != nil {
resp.TriggerEventLabel = *pickedProceeding.TriggerEventLabelDE
}
if pickedProceeding.TriggerEventLabelEN != nil {
resp.TriggerEventLabelEN = *pickedProceeding.TriggerEventLabelEN
}
if hasSubTrackNote {
resp.ContextualNote = subTrackNote.NoteDE
resp.ContextualNoteEN = subTrackNote.NoteEN

View File

@@ -35,9 +35,15 @@ import (
)
// 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"`
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"`
@@ -74,9 +80,20 @@ func NewSubmissionDraftService(db *sqlx.DB, projects *ProjectService, vars *Subm
// 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
@@ -117,10 +134,61 @@ func (s *SubmissionDraftService) List(ctx context.Context, userID, projectID uui
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,
@@ -134,14 +202,16 @@ func (s *SubmissionDraftService) Get(ctx context.Context, userID, draftID uuid.U
if err != nil {
return nil, fmt.Errorf("get submission draft: %w", err)
}
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
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
}
return nil, err
}
if err := d.decodeVariables(); err != nil {
return nil, err
@@ -166,7 +236,7 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
LIMIT 1`,
projectID, submissionCode, userID)
if errors.Is(err, sql.ErrNoRows) {
return s.Create(ctx, userID, projectID, submissionCode, lang)
return s.Create(ctx, userID, &projectID, submissionCode, lang)
}
if err != nil {
return nil, fmt.Errorf("ensure latest submission draft: %w", err)
@@ -179,9 +249,15 @@ func (s *SubmissionDraftService) EnsureLatest(ctx context.Context, userID, proje
// Create makes a new draft with an auto-incremented "Entwurf N" name
// ("Draft N" for English locale). Lawyer can rename via Update.
func (s *SubmissionDraftService) Create(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
//
// 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 {
@@ -207,16 +283,29 @@ func (s *SubmissionDraftService) Create(ctx context.Context, userID, projectID u
// 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.
func (s *SubmissionDraftService) nextDraftName(ctx context.Context, projectID uuid.UUID, submissionCode string, userID uuid.UUID, lang string) (string, error) {
//
// 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
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)
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)
}
@@ -251,15 +340,26 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
}
if newName != existing.Name {
// Pre-check for the unique constraint so we can return a
// typed error instead of a raw PG conflict.
// 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
err := 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 err != nil {
return nil, fmt.Errorf("check name uniqueness: %w", err)
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
@@ -280,6 +380,20 @@ func (s *SubmissionDraftService) Update(ctx context.Context, userID, draftID uui
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
}
@@ -352,7 +466,11 @@ func (s *SubmissionDraftService) MarkExported(ctx context.Context, draftID uuid.
// 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.
// 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,
@@ -401,6 +519,34 @@ func (s *SubmissionDraftService) Export(ctx context.Context, draft *SubmissionDr
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 {

View File

@@ -57,9 +57,13 @@ func NewSubmissionVarsService(db *sqlx.DB, projects *ProjectService, parties *Pa
}
// 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
ProjectID *uuid.UUID
SubmissionCode string
}
@@ -87,7 +91,11 @@ type SubmissionVarsResult struct {
// 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.
// 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")
@@ -101,34 +109,11 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
return nil, ErrNotVisible
}
// 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
}
rule, err := s.loadPublishedRule(ctx, in.SubmissionCode)
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
}
lang := user.Lang
if lang == "" {
lang = "de"
@@ -137,21 +122,55 @@ func (s *SubmissionVarsService) Build(ctx context.Context, in SubmissionVarsCont
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)
addRuleVars(bag, rule, lang)
addDeadlineVars(bag, next, project, lang)
return &SubmissionVarsResult{
Placeholders: bag,
User: user,
Project: project,
Rule: rule,
ProceedingType: pt,
Parties: parties,
NextDeadline: next,
Lang: lang,
}, nil
out.Project = project
out.ProceedingType = pt
out.Parties = parties
out.NextDeadline = next
return out, nil
}
// loadPublishedRule fetches the deadline_rule that owns the given

View File

@@ -232,12 +232,15 @@ func buildDocumentXML() string {
// 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.
// 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()