From 480332a5f51e8aea9595fb813bbbe4fde0e87538 Mon Sep 17 00:00:00 2001 From: mAi Date: Wed, 27 May 2026 15:20:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(deadline-system):=20P3=20=E2=80=94=20three?= =?UTF-8?q?-way=20detail=20filter=20on=20Verfahrensablauf=20(m/paliad#149)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit m's headline UX ask (2026-05-27 14:58, paliadin priority signal): "The new timeline filters for optional / mandatory / show only selected is what I am most waiting for. I want this to be consolidated for all our deadlines so we can simulate all proceedings." Phase 2 P3. Adds a three-way detail-level filter above the result panel on /tools/verfahrensablauf: ( ) Nur Pflicht — only priority='mandatory' rules (•) Gewählt — mandatory + recommended (default) + every explicit per-rule override the user has set in projects.scenario_flags ( ) Alle Optionen — every rule, with unselected ones rendered dotted-border + muted so the user sees what they're NOT considering State persists per-user via localStorage["verfahrensablauf:view_mode"]. The filter is pure client-side narrowing on the calc payload — flipping the toggle re-renders instantly without a fresh backend call. Per-rule selection (design §2.4a): every optional / recommended card now carries an [Aufnehmen] / [Entfernen] chip. Clicking writes a "rule:" entry into the project's scenario_flags via the P0 SSoT PATCH endpoint, recording only deviations from the priority default: recommended + entfernen → rule: = false (explicit deselect) optional + aufnehmen → rule: = true (explicit select) flipping back to the default deletes the entry Mandatory rules never expose the chip — they cannot be deselected. Wire-shape change: CalculatedDeadline gains `ruleId` (the backend already emits it as `ruleId` in TimelineEntry; only the frontend interface needed to surface it). Conditional handling: a conditional rule whose predicate doesn't fire is treated as unselected in "Gewählt" mode (even when priority= mandatory) — mandatory means "must be filed IF the predicate fires", not "always render". The "Alle Optionen" view re-surfaces it so the lawyer can see what scenario would unlock it. Cross-surface coherence: hydrating ?project= reads scenario_flags from the SSoT and pre-fills the existing flag checkboxes (with_ccr / with_amend / with_cci) so the page reflects the project's persisted state on first paint. Every flag toggle + every per-rule chip click PATCHes back. The page also listens for the scenario-flag-changed CustomEvent fired by peer surfaces (Mode B Fristenrechner result-view) and re-renders without a fresh fetch. i18n: 5 new keys (deadlines.detail.{label,mandatory_only,selected, all_options,optional_unselected_hint,aufnehmen,entfernen}) DE + EN. CSS: dotted-border + muted treatment on .timeline-item-header-- unselected; .timeline-selection-chip with --add (lime accent) and --remove (discreet muted) variants. Tests: 8 new unit tests covering isRuleSelected (4 priority × 2 flag state matrix) and filterByDetailMode (3 modes × default/override cases). Verified: bun build clean, bun test 264/264 (8 new), go vet clean. Design: docs/design-deadline-system-revision-2026-05-27.md §2.4a (selection state model), §3.3a (view-mode toggle), §6 (Entry A spec). t-paliad-331. Re-prioritised by m via paliadin 14:58. --- frontend/src/client/i18n.ts | 14 ++ .../verfahrensablauf-detail-mode.test.ts | 96 +++++++++ .../client/verfahrensablauf-detail-mode.ts | 125 +++++++++++ frontend/src/client/verfahrensablauf.ts | 204 +++++++++++++++++- .../src/client/views/verfahrensablauf-core.ts | 37 +++- frontend/src/i18n-keys.ts | 7 + frontend/src/styles/global.css | 63 ++++++ frontend/src/verfahrensablauf.tsx | 25 +++ 8 files changed, 563 insertions(+), 8 deletions(-) create mode 100644 frontend/src/client/verfahrensablauf-detail-mode.test.ts create mode 100644 frontend/src/client/verfahrensablauf-detail-mode.ts diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index 4833a0e..cba9ccb 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -1023,6 +1023,13 @@ const translations: Record> = { "deadlines.overhaul.group.conditional": "Bedingt", "deadlines.overhaul.spawn.badge": "\u21f2 neues Verfahren", "deadlines.overhaul.spawn.tooltip": "Diese Regel leitet ein neues Verfahren ein.", + "deadlines.detail.label": "Anzeige:", + "deadlines.detail.mandatory_only": "Nur Pflicht", + "deadlines.detail.selected": "Gewählt", + "deadlines.detail.all_options": "Alle Optionen", + "deadlines.detail.optional_unselected_hint": "Diese Regel ist optional und gehört nicht zum aktuellen Szenario.", + "deadlines.detail.aufnehmen": "Aufnehmen", + "deadlines.detail.entfernen": "Entfernen", "deadlines.overhaul.condition.badge": "Nur unter Bedingung", "deadlines.overhaul.crossparty.badge": "Gegenseitig", "deadlines.overhaul.crossparty.tooltip": "Diese Frist wird von der Gegenseite eingereicht. Sie erscheint nur zur Information und wird nicht in die Akte übernommen.", @@ -4211,6 +4218,13 @@ const translations: Record> = { "deadlines.overhaul.group.conditional": "Conditional", "deadlines.overhaul.spawn.badge": "⇲ new proceeding", "deadlines.overhaul.spawn.tooltip": "This rule initiates a new proceeding.", + "deadlines.detail.label": "Detail:", + "deadlines.detail.mandatory_only": "Mandatory only", + "deadlines.detail.selected": "Selected", + "deadlines.detail.all_options": "All options", + "deadlines.detail.optional_unselected_hint": "This rule is optional and not part of the current scenario.", + "deadlines.detail.aufnehmen": "Add", + "deadlines.detail.entfernen": "Remove", "deadlines.overhaul.condition.badge": "Conditional", "deadlines.overhaul.crossparty.badge": "Other side", "deadlines.overhaul.crossparty.tooltip": "This deadline is filed by the opposing party. Shown for information only — not added to the Akte.", diff --git a/frontend/src/client/verfahrensablauf-detail-mode.test.ts b/frontend/src/client/verfahrensablauf-detail-mode.test.ts new file mode 100644 index 0000000..e0cbb94 --- /dev/null +++ b/frontend/src/client/verfahrensablauf-detail-mode.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from "bun:test"; +import { type CalculatedDeadline } from "./views/verfahrensablauf-core"; +import { filterByDetailMode, isRuleSelected } from "./verfahrensablauf-detail-mode"; + +// Helper — minimum-viable CalculatedDeadline for unit testing the filter +// (the renderer's other fields don't matter to the filter). +function mkRule( + ruleId: string, + priority: "mandatory" | "recommended" | "optional", + extras: Partial = {}, +): CalculatedDeadline { + return { + ruleId, + code: ruleId, + name: ruleId, + nameEN: ruleId, + party: "", + priority, + ruleRef: "", + dueDate: "2026-06-01", + originalDate: "2026-06-01", + wasAdjusted: false, + isRootEvent: false, + isCourtSet: false, + ...extras, + }; +} + +describe("isRuleSelected", () => { + it("mandatory rules are always selected, even with explicit deselect", () => { + const dl = mkRule("a", "mandatory"); + expect(isRuleSelected(dl, null)).toBe(true); + expect(isRuleSelected(dl, { "rule:a": false })).toBe(true); + }); + + it("recommended rules default to selected; explicit false deselects", () => { + const dl = mkRule("a", "recommended"); + expect(isRuleSelected(dl, null)).toBe(true); + expect(isRuleSelected(dl, {})).toBe(true); + expect(isRuleSelected(dl, { "rule:a": false })).toBe(false); + expect(isRuleSelected(dl, { "rule:a": true })).toBe(true); + }); + + it("optional rules default to unselected; explicit true selects", () => { + const dl = mkRule("a", "optional"); + expect(isRuleSelected(dl, null)).toBe(false); + expect(isRuleSelected(dl, {})).toBe(false); + expect(isRuleSelected(dl, { "rule:a": true })).toBe(true); + expect(isRuleSelected(dl, { "rule:a": false })).toBe(false); + }); + + it("conditional rules are treated as unselected in 'Gewählt' (engine left them unprojected)", () => { + const dl = mkRule("a", "mandatory", { isConditional: true }); + expect(isRuleSelected(dl, null)).toBe(false); + }); +}); + +describe("filterByDetailMode", () => { + const deadlines = [ + mkRule("anchor", "mandatory", { isRootEvent: true }), + mkRule("m1", "mandatory"), + mkRule("r1", "recommended"), + mkRule("o1", "optional"), + mkRule("o2", "optional"), + ]; + + it("mandatory_only returns mandatory + root only", () => { + const out = filterByDetailMode(deadlines, "mandatory_only", null); + const ids = out.map((d) => d.ruleId); + expect(ids).toEqual(["anchor", "m1"]); + }); + + it("selected (default flags) returns mandatory + recommended + root", () => { + const out = filterByDetailMode(deadlines, "selected", null); + const ids = out.map((d) => d.ruleId); + expect(ids).toEqual(["anchor", "m1", "r1"]); + }); + + it("selected with explicit per-rule overrides flips both directions", () => { + const flags = { "rule:r1": false, "rule:o1": true }; + const out = filterByDetailMode(deadlines, "selected", flags); + const ids = out.map((d) => d.ruleId); + expect(ids).toEqual(["anchor", "m1", "o1"]); + }); + + it("all_options returns the full list and tags unselected rules", () => { + const out = filterByDetailMode(deadlines, "all_options", null); + expect(out.length).toBe(5); + const unselected = out.filter( + (d) => (d as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected, + ); + // Root + mandatory + recommended are selected; the two optionals + // are unselected → 2 tagged rows. + expect(unselected.map((d) => d.ruleId).sort()).toEqual(["o1", "o2"]); + }); +}); diff --git a/frontend/src/client/verfahrensablauf-detail-mode.ts b/frontend/src/client/verfahrensablauf-detail-mode.ts new file mode 100644 index 0000000..4bb2900 --- /dev/null +++ b/frontend/src/client/verfahrensablauf-detail-mode.ts @@ -0,0 +1,125 @@ +// Detail-level filter for /tools/verfahrensablauf (m/paliad#149 Phase 2 P3). +// +// m's framing (2026-05-27 14:40, design §2.4a + §3.3a): +// +// "It is more that I want a grade of detail in our swimlane display. +// I want to show them but also be able to 'focus' by not displaying +// optional things. We need an option 'show only selected' or +// 'mandatory' ... filter events from the timeline based on whether +// they are selected in this scenario." +// +// Three modes: +// - mandatory_only — render only priority='mandatory' rules +// - selected (default) — mandatory + every rule whose effective +// selection (priority-default OR scenario-flag +// override) is true. Honest summary of "the +// lawyer's scenario". +// - all_options — render everything, with unselected optionals +// rendered dotted-border + muted so the user sees +// what they're NOT considering. +// +// Selection model (per design §2.4a): +// - priority='mandatory' → always selected (cannot be deselected) +// - priority='recommended' → default-selected; rule:=false in +// scenario_flags deselects +// - priority='optional' → default-unselected; rule:=true in +// scenario_flags selects +// - conditional rules → respect their condition_expr first; if +// the predicate doesn't hold, they're +// effectively unselected regardless of +// their priority default + +import { type CalculatedDeadline } from "./views/verfahrensablauf-core"; + +export type DetailMode = "mandatory_only" | "selected" | "all_options"; + +const STORAGE_KEY = "verfahrensablauf:view_mode"; +const DEFAULT_MODE: DetailMode = "selected"; + +export function getDetailMode(): DetailMode { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (raw === "mandatory_only" || raw === "selected" || raw === "all_options") { + return raw; + } + } catch { + // localStorage unavailable (private mode, security policy) — fall + // through to default. Render still works; just no persistence. + } + return DEFAULT_MODE; +} + +export function setDetailMode(mode: DetailMode): void { + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch { + // best-effort + } +} + +// isRuleSelected: combine priority default with the scenario-flag +// override map. Returns the effective selection state. +// +// priority='mandatory' → always true +// priority='recommended' → default true, flipped by rule:=false +// priority='optional' → default false, flipped by rule:=true +// other (informational) → treated as optional +export function isRuleSelected( + dl: CalculatedDeadline, + scenarioFlags: Record | null, +): boolean { + // A conditional rule that the engine left unprojected (no concrete + // date because its predicate doesn't hold) is effectively unselected + // in "selected" view mode — even for priority='mandatory' rules, + // because mandatory means "must be filed IF the predicate fires", + // not "always render". Surfacing a non-applicable conditional row in + // "Gewählt" would be a lie. The "all_options" view re-surfaces it via + // the unfiltered render path so the lawyer can see what scenarios + // would unlock it. + if (dl.isConditional) return false; + + if (dl.priority === "mandatory") return true; + + const key = dl.ruleId ? `rule:${dl.ruleId}` : null; + const override = key && scenarioFlags ? scenarioFlags[key] : undefined; + if (typeof override === "boolean") return override; + + return dl.priority === "recommended"; +} + +// filterByDetailMode applies the three-way filter to a deadlines list. +// Returns a NEW array with the appropriate subset; the caller passes +// the filtered list to the existing renderColumnsBody / renderTimelineBody. +// +// all_options: returns the input as-is, with an `__detailUnselected` +// flag set on optionals/conditionals that aren't part of the active +// scenario — the renderer reads this flag to add the dotted-border +// muted styling. +export function filterByDetailMode( + deadlines: CalculatedDeadline[], + mode: DetailMode, + scenarioFlags: Record | null, +): CalculatedDeadline[] { + if (mode === "all_options") { + // No filtering, but tag the unselected rows so the renderer can + // dim them. The original CalculatedDeadline doesn't carry this + // axis — we stamp it via a cast so the renderer can pick it up + // without growing the public type. Read-only at the renderer side. + return deadlines.map((dl) => { + const unselected = !isRuleSelected(dl, scenarioFlags) && !dl.isRootEvent; + return unselected + ? ({ ...dl, __detailUnselected: true } as CalculatedDeadline & { __detailUnselected: true }) + : dl; + }); + } + if (mode === "mandatory_only") { + return deadlines.filter( + (dl) => dl.priority === "mandatory" || dl.isRootEvent, + ); + } + // "selected": mandatory always, plus rules whose effective selection + // is true. Root events always render (they're the proceeding anchor). + return deadlines.filter( + (dl) => dl.isRootEvent || isRuleSelected(dl, scenarioFlags), + ); +} diff --git a/frontend/src/client/verfahrensablauf.ts b/frontend/src/client/verfahrensablauf.ts index b43a7a5..dc76c12 100644 --- a/frontend/src/client/verfahrensablauf.ts +++ b/frontend/src/client/verfahrensablauf.ts @@ -25,6 +25,19 @@ import { reseedChips, type EventChoice, } from "./views/event-card-choices"; +import { + filterByDetailMode, + getDetailMode, + isRuleSelected, + setDetailMode, + type DetailMode, +} from "./verfahrensablauf-detail-mode"; +import { + fetchScenarioFlags, + onScenarioFlagsChanged, + patchScenarioFlags, + SCENARIO_FLAG_CHANGED_EVENT, +} from "./scenario-flags"; import { APPEAL_TARGETS, SCENARIO_KEYS, @@ -48,6 +61,28 @@ import { let selectedType = ""; let lastResponse: DeadlineResponse | null = null; +// m/paliad#149 Phase 2 P3 — detail-level view-mode + scenario-flag state. +// +// detailMode: which of the three filter buckets is active. Persisted in +// localStorage under verfahrensablauf:view_mode so it survives reload +// and follows the user across projects (m's "I want a grade of detail +// in our swimlane display" framing — it's a UI preference, not a +// scenario fact). +// +// projectId: when the page is opened with ?project=, scenario_flag +// reads/writes go through PATCH /api/projects/{id}/scenario-flags (the +// P0 SSoT). Kontextfrei (no project) stays on localStorage via the +// existing perCardChoices path; per-rule selection deviations land in +// scenarioFlagsLocal keyed by proceeding_type code. +// +// scenarioFlags: live map shadow. Refreshed by hydrateScenarioFlags() +// on project pick + listens to the scenario-flag-changed CustomEvent +// so toggles from other surfaces (Mode B Fristenrechner result-view) +// re-trigger re-render here without a fresh fetch. +let detailMode: DetailMode = getDetailMode(); +let projectIdForFlags: string | null = null; +let scenarioFlags: Record = {}; + // Perspective state. URL-driven so the view is shareable + survives // reload: // ?side=claimant|defendant — swaps which column owns the user's @@ -470,8 +505,15 @@ function renderResults(data: DeadlineResponse) { ? `
${escHtml(noteText)}
` : ""; + // m/paliad#149 Phase 2 P3 — apply the detail-level filter pre-render. + // The calc payload stays the same; we just narrow what the renderer + // sees. Root events always pass through so the proceeding anchor is + // never hidden by the filter. + const filteredDeadlines = filterByDetailMode(data.deadlines, detailMode, scenarioFlags); + const filteredData: DeadlineResponse = { ...data, deadlines: filteredDeadlines }; + const bodyHtml = procedureView === "columns" - ? renderColumnsBody(data, { + ? renderColumnsBody(filteredData, { editable: true, showNotes, showDurations, @@ -489,7 +531,7 @@ function renderResults(data: DeadlineResponse) { // m/paliad#136 Bug 1) appealAware: hasAppealTarget(selectedType), }) - : renderTimelineBody(data, { showParty: true, editable: true, showNotes, showDurations }); + : renderTimelineBody(filteredData, { showParty: true, editable: true, showNotes, showDurations }); container.innerHTML = headerHtml + noteHtml + bodyHtml; if (printBtn) printBtn.style.display = "block"; @@ -786,6 +828,120 @@ function applyVerfahrensablaufViewBodyClass(view: ProcedureView) { document.body.classList.toggle("verfahrensablauf-view-columns", view === "columns"); } +// initDetailModeToggle wires the three-way Anzeige toggle (Nur Pflicht / +// Gewählt / Alle Optionen) introduced for m/paliad#149 Phase 2 P3. +// State persists via localStorage (per-user, per-browser); flipping the +// radio re-renders the last response without a fresh calc — the filter +// is pure client-side narrowing on data the page already has. +function initDetailModeToggle() { + const toggle = document.getElementById("verfahrensablauf-detail-toggle"); + if (!toggle) return; + toggle.querySelectorAll("input[name=detail-mode]").forEach((input) => { + input.checked = input.value === detailMode; + input.addEventListener("change", () => { + if (!input.checked) return; + const v = input.value; + if (v === "mandatory_only" || v === "selected" || v === "all_options") { + detailMode = v; + setDetailMode(detailMode); + if (lastResponse) renderResults(lastResponse); + } + }); + }); +} + +// initScenarioFlagsForProject loads the project's persisted scenario_flags +// (mig 154 SSoT). Called when the page is opened with ?project= and +// also when the project autofill resolves a project. Listens for the +// scenario-flag-changed CustomEvent so any peer surface that PATCHes the +// same project (Mode B Fristenrechner result-view) keeps this page in +// sync without polling. +async function hydrateScenarioFlags(projectID: string) { + projectIdForFlags = projectID; + const view = await fetchScenarioFlags(projectID); + if (!view) return; + scenarioFlags = view.flags; + // The named scenario flags (with_ccr / with_amend / with_cci) drive + // the existing flag checkboxes — re-syncing them here makes the page + // reflect the project's persisted state on first paint. + const setChecked = (id: string, val: boolean | undefined): void => { + const el = document.getElementById(id) as HTMLInputElement | null; + if (!el) return; + el.checked = val === true; + }; + setChecked("ccr-flag", scenarioFlags["with_ccr"]); + setChecked("inf-amend-flag", scenarioFlags["with_amend"]); + setChecked("rev-amend-flag", scenarioFlags["with_amend"]); + setChecked("rev-cci-flag", scenarioFlags["with_cci"]); + syncInfAmendEnabled(); + if (lastResponse) renderResults(lastResponse); +} + +// Subscribe to peer-surface scenario-flag changes once at module load. +// The listener is idempotent — we re-read the map and re-render. Skipped +// when projectIdForFlags hasn't been set yet (kontextfrei mode). +onScenarioFlagsChanged((detail) => { + if (!projectIdForFlags || detail.projectId !== projectIdForFlags) return; + scenarioFlags = detail.flags; + if (lastResponse) renderResults(lastResponse); +}); + +// persistNamedScenarioFlag writes a named flag (with_ccr / with_amend / +// with_cci) to the project's scenario_flags via PATCH. No-op in +// kontextfrei mode. The page-level checkbox owns the click; this helper +// just mirrors it into the SSoT so peer surfaces see the change. +function persistNamedScenarioFlag(key: string, value: boolean): void { + if (!projectIdForFlags) return; + void patchScenarioFlags(projectIdForFlags, { [key]: value }); + scenarioFlags = { ...scenarioFlags, [key]: value }; +} + +// onRuleSelectionToggle handles a click on a per-rule [Aufnehmen] or +// [Entfernen] chip (m/paliad#149 Phase 2 P3, design §2.4a). Translates +// the action into a scenario-flag delta: +// +// priority='recommended', aufnehmen=true → delete rule: (back to default-selected) +// priority='recommended', aufnehmen=false → write rule: = false (explicit deselect) +// priority='optional', aufnehmen=true → write rule: = true (explicit select) +// priority='optional', aufnehmen=false → delete rule: (back to default-unselected) +// +// In other words: the chip stores only DEVIATIONS from the priority +// default; flipping back to the default-state deletes the entry. Akte +// mode PATCHes via scenario-flags.ts; kontextfrei mode is no-op +// today (per-rule selections in kontextfrei mode are a future P3 +// extension via localStorage; the chip still hides itself once flipped +// because the page-level scenarioFlags map updates). +function onRuleSelectionToggle(ruleId: string, priority: string, aufnehmen: boolean): void { + const key = `rule:${ruleId}`; + let deltaValue: boolean | null; + if (priority === "recommended") { + deltaValue = aufnehmen ? null : false; + } else if (priority === "optional") { + deltaValue = aufnehmen ? true : null; + } else { + return; // mandatory / unknown — not toggleable + } + + // Update the local shadow first so the re-render below reflects the + // new state regardless of network latency. + const next = { ...scenarioFlags }; + if (deltaValue === null) { + delete next[key]; + } else { + next[key] = deltaValue; + } + scenarioFlags = next; + + // Persist to the project's SSoT when in akte mode. Fire-and-forget; + // a network failure leaves the local shadow ahead of the server, which + // the next hydrate or peer scenario-flag-changed event reconciles. + if (projectIdForFlags) { + void patchScenarioFlags(projectIdForFlags, { [key]: deltaValue }); + } + + if (lastResponse) renderResults(lastResponse); +} + function initViewToggle() { const toggle = document.getElementById("fristen-view-toggle"); if (!toggle) return; @@ -908,17 +1064,24 @@ document.addEventListener("DOMContentLoaded", () => { // disabled checkbox as checked. const infAmend = document.getElementById("inf-amend-flag") as HTMLInputElement | null; if (infAmend) writeBoolFlag(scenarioStorage, SCENARIO_KEYS.infAmend, infAmend.checked); + // m/paliad#149 Phase 2 P3 — mirror into the project's scenario_flags + // SSoT when in akte mode. PATCH is fire-and-forget; failure just + // means the local UI keeps its optimistic state and the next + // hydrate reconciles. + persistNamedScenarioFlag("with_ccr", ccrFlag.checked); + if (infAmend) persistNamedScenarioFlag("with_amend", infAmend.checked); scheduleCalc(0); }); - const flagStorageKeys: Record = { - "inf-amend-flag": SCENARIO_KEYS.infAmend, - "rev-amend-flag": SCENARIO_KEYS.revAmend, - "rev-cci-flag": SCENARIO_KEYS.revCci, + const flagStorageKeys: Record = { + "inf-amend-flag": { storageKey: SCENARIO_KEYS.infAmend, flagKey: "with_amend" }, + "rev-amend-flag": { storageKey: SCENARIO_KEYS.revAmend, flagKey: "with_amend" }, + "rev-cci-flag": { storageKey: SCENARIO_KEYS.revCci, flagKey: "with_cci" }, }; - for (const [id, storageKey] of Object.entries(flagStorageKeys)) { + for (const [id, { storageKey, flagKey }] of Object.entries(flagStorageKeys)) { const cb = document.getElementById(id) as HTMLInputElement | null; if (cb) cb.addEventListener("change", () => { writeBoolFlag(scenarioStorage, storageKey, cb.checked); + persistNamedScenarioFlag(flagKey, cb.checked); scheduleCalc(0); }); } @@ -938,6 +1101,21 @@ document.addEventListener("DOMContentLoaded", () => { } scheduleCalc(0); }); + // m/paliad#149 Phase 2 P3 — delegated handler for the per-rule + // [Aufnehmen] / [Entfernen] chips. The chip carries data-rule-id + + // data-action; the click flips the scenario-flag entry for that + // rule and re-renders. + timelineContainer.addEventListener("click", (e) => { + const target = e.target as HTMLElement | null; + const btn = target?.closest(".timeline-selection-chip"); + if (!btn) return; + e.preventDefault(); + const ruleId = btn.dataset.ruleId; + const priority = btn.dataset.priority; + const action = btn.dataset.action; + if (!ruleId || !priority || !action) return; + onRuleSelectionToggle(ruleId, priority, action === "aufnehmen"); + }); } // Notes toggle — restores last preference on load + re-renders when @@ -981,8 +1159,20 @@ document.addEventListener("DOMContentLoaded", () => { } initViewToggle(); + initDetailModeToggle(); initPerspectiveControls(); + // m/paliad#149 Phase 2 P3 — hydrate scenario_flags from the project's + // SSoT (mig 154) when the page is opened with ?project=. Initial + // fetch sets the page-level flag checkboxes; subsequent peer-surface + // changes arrive via the scenario-flag-changed CustomEvent (subscribed + // at module load above). Kontextfrei (no ?project=) skips the fetch — + // localStorage-only behaviour stays as it was. + const projectQuery = new URLSearchParams(window.location.search).get("project"); + if (projectQuery && /^[0-9a-fA-F-]{36}$/.test(projectQuery)) { + void hydrateScenarioFlags(projectQuery); + } + // t-paliad-265 — per-event-card choices. Unbound surface; persistence // is localStorage-only (t-paliad-308) so a shared link doesn't carry // the recipient's per-card tweaks. The popover module owns the diff --git a/frontend/src/client/views/verfahrensablauf-core.ts b/frontend/src/client/views/verfahrensablauf-core.ts index d128831..c9311b1 100644 --- a/frontend/src/client/views/verfahrensablauf-core.ts +++ b/frontend/src/client/views/verfahrensablauf-core.ts @@ -28,6 +28,11 @@ export interface AdjustmentReason { } export interface CalculatedDeadline { + // ruleId is the sequencing_rule.id UUID, used by the P3 per-rule + // selection deviations (`rule:` keys in projects.scenario_flags). + // Empty on synthetic UI markers like the appeal trigger row that the + // engine prepends — those carry no real rule_id. + ruleId?: string; code: string; name: string; nameEN: string; @@ -613,13 +618,43 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string data-empty="true">` : ""; - return `
+ // m/paliad#149 Phase 2 P3 — Aufnehmen / Entfernen chip on optional / + // recommended rules (when the detail-mode filter is in "all_options" + // or "selected"). The detail-mode filter tags unselected rules with + // __detailUnselected; the renderer picks that up to render the chip + // in its "Aufnehmen" state. Mandatory rules never get the chip — the + // user can't deselect them. + const detailUnselected = (dl as CalculatedDeadline & { __detailUnselected?: boolean }).__detailUnselected === true; + let selectionChip = ""; + if (dl.ruleId && dl.priority !== "mandatory" && !dl.isRootEvent) { + if (detailUnselected) { + selectionChip = ``; + } else if (dl.priority === "recommended" || dl.priority === "optional") { + // The rule IS in the active scenario but can be removed. Renders + // as a discreet [Entfernen] chip on optional / recommended cards. + selectionChip = ``; + } + } + + return `
${dlName} ${stateIconsHtml} ${chipHtml} ${dateStr} + ${selectionChip} ${choicesHtml}
${meta} diff --git a/frontend/src/i18n-keys.ts b/frontend/src/i18n-keys.ts index 38d1f17..4706209 100644 --- a/frontend/src/i18n-keys.ts +++ b/frontend/src/i18n-keys.ts @@ -1246,6 +1246,8 @@ export type I18nKey = | "deadlines.de.inf.olg" | "deadlines.de.null.bgh" | "deadlines.de.null.bpatg" + | "deadlines.detail.all_options" + | "deadlines.detail.aufnehmen" | "deadlines.detail.back" | "deadlines.detail.cancel" | "deadlines.detail.complete" @@ -1259,12 +1261,17 @@ export type I18nKey = | "deadlines.detail.delete.confirm.title" | "deadlines.detail.due" | "deadlines.detail.edit" + | "deadlines.detail.entfernen" + | "deadlines.detail.label" | "deadlines.detail.loading" + | "deadlines.detail.mandatory_only" | "deadlines.detail.notes" | "deadlines.detail.notfound" + | "deadlines.detail.optional_unselected_hint" | "deadlines.detail.reopen" | "deadlines.detail.rule" | "deadlines.detail.save" + | "deadlines.detail.selected" | "deadlines.detail.source" | "deadlines.detail.title" | "deadlines.dpma" diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index fa9a17a..c76207a 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -3704,6 +3704,69 @@ input[type="range"]::-moz-range-thumb { text-align: right; } +/* m/paliad#149 Phase 2 P3 — detail-mode unselected card styling + + per-rule Aufnehmen / Entfernen chip. + + Unselected cards (priority='optional' or 'recommended' that the user + has chosen NOT to include in the active scenario, surfaced only in + "Alle Optionen" view-mode) render with a dotted border + muted fill, + mirroring the conditional-row treatment. The card stays clickable so + the user can still inspect notes / dates, but it visually reads as + "not part of this scenario right now". + + The chip itself is intentionally small and unobtrusive — Aufnehmen + is a lime-tinted call-to-action; Entfernen is a discreet muted chip. */ +.timeline-item-header--unselected, +.timeline-item--detail-unselected .timeline-content, +.fr-col-item--detail-unselected { + opacity: 0.7; +} +.timeline-item--detail-unselected .timeline-content, +.fr-col-item--detail-unselected { + border: 1px dashed var(--color-border, #d4d4d4); + border-radius: 4px; + padding: 0.35rem 0.55rem; + background: var(--color-bg-soft, #fafafa); +} + +.timeline-selection-chip { + appearance: none; + border: 1px solid var(--color-border, #d4d4d4); + background: var(--color-bg, #fff); + color: var(--color-text); + font-size: 0.72rem; + line-height: 1.2; + padding: 0.1rem 0.5rem; + border-radius: 999px; + cursor: pointer; + margin-left: 0.4rem; + white-space: nowrap; +} +.timeline-selection-chip:hover { + background: var(--color-bg-soft, #f4f4f4); +} +.timeline-selection-chip--add { + /* Aufnehmen — pulls the rule into the active scenario. Uses the + Paliad accent (lime) so the call-to-action is visually distinct + from the muted card around it. */ + border-color: var(--color-accent, #c6f41c); + background: var(--color-accent-soft-bg, #f0fcd1); + color: var(--color-accent-soft-fg, #4d6b00); + font-weight: 600; +} +.timeline-selection-chip--add::before { + content: "+ "; +} +.timeline-selection-chip--remove { + /* Entfernen — discreet chip on selected optional rows. The user can + restore the priority default by clicking; mandatory rules never + expose this chip. */ + color: var(--color-text-muted); +} +.timeline-selection-chip--remove::before { + content: "× "; +} + .event-card-choices-popover { background: var(--color-bg, #fff); diff --git a/frontend/src/verfahrensablauf.tsx b/frontend/src/verfahrensablauf.tsx index fd30611..d526231 100644 --- a/frontend/src/verfahrensablauf.tsx +++ b/frontend/src/verfahrensablauf.tsx @@ -327,6 +327,31 @@ export function renderVerfahrensablauf(): string { Ergebnis + {/* m/paliad#149 Phase 2 P3 — three-way detail filter. + Controls how much of the procedural shape renders: + just mandatory; mandatory + selected (default); or + every option including unselected ones rendered + muted. State persists in localStorage under + verfahrensablauf:view_mode. The toggle drives a + client-side filter pre-render; the calc payload + stays the same, so flipping is instant. */} +
+ Anzeige: + + + +
+
Ansicht: