feat(deadline-system): P3 — three-way detail filter on Verfahrensablauf (m/paliad#149)
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:<uuid>" entry into the project's scenario_flags via the P0 SSoT
PATCH endpoint, recording only deviations from the priority default:
recommended + entfernen → rule:<uuid> = false (explicit deselect)
optional + aufnehmen → rule:<uuid> = 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=<id> 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.
This commit is contained in:
@@ -1023,6 +1023,13 @@ const translations: Record<Lang, Record<string, string>> = {
|
||||
"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<Lang, Record<string, string>> = {
|
||||
"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.",
|
||||
|
||||
96
frontend/src/client/verfahrensablauf-detail-mode.test.ts
Normal file
96
frontend/src/client/verfahrensablauf-detail-mode.test.ts
Normal file
@@ -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> = {},
|
||||
): 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"]);
|
||||
});
|
||||
});
|
||||
125
frontend/src/client/verfahrensablauf-detail-mode.ts
Normal file
125
frontend/src/client/verfahrensablauf-detail-mode.ts
Normal file
@@ -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:<uuid>=false in
|
||||
// scenario_flags deselects
|
||||
// - priority='optional' → default-unselected; rule:<uuid>=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:<uuid>=false
|
||||
// priority='optional' → default false, flipped by rule:<uuid>=true
|
||||
// other (informational) → treated as optional
|
||||
export function isRuleSelected(
|
||||
dl: CalculatedDeadline,
|
||||
scenarioFlags: Record<string, boolean> | 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<string, boolean> | 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),
|
||||
);
|
||||
}
|
||||
@@ -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=<id>, 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<string, boolean> = {};
|
||||
|
||||
// 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) {
|
||||
? `<div class="timeline-context-note" role="note">${escHtml(noteText)}</div>`
|
||||
: "";
|
||||
|
||||
// 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<HTMLInputElement>("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=<id> 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:<uuid> (back to default-selected)
|
||||
// priority='recommended', aufnehmen=false → write rule:<uuid> = false (explicit deselect)
|
||||
// priority='optional', aufnehmen=true → write rule:<uuid> = true (explicit select)
|
||||
// priority='optional', aufnehmen=false → delete rule:<uuid> (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<string, string> = {
|
||||
"inf-amend-flag": SCENARIO_KEYS.infAmend,
|
||||
"rev-amend-flag": SCENARIO_KEYS.revAmend,
|
||||
"rev-cci-flag": SCENARIO_KEYS.revCci,
|
||||
const flagStorageKeys: Record<string, { storageKey: string; flagKey: string }> = {
|
||||
"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<HTMLButtonElement>(".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=<id>. 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
|
||||
|
||||
@@ -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:<uuid>` 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"></span>`
|
||||
: "";
|
||||
|
||||
return `<div class="timeline-item-header">
|
||||
// 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 = `<button type="button" class="timeline-selection-chip timeline-selection-chip--add"
|
||||
data-rule-id="${escAttr(dl.ruleId)}"
|
||||
data-priority="${escAttr(dl.priority)}"
|
||||
data-action="aufnehmen"
|
||||
title="${escAttr(t("deadlines.detail.optional_unselected_hint"))}">
|
||||
${escHtml(t("deadlines.detail.aufnehmen"))}
|
||||
</button>`;
|
||||
} 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 = `<button type="button" class="timeline-selection-chip timeline-selection-chip--remove"
|
||||
data-rule-id="${escAttr(dl.ruleId)}"
|
||||
data-priority="${escAttr(dl.priority)}"
|
||||
data-action="entfernen">
|
||||
${escHtml(t("deadlines.detail.entfernen"))}
|
||||
</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
return `<div class="timeline-item-header${detailUnselected ? " timeline-item-header--unselected" : ""}">
|
||||
<span class="timeline-name">
|
||||
${dlName}
|
||||
${stateIconsHtml}
|
||||
${chipHtml}
|
||||
</span>
|
||||
${dateStr}
|
||||
${selectionChip}
|
||||
${choicesHtml}
|
||||
</div>
|
||||
${meta}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -327,6 +327,31 @@ export function renderVerfahrensablauf(): string {
|
||||
<span data-i18n="deadlines.step3">Ergebnis</span>
|
||||
</h3>
|
||||
|
||||
{/* 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. */}
|
||||
<div className="verfahrensablauf-detail-toggle" id="verfahrensablauf-detail-toggle"
|
||||
role="radiogroup" aria-label="Detail">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.detail.label">Anzeige:</span>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="mandatory_only" />
|
||||
<span data-i18n="deadlines.detail.mandatory_only">Nur Pflicht</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="selected" checked />
|
||||
<span data-i18n="deadlines.detail.selected">Gewählt</span>
|
||||
</label>
|
||||
<label className="fristen-view-option">
|
||||
<input type="radio" name="detail-mode" value="all_options" />
|
||||
<span data-i18n="deadlines.detail.all_options">Alle Optionen</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="fristen-view-toggle" id="fristen-view-toggle" role="radiogroup" aria-label="Ansicht">
|
||||
<span className="fristen-view-label" data-i18n="deadlines.view.label">Ansicht:</span>
|
||||
<label className="fristen-view-option">
|
||||
|
||||
Reference in New Issue
Block a user