Compare commits
1 Commits
9aee9e4101
...
mai/darwin
| Author | SHA1 | Date | |
|---|---|---|---|
| aa435e5435 |
@@ -2,11 +2,11 @@
|
|||||||
// 3-step wizard: select proceeding -> enter date -> view timeline
|
// 3-step wizard: select proceeding -> enter date -> view timeline
|
||||||
//
|
//
|
||||||
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
// Rendering primitives (renderTimelineBody / renderColumnsBody /
|
||||||
// deadlineCardHtml / formatDate / partyBadge / court picker) live in
|
// deadlineCardHtml / formatDate / partyBadge / court picker / inline
|
||||||
// `./views/verfahrensablauf-core` and are shared with the
|
// date editor) live in `./views/verfahrensablauf-core` and are shared
|
||||||
// /tools/verfahrensablauf page (t-paliad-179 Slice 1). This module owns
|
// with /tools/verfahrensablauf. This module owns the Step1/2/3a
|
||||||
// the Step1/2/3a wizard, Pathway A/B, Akte save flow, anchor-override
|
// wizard, Pathway A/B, Akte save flow — none of which Verfahrensablauf
|
||||||
// click-to-edit — none of which Verfahrensablauf wants.
|
// wants.
|
||||||
|
|
||||||
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
import { initI18n, t, tDyn, getLang, onLangChange } from "./i18n";
|
||||||
import { initSidebar } from "./sidebar";
|
import { initSidebar } from "./sidebar";
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
priorityRendering,
|
priorityRendering,
|
||||||
renderColumnsBody,
|
renderColumnsBody,
|
||||||
renderTimelineBody,
|
renderTimelineBody,
|
||||||
|
wireDateEditClicks,
|
||||||
} from "./views/verfahrensablauf-core";
|
} from "./views/verfahrensablauf-core";
|
||||||
|
|
||||||
let lastResponse: DeadlineResponse | null = null;
|
let lastResponse: DeadlineResponse | null = null;
|
||||||
@@ -430,54 +431,21 @@ function renderProcedureResults(data: DeadlineResponse) {
|
|||||||
applyPendingFocus();
|
applyPendingFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
// openInlineDateEditor swaps the date span for a date input. On commit
|
// onDateEditCommit is the click-to-edit callback handed to the shared
|
||||||
// (blur or Enter), the override is recorded and the timeline re-fetched.
|
// wireDateEditClicks() helper: persist the per-rule override (empty value
|
||||||
// On Escape, the editor closes without changing anything. An empty
|
// clears it) then recompute so downstream rules re-anchor.
|
||||||
// commit clears the override (lets the user revert to the calculated
|
function onDateEditCommit(ruleCode: string, newValue: string) {
|
||||||
// date or to the IsCourtSet placeholder).
|
if (newValue === "") {
|
||||||
function openInlineDateEditor(span: HTMLElement) {
|
anchorOverrides.delete(ruleCode);
|
||||||
const ruleCode = span.dataset.ruleCode!;
|
} else {
|
||||||
const current = span.dataset.currentDate || anchorOverrides.get(ruleCode) || "";
|
anchorOverrides.set(ruleCode, newValue);
|
||||||
const editor = document.createElement("input");
|
}
|
||||||
editor.type = "date";
|
void calculate();
|
||||||
editor.className = "frist-date-edit-input";
|
|
||||||
editor.value = current;
|
|
||||||
|
|
||||||
const commit = (newValue: string) => {
|
|
||||||
if (newValue === "") {
|
|
||||||
anchorOverrides.delete(ruleCode);
|
|
||||||
} else {
|
|
||||||
anchorOverrides.set(ruleCode, newValue);
|
|
||||||
}
|
|
||||||
void calculate();
|
|
||||||
};
|
|
||||||
|
|
||||||
const cancel = () => {
|
|
||||||
editor.replaceWith(span);
|
|
||||||
};
|
|
||||||
|
|
||||||
editor.addEventListener("blur", () => {
|
|
||||||
if (editor.value !== current) commit(editor.value);
|
|
||||||
else cancel();
|
|
||||||
});
|
|
||||||
editor.addEventListener("keydown", (e) => {
|
|
||||||
const ke = e as KeyboardEvent;
|
|
||||||
if (ke.key === "Enter") {
|
|
||||||
e.preventDefault();
|
|
||||||
editor.blur();
|
|
||||||
} else if (ke.key === "Escape") {
|
|
||||||
e.preventDefault();
|
|
||||||
cancel();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
span.replaceWith(editor);
|
|
||||||
editor.focus();
|
|
||||||
if (editor.value) editor.select();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// deadlineCardHtml / renderTimelineBody / renderColumnsBody moved to
|
// deadlineCardHtml / renderTimelineBody / renderColumnsBody /
|
||||||
// ./views/verfahrensablauf-core (t-paliad-179 Slice 1).
|
// openInlineDateEditor / wireDateEditClicks moved to
|
||||||
|
// ./views/verfahrensablauf-core.
|
||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
selectedType = "";
|
selectedType = "";
|
||||||
@@ -648,21 +616,7 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
// rules re-anchor on the user's date. Delegated on the container so
|
// rules re-anchor on the user's date. Delegated on the container so
|
||||||
// it survives renderProcedureResults() innerHTML rewrites.
|
// it survives renderProcedureResults() innerHTML rewrites.
|
||||||
const timelineContainer = document.getElementById("timeline-container");
|
const timelineContainer = document.getElementById("timeline-container");
|
||||||
if (timelineContainer) {
|
if (timelineContainer) wireDateEditClicks(timelineContainer, onDateEditCommit);
|
||||||
timelineContainer.addEventListener("click", (e) => {
|
|
||||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
|
||||||
if (!target || !target.dataset.ruleCode) return;
|
|
||||||
openInlineDateEditor(target);
|
|
||||||
});
|
|
||||||
timelineContainer.addEventListener("keydown", (e) => {
|
|
||||||
const ke = e as KeyboardEvent;
|
|
||||||
if (ke.key !== "Enter" && ke.key !== " ") return;
|
|
||||||
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
|
||||||
if (!target || !target.dataset.ruleCode) return;
|
|
||||||
e.preventDefault();
|
|
||||||
openInlineDateEditor(target);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Reset button
|
// Reset button
|
||||||
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
document.getElementById("reset-btn")!.addEventListener("click", reset);
|
||||||
|
|||||||
@@ -17,11 +17,21 @@ import {
|
|||||||
populateCourtPicker,
|
populateCourtPicker,
|
||||||
renderColumnsBody,
|
renderColumnsBody,
|
||||||
renderTimelineBody,
|
renderTimelineBody,
|
||||||
|
wireDateEditClicks,
|
||||||
} from "./views/verfahrensablauf-core";
|
} from "./views/verfahrensablauf-core";
|
||||||
|
|
||||||
let selectedType = "";
|
let selectedType = "";
|
||||||
let lastResponse: DeadlineResponse | null = null;
|
let lastResponse: DeadlineResponse | null = null;
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// user's chosen date. Cleared whenever the trigger changes (proceeding,
|
||||||
|
// trigger date, flag toggle) so a fresh calc starts unanchored — same
|
||||||
|
// semantic as /tools/fristenrechner.
|
||||||
|
const anchorOverrides = new Map<string, string>();
|
||||||
|
function clearAnchorOverrides() { anchorOverrides.clear(); }
|
||||||
|
|
||||||
type ProcedureView = "timeline" | "columns";
|
type ProcedureView = "timeline" | "columns";
|
||||||
let procedureView: ProcedureView = "columns";
|
let procedureView: ProcedureView = "columns";
|
||||||
|
|
||||||
@@ -125,10 +135,14 @@ async function doCalc() {
|
|||||||
? courtPicker.value
|
? courtPicker.value
|
||||||
: "";
|
: "";
|
||||||
|
|
||||||
|
const overrides: Record<string, string> = {};
|
||||||
|
for (const [code, date] of anchorOverrides) overrides[code] = date;
|
||||||
|
|
||||||
const data = await calculateDeadlines({
|
const data = await calculateDeadlines({
|
||||||
proceedingType: selectedType,
|
proceedingType: selectedType,
|
||||||
triggerDate,
|
triggerDate,
|
||||||
flags: readFlags(),
|
flags: readFlags(),
|
||||||
|
anchorOverrides: overrides,
|
||||||
courtId,
|
courtId,
|
||||||
});
|
});
|
||||||
if (seq !== calcSeq) return;
|
if (seq !== calcSeq) return;
|
||||||
@@ -180,8 +194,8 @@ function renderResults(data: DeadlineResponse) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const bodyHtml = procedureView === "columns"
|
const bodyHtml = procedureView === "columns"
|
||||||
? renderColumnsBody(data, { showNotes })
|
? renderColumnsBody(data, { editable: true, showNotes })
|
||||||
: renderTimelineBody(data, { showParty: true, showNotes });
|
: renderTimelineBody(data, { showParty: true, editable: true, showNotes });
|
||||||
|
|
||||||
container.innerHTML = headerHtml + bodyHtml;
|
container.innerHTML = headerHtml + bodyHtml;
|
||||||
if (printBtn) printBtn.style.display = "block";
|
if (printBtn) printBtn.style.display = "block";
|
||||||
@@ -229,7 +243,12 @@ function syncInfAmendEnabled() {
|
|||||||
function selectProceeding(btn: HTMLButtonElement) {
|
function selectProceeding(btn: HTMLButtonElement) {
|
||||||
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
document.querySelectorAll(".proceeding-btn").forEach((b) => b.classList.remove("active"));
|
||||||
btn.classList.add("active");
|
btn.classList.add("active");
|
||||||
selectedType = btn.dataset.code || "";
|
const nextType = btn.dataset.code || "";
|
||||||
|
// Different proceeding tree → previously-set overrides reference
|
||||||
|
// rule codes that don't exist in the new tree. Clear before the
|
||||||
|
// next calc so the fresh proceeding starts unanchored.
|
||||||
|
if (selectedType !== nextType) clearAnchorOverrides();
|
||||||
|
selectedType = nextType;
|
||||||
|
|
||||||
// Trigger-event label fires from the calc response (root rule).
|
// Trigger-event label fires from the calc response (root rule).
|
||||||
// Until step 3 renders, fall back to an em-dash placeholder.
|
// Until step 3 renders, fall back to an em-dash placeholder.
|
||||||
@@ -312,6 +331,21 @@ document.addEventListener("DOMContentLoaded", () => {
|
|||||||
|
|
||||||
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
document.getElementById("fristen-print-btn")?.addEventListener("click", () => window.print());
|
||||||
|
|
||||||
|
// Click-to-edit on timeline / column date cells — same delegated
|
||||||
|
// pattern as /tools/fristenrechner. Survives renderResults()'s
|
||||||
|
// innerHTML rewrites because the listener lives on the container.
|
||||||
|
const timelineContainer = document.getElementById("timeline-container");
|
||||||
|
if (timelineContainer) {
|
||||||
|
wireDateEditClicks(timelineContainer, (ruleCode, newValue) => {
|
||||||
|
if (newValue === "") {
|
||||||
|
anchorOverrides.delete(ruleCode);
|
||||||
|
} else {
|
||||||
|
anchorOverrides.set(ruleCode, newValue);
|
||||||
|
}
|
||||||
|
scheduleCalc(0);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Notes toggle — restores last preference on load + re-renders when
|
// Notes toggle — restores last preference on load + re-renders when
|
||||||
// the user flips it. Lives in the same toggle bar as the view picker.
|
// the user flips it. Lives in the same toggle bar as the view picker.
|
||||||
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
const notesShowCb = document.getElementById("fristen-notes-show") as HTMLInputElement | null;
|
||||||
|
|||||||
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
67
frontend/src/client/views/verfahrensablauf-core.test.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
type CalculatedDeadline,
|
||||||
|
deadlineCardHtml,
|
||||||
|
} from "./verfahrensablauf-core";
|
||||||
|
|
||||||
|
// Regression tests for the editable→click-to-edit wiring on timeline date
|
||||||
|
// cells (m/paliad#59). When CardOpts.editable=true the card renderer must
|
||||||
|
// emit `class="… frist-date-edit"` with `data-rule-code` + `data-current-
|
||||||
|
// date` on the date span. Pages then attach a delegated click handler that
|
||||||
|
// resolves that selector to swap in an inline `<input type="date">`. If a
|
||||||
|
// future refactor drops the attrs, /tools/verfahrensablauf and
|
||||||
|
// /tools/fristenrechner both silently lose click-to-edit (no script error,
|
||||||
|
// nothing happens on click). These tests pin the contract.
|
||||||
|
//
|
||||||
|
// Fixture leaves ruleRef/legalSource* empty so deadlineCardHtml stays
|
||||||
|
// inside its non-DOM code paths (escHtml is DOM-backed and bun test runs
|
||||||
|
// in plain Node without jsdom).
|
||||||
|
|
||||||
|
const dl = (overrides: Partial<CalculatedDeadline> = {}): CalculatedDeadline => ({
|
||||||
|
code: "upc-rop-12",
|
||||||
|
name: "Klageerwiderung",
|
||||||
|
nameEN: "Statement of Defence",
|
||||||
|
party: "defendant",
|
||||||
|
priority: "mandatory",
|
||||||
|
ruleRef: "",
|
||||||
|
dueDate: "2026-07-15",
|
||||||
|
originalDate: "2026-07-15",
|
||||||
|
wasAdjusted: false,
|
||||||
|
isRootEvent: false,
|
||||||
|
isCourtSet: false,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deadlineCardHtml — editable=true emits click-to-edit attrs", () => {
|
||||||
|
test("date span carries frist-date-edit class + data-rule-code + data-current-date", () => {
|
||||||
|
const html = deadlineCardHtml(dl(), { showParty: true, editable: true });
|
||||||
|
expect(html).toContain('class="timeline-date frist-date-edit"');
|
||||||
|
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||||
|
expect(html).toContain('data-current-date="2026-07-15"');
|
||||||
|
expect(html).toContain('role="button"');
|
||||||
|
expect(html).toContain('tabindex="0"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("editable=false (default) emits the date span without click-to-edit attrs", () => {
|
||||||
|
const html = deadlineCardHtml(dl(), { showParty: true });
|
||||||
|
expect(html).toContain("timeline-date");
|
||||||
|
expect(html).not.toContain("data-rule-code=");
|
||||||
|
expect(html).not.toContain('role="button"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("root event suppresses editable even when editable=true (root has no override semantic)", () => {
|
||||||
|
const html = deadlineCardHtml(dl({ isRootEvent: true }), { showParty: true, editable: true });
|
||||||
|
expect(html).not.toContain("data-rule-code=");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("isCourtSet renders the court-set placeholder with click-to-edit so users can pin a real date", () => {
|
||||||
|
const html = deadlineCardHtml(dl({ isCourtSet: true }), { showParty: true, editable: true });
|
||||||
|
expect(html).toContain("timeline-court-set frist-date-edit");
|
||||||
|
expect(html).toContain('data-rule-code="upc-rop-12"');
|
||||||
|
});
|
||||||
|
|
||||||
|
test("empty rule code with editable=true still suppresses click-to-edit (no anchor target)", () => {
|
||||||
|
const html = deadlineCardHtml(dl({ code: "" }), { showParty: true, editable: true });
|
||||||
|
expect(html).not.toContain("data-rule-code=");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -299,6 +299,87 @@ export function deadlineCardHtml(dl: CalculatedDeadline, opts: CardOpts): string
|
|||||||
${notesBlock}`;
|
${notesBlock}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── inline date editor (click-to-edit per-rule due date) ────────────────
|
||||||
|
//
|
||||||
|
// The renderer emits `<span class="frist-date-edit" data-rule-code="…"
|
||||||
|
// data-current-date="YYYY-MM-DD" role="button" tabindex="0">…</span>` when
|
||||||
|
// CardOpts.editable is true. Pages call wireDateEditClicks() on their
|
||||||
|
// result container once, and the delegated click/keydown handlers swap a
|
||||||
|
// clicked span for a `<input type="date">` editor via openInlineDateEditor.
|
||||||
|
// The caller's onCommit callback receives (ruleCode, newValue) — an empty
|
||||||
|
// newValue means "revert" (clear the anchor override and let the calculator
|
||||||
|
// re-project). The actual recompute is the caller's job — they own the
|
||||||
|
// anchor-overrides map + the calc dispatch.
|
||||||
|
|
||||||
|
export function openInlineDateEditor(
|
||||||
|
span: HTMLElement,
|
||||||
|
onCommit: (ruleCode: string, newValue: string) => void,
|
||||||
|
): void {
|
||||||
|
const ruleCode = span.dataset.ruleCode || "";
|
||||||
|
if (!ruleCode) return;
|
||||||
|
const current = span.dataset.currentDate || "";
|
||||||
|
const editor = document.createElement("input");
|
||||||
|
editor.type = "date";
|
||||||
|
editor.className = "frist-date-edit-input";
|
||||||
|
editor.value = current;
|
||||||
|
|
||||||
|
let done = false;
|
||||||
|
const cancel = () => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
editor.replaceWith(span);
|
||||||
|
};
|
||||||
|
const commit = (newValue: string) => {
|
||||||
|
if (done) return;
|
||||||
|
done = true;
|
||||||
|
onCommit(ruleCode, newValue);
|
||||||
|
};
|
||||||
|
|
||||||
|
editor.addEventListener("blur", () => {
|
||||||
|
if (editor.value !== current) commit(editor.value);
|
||||||
|
else cancel();
|
||||||
|
});
|
||||||
|
editor.addEventListener("keydown", (e) => {
|
||||||
|
const ke = e as KeyboardEvent;
|
||||||
|
if (ke.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
editor.blur();
|
||||||
|
} else if (ke.key === "Escape") {
|
||||||
|
e.preventDefault();
|
||||||
|
cancel();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
span.replaceWith(editor);
|
||||||
|
editor.focus();
|
||||||
|
if (editor.value) editor.select();
|
||||||
|
}
|
||||||
|
|
||||||
|
// wireDateEditClicks attaches delegated click + keyboard handlers to the
|
||||||
|
// timeline result container so click-to-edit survives every innerHTML
|
||||||
|
// rewrite the page does on recalc. Idempotent — re-calling on the same
|
||||||
|
// container does nothing (the dataset flag short-circuits).
|
||||||
|
export function wireDateEditClicks(
|
||||||
|
container: HTMLElement,
|
||||||
|
onCommit: (ruleCode: string, newValue: string) => void,
|
||||||
|
): void {
|
||||||
|
if (container.dataset.dateEditWired === "1") return;
|
||||||
|
container.dataset.dateEditWired = "1";
|
||||||
|
container.addEventListener("click", (e) => {
|
||||||
|
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||||
|
if (!target || !target.dataset.ruleCode) return;
|
||||||
|
openInlineDateEditor(target, onCommit);
|
||||||
|
});
|
||||||
|
container.addEventListener("keydown", (e) => {
|
||||||
|
const ke = e as KeyboardEvent;
|
||||||
|
if (ke.key !== "Enter" && ke.key !== " ") return;
|
||||||
|
const target = (e.target as HTMLElement).closest<HTMLElement>(".frist-date-edit");
|
||||||
|
if (!target || !target.dataset.ruleCode) return;
|
||||||
|
e.preventDefault();
|
||||||
|
openInlineDateEditor(target, onCommit);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
export function renderTimelineBody(data: DeadlineResponse, opts: CardOpts = { showParty: true }): string {
|
||||||
let html = '<div class="timeline">';
|
let html = '<div class="timeline">';
|
||||||
for (const dl of data.deadlines) {
|
for (const dl of data.deadlines) {
|
||||||
|
|||||||
Reference in New Issue
Block a user