Compare commits

..

7 Commits

Author SHA1 Message Date
mAi
72b64140e9 mAi: #83 - approval withdraw warning modal + edit-instead path
t-paliad-252. Replace the silent confirm()-then-DELETE with a three-path
warning modal: Cancel / Edit event (primary) / Withdraw and delete
(destructive). The edit-instead path lets the requester revise the
in-flight entity without withdrawing the approval request.

Backend — new service method + endpoint
- ApprovalService.EditPendingEntity(requestID, callerID, fields):
  - validates caller == requested_by AND status = pending
  - reuses the existing wider counter-allowlist (buildCounterSetClauses
    from SuggestChanges) — every editable field on the entity, not just
    the date triggers
  - applies the field updates to the entity row via applyEntityUpdate
    (including the event_type_ids junction rewrite for deadlines)
  - merges new fields into approval_requests.payload (jsonb) so the
    approver inbox sees what was revised
  - emits a distinct *_approval_edited_by_requester project_event so the
    Verlauf surfaces the revision separately from the original *_requested
    row and any decision row
  - request stays pending; entity.approval_status stays pending
- POST /api/approval-requests/{id}/edit-entity
  - Body: {"fields": {<entity-shape>}}
  - Errors reuse the existing mapApprovalError mapping:
    400 suggestion_requires_change, 403 not_authorized,
    404, 409 request_not_pending
- Distinguishing audit event types per the spec:
  - destructive Withdraw path: existing <entity>_approval_revoked
    (no behaviour change — for CREATE deletes the entity, for UPDATE /
    COMPLETE reverts to pre_image, for DELETE cancels the delete request)
  - edit-instead path: new <entity>_approval_edited_by_requester

Frontend — shared withdraw warning modal
- frontend/src/client/components/withdraw-warning-modal.ts
  - Built on the unified openModal() primitive (t-paliad-217 Slice A)
  - Primary CTA "Termin bearbeiten" highlights the non-destructive path
  - Secondary defaults to "Abbrechen" (handled by openModal)
  - Destructive button "Endgültig zurückziehen und löschen" lives inside
    the body (red, separated by a dashed border) so the safe path stays
    visually primary in the footer
  - Copy adapts per lifecycle:
    CREATE   → "Wenn Sie zurückziehen, wird die Frist/der Termin gelöscht."
    UPDATE   → "Ihre vorgeschlagenen Änderungen werden verworfen."
    DELETE   → "Der Eintrag bleibt bestehen."

Frontend — wiring on both detail pages
- deadlines-detail.ts + appointments-detail.ts:
  - Replace confirm() in withdraw flow with openWithdrawWarningModal()
  - Edit path: set module-level pendingEditMode = true + enter edit mode
    (override existing pending-state freeze on appointments; expose
    enterEdit() via late-bound pendingEnterEdit on deadlines)
  - Save handler in pendingEditMode routes to /edit-entity instead of
    PATCH /api/<entity>/{id} (which still 409s on pending state)
  - Destructive Withdraw path: existing /revoke endpoint unchanged
  - For CREATE-lifecycle revokes the entity is gone — bounce to the
    /events list instead of trying to re-fetch (was reload() before)

i18n: +14 keys DE+EN under approvals.withdraw.* (modal title, primary,
destructive, cancel, lead.create.{deadline,appointment}, lead.update,
lead.delete, sub.create, sub.update, sub.delete)

CSS: .withdraw-warning-body + .withdraw-warning-{intro,sub,
destructive-row,destructive-btn} — lime-tint sibling palette consistent
with the existing form-hint pattern; destructive button uses .btn-danger.

Build hygiene:
- go build + go vet + go test ./internal/... clean
- frontend bun run build clean (2807 keys, +14 new, scan clean)

Files of note:
- internal/services/approval_service.go (EditPendingEntity + sortedKeys
  helper; maps.Copy for the payload merge)
- internal/handlers/approvals.go (handleEditPendingEntity)
- internal/handlers/handlers.go (route registration)
- frontend/src/client/components/withdraw-warning-modal.ts (new shared
  component)
- frontend/src/client/deadlines-detail.ts (initWithdraw rewrite + Save
  pending-edit branch)
- frontend/src/client/appointments-detail.ts (withdrawAppointmentRequest
  rewrite + Save pending-edit branch + form-freeze respects
  pendingEditMode)

Out of scope (intentionally):
- Reopening already-deleted approval requests (the destructive path
  stays final).
- Approval-request analytics / metrics.
- Notifying the original approval-requester via channel.
2026-05-25 14:24:55 +02:00
mAi
1bf62c78e3 Merge: t-paliad-251 — Deadline form overhaul (m/paliad#82) 2026-05-25 14:05:06 +02:00
mAi
9a774ba3ad Merge: t-paliad-254 — Sidebar scroll position persists across nav (m/paliad#85) 2026-05-25 14:04:39 +02:00
mAi
8caaf6a631 mAi: #82 - deadline form overhaul: type-modal filter chips, type→rule autofill, Auto mode, Standardtitel
t-paliad-251. Four bundled concerns from m's 2026-05-25 reports, one
worker, one branch.

Part 1 — Event-type browse modal (search + filters)
- Modal already had a search input; added court-type filter chips
  (UPC / EPA / DPMA / DE / Allgemein) under the search.
- Chips render only the jurisdictions actually present in the data;
  any future flavour lands at the end of the row.
- Active chip uses the lime-tint chip palette already established by
  the .event-type-collapsed* family (t-paliad-165).
- Search input keeps autofocus; chip + search filters intersect.

Part 2 — Type → Rule auto-fill + sort options
- Inverted the existing rule.concept_default_event_type_id mapping
  client-side: given a chosen event_type X, candidate rules are
  those with concept_default_event_type_id === X.
- Resolution picks (1) exact match on the project's
  proceeding_type_id, (2) jurisdiction match on the rule's
  proceeding (EPA→EPO canonicalised), (3) first candidate.
- Sort dropdown next to the Rule label: by proceeding sequence,
  by court (jurisdiction grouping with optgroup), alphabetical.
  Defaults to "by court"; localStorage-persisted per browser.
- All sorts are client-side over the existing /api/deadline-rules
  payload — no new endpoint.

Part 3 — Auto rule mode + clearer override warning
- Auto badge (.form-hint--auto, lime-tint pill + " — <rule name>")
  surfaces whenever the Rule was derived from the chosen Type.
  Disappears the moment the user manually picks a different rule.
- Override warning names BOTH sides + the actually-applied rule:
  "Typ ergibt Regel: X. Gewählte Regel: Y. Es wird Y angewendet."
- Symmetric `lastAutoFilledRuleID` sticky-replace flag mirrors the
  existing `lastAutoFilledEventTypeID` (t-paliad-165) so the auto-
  fill only replaces its own previous suggestion, never a manual
  pick.
- Collapsed Typ view (t-paliad-165) is suppressed when the rule was
  auto-derived from the type — the "vorgegeben durch Regel" copy
  reads backwards in that case; show picker + Auto badge instead.

Part 4 — Standardtitel button (create + edit)
- Button rendered next to the Title field on both /deadlines/new
  and /deadlines/{id} (edit mode only).
- Recipe (recipe-docs-here-so-future-templates-can-mirror-it):
    head =
      1. event_type label (if exactly one Typ chip is set)
      2. rule code+name (when a Rule is set — "RoP.023 — Klageerwiderung")
      3. proceeding type name from project (create form only)
      4. fallback: t("deadlines.field.title.default_fallback")
    suffix = " — <project.reference>" when ref is set and not
             already in head.
  Examples:
    Klageerwiderung — C-UPC-0042       (type known)
    RoP.023 — Klageerwiderung — REF    (rule known, no type)
    UPC — Verletzungsverfahren — REF   (only proceeding type)
    Neue Frist — REF                   (fallback)
- Click REPLACES current title; no destructive confirmation
  because the user invoked it explicitly. Focus moves into the
  title input afterwards so the user can fine-tune.

Build hygiene:
- go build + go vet + go test ./internal/... clean.
- frontend/build.ts clean (2786 keys, +10 new DE+EN, scan clean).
- All changes client-side / CSS / i18n + 2 small TSX edits; no
  schema, no service, no migration.

Files touched:
- frontend/src/client/event-types.ts (browse-modal chips)
- frontend/src/client/deadlines-new.ts (rewrite — Type→Rule, sort,
  Auto badge, override warn, Standardtitel)
- frontend/src/client/deadlines-detail.ts (edit-mode Standardtitel
  + show/hide on enter/exit edit)
- frontend/src/deadlines-new.tsx (label-row + sort dropdown + Auto
  badge slot + override-warn slot + Standardtitel button)
- frontend/src/deadlines-detail.tsx (Standardtitel button)
- frontend/src/styles/global.css (.event-type-browse-chip*,
  .form-hint--auto, .form-hint-badge, .form-field-label-row,
  .btn-link-action, .rule-sort-select)
- frontend/src/client/i18n.ts (+10 keys DE+EN)
2026-05-25 14:03:04 +02:00
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
22 changed files with 1885 additions and 100 deletions

View File

@@ -2,6 +2,7 @@ import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import { initNotes } from "./notes";
import { projectIndent } from "./project-indent";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
interface Appointment {
id: string;
@@ -25,6 +26,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
interface Me {
@@ -43,6 +47,10 @@ let project: Project | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
let me: Me | null = null;
// t-paliad-252 — see deadlines-detail.ts. Routes Save to the new
// /api/approval-requests/{id}/edit-entity endpoint when the user picked
// "Termin bearbeiten" in the withdraw warning modal.
let pendingEditMode = false;
function parseAppointmentID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -207,10 +215,14 @@ function renderHeader() {
}
// Freeze the edit form + delete button while a request is in flight.
// t-paliad-252 — when the user picked "Termin bearbeiten" in the
// withdraw modal, pendingEditMode unfreezes the form so Save can route
// to /edit-entity (which keeps the request pending + merges payload).
const form = document.getElementById("appointment-edit-form") as HTMLFormElement | null;
if (form) {
const freeze = isPending && !pendingEditMode;
form.querySelectorAll<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement | HTMLButtonElement>("input, select, textarea, button[type=submit]")
.forEach((el) => { el.disabled = isPending; });
.forEach((el) => { el.disabled = freeze; });
}
const deleteBtn = document.getElementById("appointment-delete-btn") as HTMLButtonElement | null;
if (deleteBtn) deleteBtn.disabled = isPending;
@@ -263,6 +275,39 @@ async function saveEdit(ev: Event) {
submitBtn.disabled = true;
try {
// t-paliad-252 — pending-edit mode routes through /edit-entity which
// keeps the request pending + merges fields into payload. clear_project
// and project_id are NOT in the counter-allowlist (yet) — the requester
// can't move projects on a pending request from this surface.
if (pendingEditMode && pendingRequest) {
const editFields = { ...payload };
delete editFields.clear_project;
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: editFields }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/appointments/${appointment.id}`);
if (fresh.ok) appointment = await fresh.json();
await loadPendingRequest();
// Exit pending-edit mode so the form re-freezes (still pending).
pendingEditMode = false;
renderHeader();
fillEditForm();
msg.textContent = t("appointments.detail.saved");
msg.className = "form-msg form-msg-ok";
} else {
const data = await resp.json().catch(() => ({}) as { error?: string; message?: string });
msg.textContent = data.message || data.error || t("appointments.error.generic");
msg.className = "form-msg form-msg-error";
}
return;
}
const resp = await fetch(`/api/appointments/${appointment.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -312,12 +357,37 @@ async function deleteAppointment() {
}
}
// t-paliad-252 — withdraw warning modal replaces the old confirm().
// Returns:
// "edit" → unfreeze the edit form (pending-edit mode); Save will
// route through /api/approval-requests/{id}/edit-entity
// "withdraw" → destructive: the existing /revoke endpoint
// null → user cancelled
async function withdrawAppointmentRequest() {
if (!appointment || !pendingRequest) return;
if (!confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
const btn = document.getElementById("appointment-withdraw-btn") as HTMLButtonElement | null;
if (btn) btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "appointment",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
if (btn) btn.disabled = false;
return;
}
if (action === "edit") {
pendingEditMode = true;
if (btn) btn.disabled = false;
// renderHeader re-evaluates the freeze and unfreezes the form now
// that pendingEditMode is set. Focus the first editable field so the
// user can type immediately.
renderHeader();
const titleEl = document.getElementById("appointment-title-edit") as HTMLInputElement | null;
titleEl?.focus();
return;
}
// action === "withdraw" → destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -328,9 +398,12 @@ async function withdrawAppointmentRequest() {
if (fresh.ok) {
appointment = await fresh.json();
await loadPendingRequest();
renderHeader();
fillEditForm();
} else {
// CREATE lifecycle: entity gone → back to the list.
window.location.href = "/events?type=appointment";
}
renderHeader();
fillEditForm();
} else {
const data = await resp.json().catch(() => ({}) as { message?: string; error?: string });
const msg = document.getElementById("appointment-edit-msg")!;

View File

@@ -0,0 +1,149 @@
// t-paliad-252 / m/paliad#83 — withdraw warning modal.
//
// Before t-paliad-252 the deadline + appointment detail pages did a
// confirm() dialog before POSTing to /api/approval-requests/{id}/revoke.
// For pending CREATE lifecycles that endpoint silently DELETES the
// underlying entity row — m's "withdrawing the approval deletes the event"
// surprise.
//
// This modal replaces the confirm() with three explicit paths:
//
// 1. Cancel — does nothing
// 2. Termin bearbeiten (primary) — opens the edit form; saving routes
// through POST /approval-requests/{id}/
// edit-entity which keeps the request
// pending and merges the new fields
// into approval_request.payload
// 3. Endgültig zurückziehen + — destructive; current /revoke
// löschen behaviour (delete for CREATE, revert
// for UPDATE/COMPLETE, cancel for
// DELETE-lifecycle requests)
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) so the
// three-button row sits cleanly inside the body — the primitive only
// supports one secondary action, but we paint the destructive button as a
// separate row above the footer.
import { t } from "../i18n";
import { openModal } from "./modal";
export type WithdrawAction = "edit" | "withdraw";
export interface WithdrawWarningArgs {
// entityType drives the copy ("event" vs "appointment" labels).
entityType: "deadline" | "appointment";
// lifecycleEvent of the pending request; copy adapts (CREATE warns about
// deletion; UPDATE/COMPLETE warn about revert; DELETE warns about
// cancelling the deletion request).
lifecycleEvent: "create" | "update" | "complete" | "delete" | string;
}
// openWithdrawWarningModal resolves with the chosen action, or null if the
// user dismissed via Cancel / Esc / backdrop / browser back-button.
export async function openWithdrawWarningModal(
args: WithdrawWarningArgs,
): Promise<WithdrawAction | null> {
const body = document.createElement("div");
body.className = "withdraw-warning-body";
// Lead paragraph + sub-paragraph adapt to lifecycle so the user always
// knows what the destructive button will actually do. The /revoke
// backend behaviour:
// - create → DELETE the entity (the "surprise" m flagged)
// - update → revert to pre_image
// - complete → revert to pre-complete state
// - delete → cancel the delete request (entity stays alive)
const intro = document.createElement("p");
intro.className = "withdraw-warning-intro";
intro.textContent = leadCopyFor(args);
body.appendChild(intro);
const sub = document.createElement("p");
sub.className = "withdraw-warning-sub muted";
sub.textContent = subCopyFor(args);
body.appendChild(sub);
// The destructive button lives inside the body — the openModal primitive
// only exposes one secondary button slot, and we want the safe "Edit"
// path to be the primary CTA. Painting it in red here, separated from
// the footer, signals "this is the dangerous option" without competing
// visually with the primary CTA.
const destructiveRow = document.createElement("div");
destructiveRow.className = "withdraw-warning-destructive-row";
const destructiveBtn = document.createElement("button");
destructiveBtn.type = "button";
destructiveBtn.className = "btn btn-danger withdraw-warning-destructive-btn";
destructiveBtn.textContent = t("approvals.withdraw.destructive.label");
destructiveRow.appendChild(destructiveBtn);
body.appendChild(destructiveRow);
return new Promise<WithdrawAction | null>((resolve) => {
let chosen: WithdrawAction | null = null;
// The destructive button has to close the modal and return "withdraw".
// We need access to the modal's internal close() — fortunately openModal
// exposes it via the primary handler's first arg. We pass through the
// outer resolve and let the primary handler (Edit) own the close-fn
// route. For the destructive button we resolve the outer promise
// directly and then synthesise an ESC keypress so the modal dismisses
// — or, simpler, set chosen and use the secondary "Cancel" path that
// the modal already supports. (openModal's onClose fires on every
// dismiss path including the primary handler resolution.)
destructiveBtn.addEventListener("click", () => {
chosen = "withdraw";
// The unified openModal primitive (modal.ts) wires its dismiss path
// through the native <dialog>'s `cancel` event. Dispatching it on
// the parent <dialog> runs the same finish() → onClose → resolve
// sequence as ESC / backdrop. We then map the resolved `null` back
// to "withdraw" via the captured `chosen` in onClose below.
const dialogEl = body.closest("dialog");
dialogEl?.dispatchEvent(new Event("cancel"));
});
void openModal<WithdrawAction>({
title: t("approvals.withdraw.modal.title"),
body,
size: "md",
classNames: "withdraw-warning-modal",
primary: {
label: t("approvals.withdraw.primary.label"),
handler: (close) => {
chosen = "edit";
close("edit");
},
},
secondary: { label: t("approvals.withdraw.cancel") },
onClose: () => {
// Resolves whatever was chosen via the destructive button OR the
// primary handler. ESC / backdrop / secondary clear `chosen` to
// null which is the right "cancel" semantics.
resolve(chosen);
},
});
});
}
function leadCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return args.entityType === "appointment"
? t("approvals.withdraw.lead.create.appointment")
: t("approvals.withdraw.lead.create.deadline");
case "delete":
return t("approvals.withdraw.lead.delete");
default:
// update / complete / unknown → revert semantics
return t("approvals.withdraw.lead.update");
}
}
function subCopyFor(args: WithdrawWarningArgs): string {
switch (args.lifecycleEvent) {
case "create":
return t("approvals.withdraw.sub.create");
case "delete":
return t("approvals.withdraw.sub.delete");
default:
return t("approvals.withdraw.sub.update");
}
}

View File

@@ -9,6 +9,7 @@ import {
type EventType,
type PickerHandle,
} from "./event-types";
import { openWithdrawWarningModal } from "./components/withdraw-warning-modal";
interface Deadline {
id: string;
@@ -38,6 +39,9 @@ interface PendingApprovalRequest {
requested_at: string;
required_role: string;
requester_name?: string;
// t-paliad-252 — used by the withdraw warning modal to pick the right
// copy (CREATE warns about deletion; UPDATE/COMPLETE about revert).
lifecycle_event?: string;
}
let eventTypePicker: PickerHandle | null = null;
@@ -69,6 +73,18 @@ let rule: DeadlineRule | null = null;
let me: Me | null = null;
let allProjects: Project[] = [];
let pendingRequest: PendingApprovalRequest | null = null;
// t-paliad-252 — when the user chose "Edit event" in the withdraw warning
// modal, the entity is still in approval_status='pending'. Save must POST
// to /api/approval-requests/{id}/edit-entity (which keeps the request
// pending + merges the new fields into payload) instead of the regular
// PATCH /api/deadlines/{id} (which 409s during pending). Cleared on exit
// from edit mode + after a successful save.
let pendingEditMode = false;
// pendingEnterEdit — late-bound by initEdit() so the withdraw warning
// modal handler (initWithdraw) can route into pending-edit mode without
// duplicating the edit-mode toggle logic.
let pendingEnterEdit: (() => void) | null = null;
function parseDeadlineID(): string | null {
const parts = window.location.pathname.split("/").filter(Boolean);
@@ -366,6 +382,7 @@ function initEdit() {
const etEdit = document.getElementById("deadline-event-types-edit");
const projectLink = document.getElementById("deadline-project-link") as HTMLAnchorElement;
const projectEdit = document.getElementById("deadline-project-edit") as HTMLSelectElement | null;
const titleDefaultBtn = document.getElementById("deadline-title-default-btn") as HTMLButtonElement | null;
function enterEdit() {
titleDisplay.style.display = "none";
@@ -381,6 +398,7 @@ function initEdit() {
projectEdit.style.display = "";
projectEdit.value = deadline.project_id;
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "";
saveBtn.style.display = "";
editBtn.style.display = "none";
titleEdit.focus();
@@ -399,12 +417,50 @@ function initEdit() {
projectEdit.style.display = "none";
projectLink.style.display = "";
}
if (titleDefaultBtn) titleDefaultBtn.style.display = "none";
saveBtn.style.display = "none";
editBtn.style.display = "";
pendingEditMode = false;
}
// t-paliad-252 — expose enterEdit so the withdraw warning modal can
// route into pending-edit mode without re-running the edit-button
// visibility gate (which hides the button during pending).
pendingEnterEdit = () => {
pendingEditMode = true;
enterEdit();
};
editBtn.addEventListener("click", enterEdit);
// t-paliad-251 Part 4 — Standardtitel button.
// Recipe (mirror of computeDefaultTitle in deadlines-new.ts):
// head = event_type label (if exactly one Typ chip is in edit)
// || rule code+name (when deadline carries a rule)
// || "Neue Frist" fallback
// suffix = " — <project.reference>" when not already in head
titleDefaultBtn?.addEventListener("click", () => {
if (!deadline) return;
let head = "";
const ids = eventTypePicker?.getIDs() ?? deadline.event_type_ids ?? [];
if (ids.length === 1) {
const et = eventTypeByID.get(ids[0]);
if (et) head = eventTypeLabel(et);
}
if (!head && rule) {
const code = rule.rule_code || rule.code || "";
head = code ? `${code}${rule.name}` : rule.name;
}
if (!head && deadline.rule_code) {
head = deadline.rule_code;
}
if (!head) head = t("deadlines.field.title.default_fallback");
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) head = `${head}${ref}`;
titleEdit.value = head;
titleEdit.focus();
});
saveBtn.addEventListener("click", async () => {
if (!deadline) return;
const newTitle = titleEdit.value.trim();
@@ -424,6 +480,35 @@ function initEdit() {
if (projectEdit && projectEdit.value && projectEdit.value !== deadline.project_id) {
payload.project_id = projectEdit.value;
}
// t-paliad-252 — pending-edit mode routes through the new endpoint
// that updates the entity + merges payload into the still-pending
// approval_request. Outside pending-edit mode the regular PATCH
// path remains the authoritative one (with its existing 409-on-
// pending guard).
if (pendingEditMode && pendingRequest) {
const resp = await fetch(
`/api/approval-requests/${pendingRequest.id}/edit-entity`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ fields: payload }),
},
);
if (resp.ok) {
const fresh = await fetch(`/api/deadlines/${deadline.id}`);
if (fresh.ok) deadline = await fresh.json();
await loadPendingRequest();
render();
} else {
const body = await resp.json().catch(() => null);
const msg = (body && (body.message || body.error))
|| (t("approvals.withdraw.error") || "Fehler");
window.alert(msg);
}
return;
}
const resp = await fetch(`/api/deadlines/${deadline.id}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
@@ -501,19 +586,39 @@ function initReopen() {
});
}
// initWithdraw — t-paliad-160 §C+E. Reuses the existing
// /api/approval-requests/{id}/revoke endpoint (no new server route
// needed). After the revoke lands, the entity goes back to
// approval_status='approved' and the page reloads to refresh the
// in-memory state cleanly.
// initWithdraw — t-paliad-160 §C+E + t-paliad-252.
//
// Click flow: open the withdraw warning modal (replaces the old
// confirm()). The modal returns one of:
//
// "edit" — open the edit form in pending-edit mode; Save calls
// /api/approval-requests/{id}/edit-entity which keeps the
// request pending + merges the new fields into payload
// "withdraw" — destructive: call the existing /revoke endpoint
// (DELETE entity for CREATE, revert for UPDATE/COMPLETE,
// cancel-delete for DELETE lifecycle)
// null — user cancelled; nothing happens
function initWithdraw() {
const btn = document.getElementById("deadline-withdraw-btn") as HTMLButtonElement | null;
if (!btn) return;
btn.addEventListener("click", async () => {
if (!deadline || !pendingRequest) return;
if (!window.confirm(t("approvals.withdraw.confirm") || "Anfrage wirklich zurückziehen?")) return;
btn.disabled = true;
try {
const action = await openWithdrawWarningModal({
entityType: "deadline",
lifecycleEvent: pendingRequest.lifecycle_event ?? "create",
});
if (action === null) {
btn.disabled = false;
return;
}
if (action === "edit") {
btn.disabled = false;
pendingEnterEdit?.();
return;
}
// action === "withdraw" → existing destructive path.
const resp = await fetch(`/api/approval-requests/${pendingRequest.id}/revoke`, {
method: "POST",
headers: { "Content-Type": "application/json" },
@@ -521,14 +626,16 @@ function initWithdraw() {
});
if (resp.ok) {
// Re-fetch the entity so approval_status flips back to 'approved'
// and the badge / buttons rerender accordingly.
// and the badge / buttons rerender accordingly. For CREATE
// lifecycle the entity is gone, so the 404 surfaces as a reload.
const r = await fetch(`/api/deadlines/${deadline.id}`);
if (r.ok) {
deadline = await r.json();
await loadPendingRequest();
render();
} else {
window.location.reload();
// CREATE lifecycle deleted the entity — bounce to the list.
window.location.href = "/events?type=deadline";
}
} else {
btn.disabled = false;

View File

@@ -1,4 +1,4 @@
import { initI18n, t, tDyn } from "./i18n";
import { initI18n, t, tDyn, getLang } from "./i18n";
import { initSidebar } from "./sidebar";
import {
attachEventTypePicker,
@@ -24,6 +24,10 @@ interface Project {
reference?: string | null;
title: string;
path: string;
// t-paliad-251 — used by Type→Rule autofill to narrow rule candidates
// to the project's own proceeding. Optional because not every project
// is a case/proceeding (clients + matters carry no proceeding type).
proceeding_type_id?: number | null;
}
interface DeadlineRule {
@@ -32,15 +36,32 @@ interface DeadlineRule {
name: string;
name_en: string;
rule_code?: string;
proceeding_type_id?: number | null;
sequence_order?: number;
// t-paliad-165 — canonical event_type for this rule's concept,
// hydrated server-side from paliad.deadline_concept_event_types.
// Drives auto-fill of the Typ chip when the user picks this rule.
// Drives auto-fill of the Typ chip when the user picks this rule,
// AND is inverted to power Typ→Regel auto-fill (t-paliad-251 Part 2):
// given a chosen event_type X, candidate rules are those whose
// concept_default_event_type_id === X.
concept_default_event_type_id?: string | null;
}
interface ProceedingType {
id: number;
code: string;
name: string;
name_en?: string;
jurisdiction: string;
sort_order?: number;
}
// Rules indexed by id so the Regel-change handler can look up the
// concept's canonical event_type without re-fetching.
let rulesByID = new Map<string, DeadlineRule>();
let allRules: DeadlineRule[] = [];
let proceedingTypesByID = new Map<number, ProceedingType>();
let projectsByID = new Map<string, Project>();
// Last event_type the rule auto-filled. Tracked so we can tell whether
// the picker still reflects the rule's suggestion (replace silently on
@@ -48,6 +69,17 @@ let rulesByID = new Map<string, DeadlineRule>();
// surface the mismatch warning instead).
let lastAutoFilledEventTypeID: string | null = null;
// t-paliad-251 — symmetric flag for the inverse direction. Tracks the
// rule ID we most recently injected as the Auto-derived default for the
// chosen event_type, so we can replace it silently when the user picks
// a different type but leave manual rule picks alone.
let lastAutoFilledRuleID: string | null = null;
// Current sort mode for the Rule select. Persisted to localStorage so
// repeat-form users don't have to re-pick their preferred ordering.
type RuleSort = "by_proceeding" | "by_court" | "alpha";
const RULE_SORT_KEY = "paliad.deadline.rule.sort";
let preselectedProjectID = "";
function esc(s: string): string {
@@ -62,6 +94,20 @@ function showError(msg: string) {
el.className = "form-msg form-msg-error";
}
function ruleLabel(r: DeadlineRule): string {
const lang = getLang();
const name = (lang === "en" && r.name_en) ? r.name_en : r.name;
const code = r.rule_code || r.code || "";
return code ? `${code}${name}` : name;
}
function proceedingLabel(pt: ProceedingType | undefined): string {
if (!pt) return "";
const lang = getLang();
const name = (lang === "en" && pt.name_en) ? pt.name_en : pt.name;
return `${pt.jurisdiction}${name}`;
}
async function loadProjects() {
const sel = document.getElementById("deadline-project") as HTMLSelectElement;
const hint = document.getElementById("deadline-project-empty-hint")!;
@@ -69,6 +115,7 @@ async function loadProjects() {
const resp = await fetch("/api/projects");
if (!resp.ok) return;
const projects: Project[] = await resp.json();
projectsByID = new Map(projects.map((p) => [p.id, p]));
if (projects.length === 0) {
hint.style.display = "";
hint.innerHTML = `${esc(t("deadlines.field.akte.empty"))} <a href="/projects/new">${esc(t("deadlines.field.akte.empty.link"))}</a>`;
@@ -82,7 +129,7 @@ async function loadProjects() {
const ref = p.reference || "";
const indent = projectIndent(p.path);
options.push(
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} \u2014 ${esc(p.title)}</option>`,
`<option value="${esc(p.id)}"${isSelected}>${indent}${esc(ref)} ${esc(p.title)}</option>`,
);
}
sel.innerHTML = options.join("");
@@ -91,28 +138,186 @@ async function loadProjects() {
}
}
async function loadProceedingTypes() {
try {
const resp = await fetch("/api/proceeding-types-db");
if (!resp.ok) return;
const types: ProceedingType[] = await resp.json();
proceedingTypesByID = new Map(types.map((pt) => [pt.id, pt]));
} catch {
/* non-fatal — rule sort falls back to alpha when proceeding-type
metadata is missing */
}
}
async function loadRules() {
// Optional: load rules so user can attach. We pull all rules; small set.
const sel = document.getElementById("deadline-rule") as HTMLSelectElement;
try {
const resp = await fetch("/api/deadline-rules");
if (!resp.ok) return;
const rules: DeadlineRule[] = await resp.json();
rulesByID = new Map(rules.map((r) => [r.id, r]));
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
for (const r of rules) {
const code = r.rule_code || r.code || "";
const label = code ? `${code} \u2014 ${r.name}` : r.name;
opts.push(`<option value="${esc(r.id)}">${esc(label)}</option>`);
}
sel.innerHTML = opts.join("");
allRules = (await resp.json()) as DeadlineRule[];
rulesByID = new Map(allRules.map((r) => [r.id, r]));
renderRuleSelect();
} catch {
/* non-fatal — rule select stays at "no rule" */
}
}
// renderRuleSelect rebuilds the Rule <select> from the current sort
// mode + the cached rule set. Called whenever the user changes the sort
// dropdown, when the language flips, or after rules + proceeding types
// finish loading. The "Keine Regel" sentinel always stays at the top.
function renderRuleSelect(): void {
const sel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
if (!sel) return;
const previous = sel.value;
const sort = readRuleSort();
const opts: string[] = [
`<option value="" data-i18n="deadlines.field.rule.none">${esc(t("deadlines.field.rule.none"))}</option>`,
];
if (sort === "alpha") {
const sorted = [...allRules].sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
for (const r of sorted) {
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
}
} else if (sort === "by_court") {
// Group by proceeding_type.jurisdiction (UPC / EPA / DPMA / DE /
// other). Within each group, sort alpha by rule label so the user
// can scan a court's rules in stable order.
const byJurisdiction = new Map<string, DeadlineRule[]>();
for (const r of allRules) {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
const j = pt?.jurisdiction || t("event_types.browse.jurisdiction.none");
const list = byJurisdiction.get(j) ?? [];
list.push(r);
byJurisdiction.set(j, list);
}
const order = ["UPC", "EPA", "EPO", "DPMA", "DE"];
const keys = [...byJurisdiction.keys()].sort((a, b) => {
const ai = order.indexOf(a);
const bi = order.indexOf(b);
if (ai === -1 && bi === -1) return a.localeCompare(b);
if (ai === -1) return 1;
if (bi === -1) return -1;
return ai - bi;
});
for (const k of keys) {
const list = byJurisdiction.get(k)!.sort((a, b) => ruleLabel(a).localeCompare(ruleLabel(b)));
opts.push(`<optgroup label="${esc(k === "EPO" ? "EPA" : k)}">`);
for (const r of list) {
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
}
opts.push(`</optgroup>`);
}
} else {
// by_proceeding — group by proceeding_type, within each preserve the
// canonical sequence_order so the user reads "Klageerwiderung →
// Replik → Duplik → Verhandlung" in chronological order.
const byProceeding = new Map<number | string, DeadlineRule[]>();
const noProceedingKey = "__none__";
for (const r of allRules) {
const k: number | string = r.proceeding_type_id ?? noProceedingKey;
const list = byProceeding.get(k) ?? [];
list.push(r);
byProceeding.set(k, list);
}
const keys = [...byProceeding.keys()].sort((a, b) => {
if (a === noProceedingKey) return 1;
if (b === noProceedingKey) return -1;
const pa = proceedingTypesByID.get(a as number);
const pb = proceedingTypesByID.get(b as number);
const sa = pa?.sort_order ?? 9999;
const sb = pb?.sort_order ?? 9999;
if (sa !== sb) return sa - sb;
return (pa?.code ?? "").localeCompare(pb?.code ?? "");
});
for (const k of keys) {
const list = (byProceeding.get(k)!).slice().sort(
(a, b) => (a.sequence_order ?? 0) - (b.sequence_order ?? 0),
);
const pt = typeof k === "number" ? proceedingTypesByID.get(k) : undefined;
const groupLabel = pt ? proceedingLabel(pt) : t("deadlines.field.rule.sort.other_proceeding");
opts.push(`<optgroup label="${esc(groupLabel)}">`);
for (const r of list) {
opts.push(`<option value="${esc(r.id)}">${esc(ruleLabel(r))}</option>`);
}
opts.push(`</optgroup>`);
}
}
sel.innerHTML = opts.join("");
// Restore previous selection if it still exists in the new order.
if (previous && rulesByID.has(previous)) {
sel.value = previous;
}
}
function readRuleSort(): RuleSort {
try {
const raw = localStorage.getItem(RULE_SORT_KEY);
if (raw === "by_proceeding" || raw === "by_court" || raw === "alpha") return raw;
} catch {
/* non-fatal */
}
return "by_court";
}
function writeRuleSort(s: RuleSort): void {
try {
localStorage.setItem(RULE_SORT_KEY, s);
} catch {
/* non-fatal */
}
}
// resolveAutoRuleForType picks the best-match rule for the chosen event
// type, scoring by:
// 1. project's proceeding_type_id (if known) — exact match wins,
// 2. otherwise event_type.jurisdiction matches the rule's
// proceeding's jurisdiction (EPA→EPO canonicalised),
// 3. otherwise just the first candidate in the canonical ordering.
//
// Returns null when no rule maps to this event_type. The caller surfaces
// this as "no Auto rule available — pick one manually" rather than
// silently leaving the dropdown stuck on whatever the user picked before.
function resolveAutoRuleForType(eventTypeID: string, projectID: string): DeadlineRule | null {
const candidates = allRules.filter((r) => r.concept_default_event_type_id === eventTypeID);
if (candidates.length === 0) return null;
if (candidates.length === 1) return candidates[0];
const project = projectID ? projectsByID.get(projectID) : undefined;
if (project?.proceeding_type_id) {
const exact = candidates.find((r) => r.proceeding_type_id === project.proceeding_type_id);
if (exact) return exact;
}
const et = eventTypesByID.get(eventTypeID);
if (et?.jurisdiction && et.jurisdiction !== "any") {
const want = et.jurisdiction === "EPO" ? "EPA" : et.jurisdiction;
const jurMatch = candidates.find((r) => {
const pt = r.proceeding_type_id ? proceedingTypesByID.get(r.proceeding_type_id) : undefined;
return pt?.jurisdiction === want;
});
if (jurMatch) return jurMatch;
}
return candidates[0];
}
let preselectedProjectIDLocal = "";
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
}
preselectedProjectIDLocal = preselectedProjectID;
}
// t-paliad-165 follow-up — drive the collapsed/expanded view of the Typ
// picker. The two modes are mutually exclusive:
//
@@ -140,8 +345,14 @@ function refreshRuleView(): void {
const pickerMatchesDefault =
expected !== null && picked.length === 1 && picked[0] === expected;
// t-paliad-251 — when the rule was auto-derived from a user-picked
// type (Typ→Regel direction), the collapsed "vorgegeben durch Regel"
// copy reads backwards. Show the picker explicitly + surface the
// Auto badge on the Rule field instead.
const ruleWasAutoDerivedFromType =
lastAutoFilledRuleID !== null && ruleID === lastAutoFilledRuleID;
const wantsCollapsed =
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault;
!expandedOverride && ruleID !== "" && expected !== null && pickerMatchesDefault && !ruleWasAutoDerivedFromType;
if (wantsCollapsed) {
const et = eventTypesByID.get(expected!);
@@ -164,6 +375,58 @@ function refreshRuleView(): void {
}
}
// refreshRuleAutoBadgeAndWarning surfaces the Auto badge whenever the
// Rule was derived from the Typ (i.e. lastAutoFilledRuleID is currently
// selected) AND the warning whenever the user has manually picked a
// non-Auto rule that contradicts the Type's derived rule. Both end up
// inert when there's no Type chosen.
function refreshRuleAutoBadgeAndWarning(): void {
const autoEl = document.getElementById("deadline-rule-auto-hint");
const autoTextEl = document.getElementById("deadline-rule-auto-hint-text");
const warnEl = document.getElementById("deadline-rule-override-warn");
if (!autoEl || !autoTextEl || !warnEl) return;
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
if (!ruleSel) return;
const picked = eventTypePicker?.getIDs() ?? [];
if (picked.length !== 1) {
autoEl.style.display = "none";
warnEl.style.display = "none";
return;
}
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const derived = resolveAutoRuleForType(picked[0], projectID);
const currentRuleID = ruleSel.value || "";
if (currentRuleID && currentRuleID === lastAutoFilledRuleID) {
// The current rule was auto-derived (and the user hasn't touched it).
autoEl.style.display = "";
autoTextEl.textContent = derived ? `${ruleLabel(derived)}` : "";
warnEl.style.display = "none";
return;
}
autoEl.style.display = "none";
// Override warning: derived rule exists AND user has picked a
// different non-empty rule. The copy names BOTH so the user knows
// exactly what's happening — and which one will be applied.
if (derived && currentRuleID && currentRuleID !== derived.id) {
const current = rulesByID.get(currentRuleID);
if (current) {
const tmpl = t("deadlines.field.rule.override_warn");
const msg = tmpl
.replace("{derived}", ruleLabel(derived))
.replace("{selected}", ruleLabel(current));
warnEl.textContent = msg;
warnEl.style.display = "";
return;
}
}
warnEl.style.display = "none";
}
// applyRuleAutoFill replaces the picker silently when it still reflects
// the previous rule's suggestion (or is empty); leaves a manually-edited
// picker alone. Called whenever the Regel select changes.
@@ -200,13 +463,97 @@ function applyRuleAutoFill(): void {
refreshRuleView();
}
function initBackLinks() {
if (preselectedProjectID) {
const back = document.getElementById("deadline-new-back") as HTMLAnchorElement;
const cancel = document.getElementById("deadline-new-cancel") as HTMLAnchorElement;
back.href = `/projects/${preselectedProjectID}/deadlines`;
cancel.href = `/projects/${preselectedProjectID}/deadlines`;
// applyTypeAutoFillRule is the inverse direction (t-paliad-251 Part 2):
// when the user picks a single Typ chip, derive the canonical Rule and
// inject it into the Regel select. Like applyRuleAutoFill, it leaves
// manual rule picks alone — only replaces when the current rule is the
// previous auto-fill (sticky-replace pattern).
function applyTypeAutoFillRule(): void {
const ruleSel = document.getElementById("deadline-rule") as HTMLSelectElement | null;
if (!ruleSel) return;
const picked = eventTypePicker?.getIDs() ?? [];
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
if (picked.length !== 1) {
// 0 or 2+ Typ chips → no canonical rule to derive. Clear the
// sticky auto-fill so a stale Auto suggestion doesn't linger.
if (lastAutoFilledRuleID && ruleSel.value === lastAutoFilledRuleID) {
ruleSel.value = "";
lastAutoFilledRuleID = null;
// Mirror to the Regel→Typ path so its mismatch warning recomputes.
applyRuleAutoFill();
}
refreshRuleAutoBadgeAndWarning();
return;
}
const derived = resolveAutoRuleForType(picked[0], projectID);
const currentRuleID = ruleSel.value || "";
const ruleStillReflectsLastSuggestion =
lastAutoFilledRuleID !== null && currentRuleID === lastAutoFilledRuleID;
const ruleIsEmpty = currentRuleID === "";
if (derived) {
if (ruleIsEmpty || ruleStillReflectsLastSuggestion) {
ruleSel.value = derived.id;
lastAutoFilledRuleID = derived.id;
// Mirror to the Regel→Typ direction — the new rule's collapsed
// view + mismatch state needs to recompute now that we changed
// the selection programmatically.
applyRuleAutoFill();
}
} else if (ruleStillReflectsLastSuggestion) {
// No derived rule for the new type — drop the stale auto-fill.
ruleSel.value = "";
lastAutoFilledRuleID = null;
applyRuleAutoFill();
}
refreshRuleAutoBadgeAndWarning();
}
// computeDefaultTitle — t-paliad-251 Part 4. Recipe (documented also in
// the commit message so future title templates can mirror it):
//
// priority order picks the head of the title:
// 1. event_type label (when exactly one Typ chip is set)
// 2. rule name (when a Rule is set — uses ruleLabel = "code — name")
// 3. proceeding type name (when project carries a proceeding_type_id)
// 4. fallback: t("deadlines.field.title.default_fallback")
//
// suffix: " — <project-reference>" when the project has a reference
// string and the title doesn't already contain it.
//
// Returns "" only when even the fallback fails (i18n unavailable) —
// callers handle that by leaving the field untouched.
function computeDefaultTitle(): string {
const projectID = (document.getElementById("deadline-project") as HTMLSelectElement | null)?.value || "";
const project = projectID ? projectsByID.get(projectID) : undefined;
const ruleID = (document.getElementById("deadline-rule") as HTMLSelectElement | null)?.value || "";
const rule = ruleID ? rulesByID.get(ruleID) : undefined;
const picked = eventTypePicker?.getIDs() ?? [];
let head = "";
if (picked.length === 1) {
const et = eventTypesByID.get(picked[0]);
if (et) head = eventTypeLabel(et);
}
if (!head && rule) {
head = ruleLabel(rule);
}
if (!head && project?.proceeding_type_id) {
const pt = proceedingTypesByID.get(project.proceeding_type_id);
if (pt) head = proceedingLabel(pt);
}
if (!head) {
head = t("deadlines.field.title.default_fallback");
}
const ref = project?.reference?.trim() || "";
if (ref && !head.includes(ref)) {
return `${head}${ref}`;
}
return head;
}
async function submitForm(e: Event) {
@@ -252,8 +599,8 @@ async function submitForm(e: Event) {
return;
}
const created = await resp.json();
if (preselectedProjectID) {
window.location.href = `/projects/${preselectedProjectID}/deadlines`;
if (preselectedProjectIDLocal) {
window.location.href = `/projects/${preselectedProjectIDLocal}/deadlines`;
} else {
window.location.href = `/deadlines/${created.id}`;
}
@@ -343,12 +690,33 @@ document.addEventListener("DOMContentLoaded", async () => {
// Default due to today
const dueInput = document.getElementById("deadline-due") as HTMLInputElement;
if (!dueInput.value) dueInput.value = new Date().toISOString().split("T")[0];
await Promise.all([loadProjects(), loadRules(), loadMe()]);
// Wire the sort dropdown to read its initial value from localStorage and
// persist user picks back.
const sortSel = document.getElementById("deadline-rule-sort") as HTMLSelectElement | null;
if (sortSel) {
sortSel.value = readRuleSort();
sortSel.addEventListener("change", () => {
writeRuleSort(sortSel.value as RuleSort);
renderRuleSelect();
});
}
await Promise.all([loadProjects(), loadProceedingTypes(), loadRules(), loadMe()]);
// After both rules + proceeding types are in, re-render with the
// chosen sort so groups carry proper labels.
renderRuleSelect();
const pickerHost = document.getElementById("deadline-event-types");
if (pickerHost) {
eventTypePicker = attachEventTypePicker(pickerHost, {
currentUserAdmin,
onChange: () => refreshRuleView(),
onChange: () => {
// Both directions trigger off picker change: refresh the
// Regel→Typ collapsed/expanded state AND the Typ→Regel auto-fill.
refreshRuleView();
applyTypeAutoFillRule();
},
});
}
// t-paliad-165 follow-up — preload event_types so the collapsed
@@ -358,13 +726,18 @@ document.addEventListener("DOMContentLoaded", async () => {
.then((types) => {
eventTypesByID = new Map(types.map((et) => [et.id, et]));
refreshRuleView();
refreshRuleAutoBadgeAndWarning();
})
.catch(() => {/* non-fatal — collapsed view falls back to empty label */});
// t-paliad-165 — Regel change auto-fills the Typ chip from the rule's
// concept's canonical event_type, when the picker hasn't been
// manually edited away from the previous rule's suggestion.
// manually edited away from the previous rule's suggestion. ALSO
// resets the Typ→Regel auto-fill marker since the user just made a
// manual rule pick.
document.getElementById("deadline-rule")?.addEventListener("change", () => {
lastAutoFilledRuleID = null;
applyRuleAutoFill();
refreshRuleAutoBadgeAndWarning();
});
// "Anderen Typ wählen" — sticky expanded mode so the picker stays
// visible even when the chip still matches the rule's default.
@@ -381,6 +754,20 @@ document.addEventListener("DOMContentLoaded", async () => {
// Wire approval-hint refresh: on first render + on project change.
void refreshApprovalHint();
document.getElementById("deadline-project")?.addEventListener("change", () => {
// Project change can shift which rule the Type maps to (via the
// project's proceeding_type_id), so re-run the auto-fill.
void refreshApprovalHint();
applyTypeAutoFillRule();
});
// t-paliad-251 Part 4 — Standardtitel button replaces the title with
// a derived default. No destructive confirmation because the user
// invoked it explicitly.
document.getElementById("deadline-title-default-btn")?.addEventListener("click", () => {
const titleInput = document.getElementById("deadline-title") as HTMLInputElement | null;
if (!titleInput) return;
const derived = computeDefaultTitle();
if (derived) titleInput.value = derived;
titleInput.focus();
});
});

View File

@@ -686,6 +686,33 @@ export function openBrowseEventTypesModal(
return new Promise<string[] | null>((resolve) => {
let selected = new Set<string>(opts.initialIDs);
let searchQuery = "";
// t-paliad-251 — court-type filter chips. `null` = "Alle" (any
// jurisdiction). Any non-null value matches event_types.jurisdiction;
// "any" is mapped to NULL/missing rows via jurisdictionMatches().
let activeJurisdiction: string | null = null;
// Surface every jurisdiction present in the data — "any" stays bucketed
// separately so users still have a "show generic-only" chip. EPA is
// canonicalised to EPO in event_types (see mig 074); the chip label
// shows EPA to match the legal vocabulary the lawyers use.
const jurisdictionsPresent = new Set<string>();
for (const et of opts.types) {
const j = (et.jurisdiction ?? "").trim();
if (j) jurisdictionsPresent.add(j);
}
const JURISDICTION_ORDER = ["UPC", "EPO", "DPMA", "DE", "any"];
const chipJurisdictions = JURISDICTION_ORDER.filter((j) => jurisdictionsPresent.has(j));
// Any jurisdiction in the data that isn't in our ordered list lands at
// the end so the chip row never silently drops a court flavour.
for (const j of jurisdictionsPresent) {
if (!chipJurisdictions.includes(j)) chipJurisdictions.push(j);
}
function chipLabel(j: string): string {
if (j === "EPO") return "EPA";
if (j === "any") return t("event_types.browse.jurisdiction.none");
return j;
}
const overlay = document.createElement("div");
overlay.className = "modal-overlay event-type-browse-overlay";
@@ -694,6 +721,15 @@ export function openBrowseEventTypesModal(
<div class="event-type-browse-header">
<h2 id="event-type-browse-title">${esc(t("event_types.browse.title"))}</h2>
<input type="text" class="event-type-browse-search" data-role="search" placeholder="${esc(t("event_types.browse.search"))}" autocomplete="off" />
<div class="event-type-browse-chips" data-role="chips" role="group" aria-label="${esc(t("event_types.browse.jurisdiction.filter_label"))}">
<button type="button" class="event-type-browse-chip event-type-browse-chip--active" data-jurisdiction="" data-role="chip-all">${esc(t("event_types.browse.jurisdiction.all"))}</button>
${chipJurisdictions
.map(
(j) =>
`<button type="button" class="event-type-browse-chip" data-jurisdiction="${esc(j)}">${esc(chipLabel(j))}</button>`,
)
.join("")}
</div>
</div>
<div class="event-type-browse-list" data-role="list" tabindex="-1"></div>
<div class="event-type-browse-actions">
@@ -711,6 +747,7 @@ export function openBrowseEventTypesModal(
const countEl = overlay.querySelector<HTMLElement>("[data-role=count]")!;
const cancelBtn = overlay.querySelector<HTMLButtonElement>("[data-role=cancel]")!;
const applyBtn = overlay.querySelector<HTMLButtonElement>("[data-role=apply]")!;
const chipButtons = overlay.querySelectorAll<HTMLButtonElement>(".event-type-browse-chip");
const groups = groupByCategory(opts.types);
@@ -721,6 +758,12 @@ export function openBrowseEventTypesModal(
return j;
}
function jurisdictionMatches(et: EventType): boolean {
if (activeJurisdiction === null) return true;
const j = (et.jurisdiction ?? "").trim();
return j === activeJurisdiction;
}
function updateCount() {
countEl.textContent = t("event_types.browse.selected_count").replace(
"{n}",
@@ -731,6 +774,7 @@ export function openBrowseEventTypesModal(
function renderList() {
const q = searchQuery.trim().toLowerCase();
const matches = (et: EventType) => {
if (!jurisdictionMatches(et)) return false;
if (!q) return true;
return (
et.label_de.toLowerCase().includes(q) ||
@@ -783,6 +827,16 @@ export function openBrowseEventTypesModal(
renderList();
});
chipButtons.forEach((btn) => {
btn.addEventListener("click", () => {
const raw = btn.dataset.jurisdiction ?? "";
activeJurisdiction = raw === "" ? null : raw;
chipButtons.forEach((b) => b.classList.remove("event-type-browse-chip--active"));
btn.classList.add("event-type-browse-chip--active");
renderList();
});
});
function close(value: string[] | null) {
document.removeEventListener("keydown", onKey);
overlay.remove();

View File

@@ -302,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",
@@ -417,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",
@@ -879,6 +889,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.rule.autofill_inline": " (vorgegeben durch Regel)",
"deadlines.field.rule.mismatch": "Hinweis: Typ widerspricht Regel — Sie haben den Typ überschrieben.",
"deadlines.field.rule.override": "Anderen Typ wählen",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.override_warn": "Typ ergibt Regel: {derived}. Gewählte Regel: {selected}. Es wird {selected} angewendet.",
"deadlines.field.rule.sort.by_proceeding": "Nach Verfahrensablauf",
"deadlines.field.rule.sort.by_court": "Nach Gerichtsart",
"deadlines.field.rule.sort.alpha": "Alphabetisch",
"deadlines.field.rule.sort.other_proceeding": "Sonstige Regeln",
"deadlines.field.title.default_btn": "Standardtitel",
"deadlines.field.title.default_fallback": "Neue Frist",
"deadlines.field.notes": "Notizen (optional)",
"deadlines.field.notes.placeholder": "Hinweise, Verweise, n\u00e4chste Schritte\u2026",
"deadlines.error.required": "Akte, Titel und F\u00e4lligkeitsdatum sind Pflichtfelder.",
@@ -2437,6 +2455,8 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.browse.cancel": "Abbrechen",
"event_types.browse.selected_count": "{n} ausgewählt",
"event_types.browse.jurisdiction.none": "Allgemein",
"event_types.browse.jurisdiction.all": "Alle Gerichte",
"event_types.browse.jurisdiction.filter_label": "Nach Gerichtsart filtern",
"event_types.filter.all": "Alle Typen",
"event_types.filter.untyped": "— Ohne Typ —",
"event_types.filter.search": "Typ suchen…",
@@ -2582,6 +2602,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.withdraw.cta": "Genehmigungsanfrage zurückziehen",
"approvals.withdraw.confirm": "Genehmigungsanfrage wirklich zurückziehen?",
"approvals.withdraw.error": "Fehler beim Zurückziehen",
"approvals.withdraw.cancel": "Abbrechen",
"approvals.withdraw.modal.title": "Genehmigungsanfrage zurückziehen?",
"approvals.withdraw.primary.label": "Termin bearbeiten",
"approvals.withdraw.destructive.label": "Endgültig zurückziehen und löschen",
"approvals.withdraw.lead.create.deadline": "Wenn Sie die Anfrage zurückziehen, wird die Frist gelöscht.",
"approvals.withdraw.lead.create.appointment": "Wenn Sie die Anfrage zurückziehen, wird der Termin gelöscht.",
"approvals.withdraw.lead.update": "Wenn Sie die Anfrage zurückziehen, werden die vorgeschlagenen Änderungen verworfen — der Eintrag kehrt in den Zustand vor Ihrer Bearbeitung zurück.",
"approvals.withdraw.lead.delete": "Wenn Sie die Löschanfrage zurückziehen, bleibt der Eintrag bestehen.",
"approvals.withdraw.sub.create": "Alternativ können Sie den Eintrag stattdessen bearbeiten. Die Anfrage bleibt offen und der Genehmiger sieht Ihre neuen Werte.",
"approvals.withdraw.sub.update": "Alternativ können Sie Ihre Änderungen bearbeiten und neu absenden. Die Anfrage bleibt offen.",
"approvals.withdraw.sub.delete": "Sind Sie sicher, dass Sie die Löschanfrage zurückziehen möchten?",
"approvals.pending_create.label": "Erstellung wartet auf Genehmigung",
"approvals.pending_update.label": "Änderung wartet auf Genehmigung",
"approvals.pending_complete.label": "Erledigung wartet auf Genehmigung",
@@ -3248,9 +3279,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",
@@ -3370,6 +3403,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",
@@ -3825,6 +3866,14 @@ const translations: Record<Lang, Record<string, string>> = {
"deadlines.field.rule.autofill_inline": " (set by rule)",
"deadlines.field.rule.mismatch": "Note: type contradicts rule — you have overridden the type.",
"deadlines.field.rule.override": "Choose another type",
"deadlines.field.rule.auto_badge": "Auto",
"deadlines.field.rule.override_warn": "Type derives rule: {derived}. Selected rule: {selected}. {selected} will be applied.",
"deadlines.field.rule.sort.by_proceeding": "By proceeding sequence",
"deadlines.field.rule.sort.by_court": "By court type",
"deadlines.field.rule.sort.alpha": "Alphabetical",
"deadlines.field.rule.sort.other_proceeding": "Other rules",
"deadlines.field.title.default_btn": "Default title",
"deadlines.field.title.default_fallback": "New deadline",
"deadlines.field.notes": "Notes (optional)",
"deadlines.field.notes.placeholder": "References, hints, next steps\u2026",
"deadlines.error.required": "Matter, title and due date are required.",
@@ -5355,6 +5404,8 @@ const translations: Record<Lang, Record<string, string>> = {
"event_types.browse.cancel": "Cancel",
"event_types.browse.selected_count": "{n} selected",
"event_types.browse.jurisdiction.none": "Any",
"event_types.browse.jurisdiction.all": "All courts",
"event_types.browse.jurisdiction.filter_label": "Filter by court type",
"event_types.filter.all": "All types",
"event_types.filter.untyped": "— Untyped —",
"event_types.filter.search": "Search type…",
@@ -5500,6 +5551,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.withdraw.cta": "Withdraw approval request",
"approvals.withdraw.confirm": "Withdraw the approval request?",
"approvals.withdraw.error": "Failed to withdraw",
"approvals.withdraw.cancel": "Cancel",
"approvals.withdraw.modal.title": "Withdraw approval request?",
"approvals.withdraw.primary.label": "Edit event",
"approvals.withdraw.destructive.label": "Withdraw permanently and delete",
"approvals.withdraw.lead.create.deadline": "Withdrawing this request will delete the deadline.",
"approvals.withdraw.lead.create.appointment": "Withdrawing this request will delete the appointment.",
"approvals.withdraw.lead.update": "Withdrawing this request will discard your proposed changes — the entry will revert to its state before your edit.",
"approvals.withdraw.lead.delete": "Withdrawing the delete request will keep the entry alive.",
"approvals.withdraw.sub.create": "Alternatively, you can edit the entry instead. The request stays open and the approver will see your new values.",
"approvals.withdraw.sub.update": "Alternatively, you can edit your changes and resubmit. The request stays open.",
"approvals.withdraw.sub.delete": "Are you sure you want to withdraw the delete request?",
"approvals.pending_create.label": "Awaits approval (creation)",
"approvals.pending_update.label": "Awaits approval (change)",
"approvals.pending_complete.label": "Awaits approval (completion)",

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

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

@@ -41,6 +41,19 @@ export function renderDeadlinesDetail(): string {
<div className="entity-detail-title-col">
<h1 id="deadline-title-display" />
<input type="text" id="deadline-title-edit" className="entity-title-input" style="display:none" />
{/* t-paliad-251 Part 4 — Standardtitel button only
visible in edit mode; clicking replaces the
title with a default derived from the project
and the deadline's event types / rule. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
style="display:none"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
<div className="entity-detail-meta">
<span id="deadline-due-chip" className="frist-due-chip" />
<span id="deadline-status-chip" className="entity-status-chip" />

View File

@@ -45,7 +45,22 @@ export function renderDeadlinesNew(): string {
</div>
<div className="form-field">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
<div className="form-field-label-row">
<label htmlFor="deadline-title" data-i18n="deadlines.field.title">Titel</label>
{/* t-paliad-251 Part 4 — derive a Standardtitel from the
currently-known context (event type → rule → proceeding
type → fallback) with the project reference as suffix.
Always replaces the title; no destructive confirmation
because the user invoked it explicitly. */}
<button
type="button"
id="deadline-title-default-btn"
className="btn-link-action"
data-i18n="deadlines.field.title.default_btn"
>
Standardtitel
</button>
</div>
<input
type="text"
id="deadline-title"
@@ -105,10 +120,44 @@ export function renderDeadlinesNew(): string {
picker so the parent/child relationship reads at a
glance. Due date is its own row below. */}
<div className="form-field">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
<div className="form-field-label-row">
<label htmlFor="deadline-rule" data-i18n="deadlines.field.rule">Regel (optional)</label>
{/* t-paliad-251 Part 2 — sort options for the Rule
select. Defaults to "by_court" so users in the
UPC bucket find UPC rules quickly. */}
<select id="deadline-rule-sort" className="rule-sort-select" aria-label="Sortierung">
<option value="by_proceeding" data-i18n="deadlines.field.rule.sort.by_proceeding">Nach Verfahrensablauf</option>
<option value="by_court" data-i18n="deadlines.field.rule.sort.by_court" selected>Nach Gerichtsart</option>
<option value="alpha" data-i18n="deadlines.field.rule.sort.alpha">Alphabetisch</option>
</select>
</div>
<select id="deadline-rule">
<option value="" data-i18n="deadlines.field.rule.none">Keine Regel</option>
</select>
{/* t-paliad-251 Part 3 — explicit Auto badge surfaces
whenever the Rule was auto-derived from the Typ.
Hidden when the user has manually picked a rule. */}
<p
className="form-hint form-hint--auto"
id="deadline-rule-auto-hint"
style="display:none"
>
<span
className="form-hint-badge"
data-i18n="deadlines.field.rule.auto_badge"
>Auto</span>
<span id="deadline-rule-auto-hint-text" />
</p>
{/* t-paliad-251 Part 3 — clearer override warning that
names BOTH the type-derived rule and the actually-
applied rule. Replaces the older Regel→Typ-only
mismatch warning when the contradiction goes the
other direction. */}
<p
className="form-hint form-hint--warning"
id="deadline-rule-override-warn"
style="display:none"
/>
</div>
<div className="form-field">

View File

@@ -682,9 +682,20 @@ export type I18nKey =
| "approvals.tab.mine"
| "approvals.tab.pending_mine"
| "approvals.title"
| "approvals.withdraw.cancel"
| "approvals.withdraw.confirm"
| "approvals.withdraw.cta"
| "approvals.withdraw.destructive.label"
| "approvals.withdraw.error"
| "approvals.withdraw.lead.create.appointment"
| "approvals.withdraw.lead.create.deadline"
| "approvals.withdraw.lead.delete"
| "approvals.withdraw.lead.update"
| "approvals.withdraw.modal.title"
| "approvals.withdraw.primary.label"
| "approvals.withdraw.sub.create"
| "approvals.withdraw.sub.delete"
| "approvals.withdraw.sub.update"
| "bottomnav.add"
| "bottomnav.add.appointment"
| "bottomnav.add.appointment.sub"
@@ -1112,6 +1123,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 +1154,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"
@@ -1227,12 +1244,20 @@ export type I18nKey =
| "deadlines.field.notes"
| "deadlines.field.notes.placeholder"
| "deadlines.field.rule"
| "deadlines.field.rule.auto_badge"
| "deadlines.field.rule.autofill"
| "deadlines.field.rule.autofill_inline"
| "deadlines.field.rule.mismatch"
| "deadlines.field.rule.none"
| "deadlines.field.rule.override"
| "deadlines.field.rule.override_warn"
| "deadlines.field.rule.sort.alpha"
| "deadlines.field.rule.sort.by_court"
| "deadlines.field.rule.sort.by_proceeding"
| "deadlines.field.rule.sort.other_proceeding"
| "deadlines.field.title"
| "deadlines.field.title.default_btn"
| "deadlines.field.title.default_fallback"
| "deadlines.field.title.placeholder"
| "deadlines.filter.akte"
| "deadlines.filter.akte.all"
@@ -1366,6 +1391,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"
@@ -1574,6 +1603,8 @@ export type I18nKey =
| "event_types.browse.apply"
| "event_types.browse.cancel"
| "event_types.browse.empty"
| "event_types.browse.jurisdiction.all"
| "event_types.browse.jurisdiction.filter_label"
| "event_types.browse.jurisdiction.none"
| "event_types.browse.search"
| "event_types.browse.selected_count"

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
@@ -6545,18 +6569,12 @@ dialog.modal::backdrop {
/* Each filter is a label-above-control cell so the caption sits on top of
its select / button. The whole filter-row stays a horizontal flex-wrap
of these column-cells (t-paliad-117).
min-width: 0 + max-width: 100% lets the cell shrink to fit its flex
container and prevents a native <select> with long option text from
blowing the cell wider than the viewport (t-paliad-255). */
of these column-cells (t-paliad-117). */
.filter-group {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
min-width: 0;
max-width: 100%;
}
.filter-label {
@@ -6570,10 +6588,6 @@ dialog.modal::backdrop {
.filter-group .entity-select { width: 100%; }
}
/* max-width: 100% caps the intrinsic width of a native <select> at its
parent — without it, browsers size the select to the longest <option>
text and a very long project title overflows the viewport on tablet
widths above the 480px breakpoint (t-paliad-255). */
.entity-select {
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
@@ -6582,8 +6596,6 @@ dialog.modal::backdrop {
background: var(--color-surface);
color: var(--color-text);
cursor: pointer;
max-width: 100%;
min-width: 0;
}
.entity-select:focus {
@@ -7532,6 +7544,78 @@ dialog.modal::backdrop {
border-left: 2px solid #b88800;
}
/* t-paliad-251 — Auto-derived hint variant. Lime-tint, sibling of the
yellow warning variant. Carries a small pill-badge in front (the
"Auto" label) followed by the derived rule name. */
.form-hint--auto {
display: inline-flex;
align-items: center;
gap: 0.4rem;
background: var(--color-bg-lime-tint);
color: var(--color-text);
padding: 0.3rem 0.5rem;
border-radius: var(--radius-sm, 4px);
border-left: 2px solid var(--color-accent);
}
.form-hint-badge {
display: inline-block;
padding: 0.05rem 0.45rem;
border-radius: 999px;
background: var(--color-accent);
color: var(--color-text);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
/* t-paliad-251 — label row that hosts both the form label and an
inline action (Standardtitel button, Rule-sort dropdown). The label
keeps growing to push the action to the right edge. */
.form-field-label-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
margin-bottom: 0.25rem;
}
.form-field-label-row > label {
margin: 0;
}
/* Inline action button rendered next to a form label (Standardtitel).
Text-link styling so it doesn't compete with the primary CTA. */
.btn-link-action {
background: transparent;
border: none;
color: var(--color-link, var(--color-text));
padding: 0;
font-family: var(--font-sans);
font-size: 0.82rem;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.btn-link-action:hover {
color: var(--color-accent);
}
/* Small dropdown rendered alongside the Rule label to switch the
ordering. Tone-down sizing so it doesn't look like a co-equal
form field. Specificity-bumped to win over `.form-field select`'s
width: 100% baseline. */
.form-field select.rule-sort-select,
select.rule-sort-select {
width: auto;
padding: 0.2rem 0.4rem;
font-size: 0.82rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm, 4px);
color: var(--color-text);
font-family: var(--font-sans);
}
/* Inline checkbox label inside the attach-unit form. */
.form-checkbox {
display: inline-flex;
@@ -7639,6 +7723,42 @@ dialog.modal::backdrop {
background: #b91c1c;
}
/* t-paliad-252 — withdraw warning modal body. The destructive button sits
inside the body (above the footer's Cancel + Edit primary) so the safe
"Edit event" path stays visually primary. The intro paragraph leads,
the muted sub-line explains consequences, then the red row makes the
destructive option discoverable without competing with the CTA. */
.withdraw-warning-body {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.withdraw-warning-intro {
margin: 0;
color: var(--color-text);
font-size: 0.92rem;
line-height: 1.45;
}
.withdraw-warning-sub {
margin: 0;
color: var(--color-text-muted);
font-size: 0.85rem;
line-height: 1.45;
}
.withdraw-warning-destructive-row {
display: flex;
justify-content: flex-end;
margin-top: 0.5rem;
padding-top: 0.75rem;
border-top: 1px dashed var(--color-border);
}
.withdraw-warning-destructive-btn {
/* Inherits .btn .btn-danger, but bump the font size down a touch so
the body button doesn't crowd the footer's primary CTA. */
font-size: 0.82rem;
padding: 0.4rem 1rem;
}
.entity-soon {
text-align: center;
padding: 3rem 1.5rem;
@@ -12529,6 +12649,37 @@ dialog.quick-add-sheet::backdrop {
transition: border-color 0.15s ease;
}
.event-type-browse-search:focus { border-color: var(--color-accent); }
/* t-paliad-251 — jurisdiction filter chips inside the browse modal
header. Sits below the search input, between the search and the
results list. Active chip uses the lime-tint chip palette already
established by .event-type-collapsed* (t-paliad-165). */
.event-type-browse-chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.event-type-browse-chip {
padding: 0.2rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-surface);
color: var(--color-text-muted);
font-family: var(--font-sans);
font-size: 0.78rem;
font-weight: 500;
cursor: pointer;
transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}
.event-type-browse-chip:hover {
background: var(--color-bg-subtle);
color: var(--color-text);
}
.event-type-browse-chip--active {
background: var(--color-bg-lime-tint);
border-color: var(--color-accent);
color: var(--color-text);
font-weight: 600;
}
.event-type-browse-list {
flex: 1 1 auto;
overflow-y: auto;

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

@@ -326,6 +326,56 @@ func handleRevokeApprovalRequest(w http.ResponseWriter, r *http.Request) {
handleApprovalDecision(w, r, "revoke")
}
// POST /api/approval-requests/{id}/edit-entity — t-paliad-252 / m/paliad#83.
//
// Lets the requester revise the in-flight entity (e.g. tweak the title on a
// pending create) without withdrawing the request. The non-destructive
// sibling of /revoke that m asked for after noticing that withdraw silently
// deletes the underlying event.
//
// Body: {"fields": {<entity-shape>}}
// 200: {"status": "ok"}
//
// Status mapping (mapApprovalError):
//
// 400 suggestion_requires_change — payload has no allowlisted fields
// 403 not_authorized — caller isn't the requested_by
// 404 — request not found / not visible
// 409 request_not_pending — request already decided / revoked
type editPendingEntityBody struct {
Fields map[string]any `json:"fields"`
}
func handleEditPendingEntity(w http.ResponseWriter, r *http.Request) {
if !requireDB(w) {
return
}
uid, ok := requireUser(w, r)
if !ok {
return
}
requestID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request id"})
return
}
var body editPendingEntityBody
if r.Body != nil && r.ContentLength > 0 {
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{
"code": "invalid_body",
"message": "Ungültiger Body.",
})
return
}
}
if err := dbSvc.approval.EditPendingEntity(r.Context(), requestID, uid, body.Fields); err != nil {
writeApprovalError(w, err)
return
}
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
// suggestChangesBody is the JSON body for POST /api/approval-requests/{id}/suggest-changes.
// counter_payload is an entity-shaped jsonb of the approver's edited
// values (allowlist enforced server-side); note is the optional free-text

View File

@@ -658,6 +658,9 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
protected.HandleFunc("POST /api/approval-requests/{id}/approve", handleApproveApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/reject", handleRejectApprovalRequest)
protected.HandleFunc("POST /api/approval-requests/{id}/revoke", handleRevokeApprovalRequest)
// t-paliad-252 — non-destructive sibling of /revoke: lets the
// requester revise the in-flight entity without withdrawing.
protected.HandleFunc("POST /api/approval-requests/{id}/edit-entity", handleEditPendingEntity)
protected.HandleFunc("POST /api/approval-requests/{id}/suggest-changes", handleSuggestChangesApprovalRequest)
// t-paliad-154 — form-time effective policy lookup. Reachable by

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

@@ -41,6 +41,7 @@ import (
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
"time"
@@ -364,6 +365,135 @@ func (s *ApprovalService) Revoke(ctx context.Context, requestID, callerID uuid.U
return s.decide(ctx, requestID, callerID, RequestStatusRevoked, "")
}
// EditPendingEntity lets the REQUESTER of a pending approval_request revise
// the in-flight entity (e.g. tweak the title or due_date on a pending
// create) without withdrawing the request. t-paliad-252 / m/paliad#83 added
// this as the non-destructive sibling of Revoke — m's mental model is
// "withdraw deletes the event; let me edit the event instead, keep the
// approval request alive".
//
// Authorization: caller MUST be the original requested_by (no approver can
// edit on the requester's behalf — that would collapse into SuggestChanges).
// Request status MUST be pending.
//
// Allowlist: uses the WIDER counter-allowlist already maintained for
// SuggestChanges (buildCounterSetClauses) — every editable field on the
// entity, not just the date-bearing approval triggers. Unknown keys are
// silently dropped. Returns ErrSuggestionRequiresChange when fields carries
// no allowlisted key for the entity_type (would be a no-op write).
//
// Side effects in one tx: entity columns updated (and event_type_ids junction
// rewritten for deadlines), approval_request.payload merged with the new
// values so the approver sees what was revised, and a distinct
// `<entity>_approval_edited_by_requester` project_event emitted so the
// Verlauf shows the revision separately from the original *_requested row.
//
// The approval_request stays pending; entity.approval_status stays pending.
// The approver inbox sees a fresh updated_at + the merged payload.
func (s *ApprovalService) EditPendingEntity(ctx context.Context, requestID, callerID uuid.UUID, fields map[string]any) error {
tx, err := s.db.BeginTxx(ctx, nil)
if err != nil {
return fmt.Errorf("begin tx: %w", err)
}
defer tx.Rollback() //nolint:errcheck
req, err := s.getRequestForUpdate(ctx, tx, requestID)
if err != nil {
return err
}
if req.Status != RequestStatusPending {
return fmt.Errorf("%w: status=%s", ErrRequestNotPending, req.Status)
}
if callerID != req.RequestedBy {
return ErrNotApprover
}
// Validate the counter-allowlist intersect produces at least one
// settable column. applyEntityUpdate also wraps this check; pre-checking
// here lets us emit a cleaner error before opening the entity-write.
if _, _, err := buildCounterSetClauses(req.EntityType, fields); err != nil {
// Already wraps ErrSuggestionRequiresChange for empty / title-cleared
// cases. Propagate verbatim.
return err
}
// Apply the field updates to the entity row via the shared
// counter-allowlist path (same as SuggestChanges).
if err := s.applyEntityUpdate(ctx, tx, req.EntityType, req.EntityID, fields); err != nil {
return err
}
// Merge new fields into the request payload so the approver's inbox
// reflects what the requester revised to. Keys overwrite; event_type_ids
// is replaced wholesale per the same semantics applyEntityUpdate uses
// for the junction rewrite.
var existing map[string]any
if len(req.Payload) > 0 {
if err := json.Unmarshal(req.Payload, &existing); err != nil {
return fmt.Errorf("unmarshal payload: %w", err)
}
}
if existing == nil {
existing = map[string]any{}
}
maps.Copy(existing, fields)
merged, err := json.Marshal(existing)
if err != nil {
return fmt.Errorf("marshal merged payload: %w", err)
}
now := time.Now().UTC()
if _, err := tx.ExecContext(ctx,
`UPDATE paliad.approval_requests
SET payload = $1, updated_at = $2
WHERE id = $3`,
merged, now, requestID); err != nil {
return fmt.Errorf("update payload: %w", err)
}
// Audit emit. Distinct event_type so the Verlauf surfaces the revision
// separately from the original *_requested or any decision row.
verlaufKind := "edited_by_requester"
eventType := approvalEventType(req.EntityType, verlaufKind)
descPtr := approvalDescription(verlaufKind, req.RequiredRole, req.LifecycleEvent)
editedKeys := sortedKeys(fields)
meta := map[string]any{
"approval_request_id": req.ID.String(),
"lifecycle_event": req.LifecycleEvent,
req.EntityType + "_id": req.EntityID.String(),
"edited_fields": editedKeys,
}
if err := insertProjectEventWithMeta(ctx, tx, req.ProjectID, callerID, eventType, eventType, descPtr, meta); err != nil {
return err
}
return tx.Commit()
}
// sortedKeys returns m's keys in stable alphabetical order so the audit-log
// metadata is byte-for-byte stable across calls (helps when diffing audit
// logs or asserting on them in tests).
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
// Use the stdlib sort; the slice is small (≤ counter-allowlist size).
sortStrings(out)
return out
}
// sortStrings: indirection so we don't add a new top-level import group.
// In Go 1.21+ slices.Sort exists; this package is currently importing
// strings + standard libs and adding "sort" would re-fan the imports.
// Kept as a one-line wrapper to localise the dependency if a later move
// to slices.Sort feels right.
func sortStrings(s []string) {
for i := 1; i < len(s); i++ {
for j := i; j > 0 && s[j-1] > s[j]; j-- {
s[j-1], s[j] = s[j], s[j-1]
}
}
}
// SuggestChanges is the fourth approval action (t-paliad-216). The caller
// proposes a counter-payload + optional free-text note; in one transaction
// we close the old request as 'changes_requested', revert the entity from

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