Files
paliad/frontend/src/client/components/approval-edit-modal.ts
mAi 13bb01ec96 fix(deadlines): event type renders before rule; bundle as Verfahrenshandlung
m/paliad#56 (t-paliad-221) — the deadlines editor read Title → Rule →
Event Type, which inverted the conceptual hierarchy (rule is the
citation under an event type, not its peer). Reorder all three
surfaces so the event-type parent comes first and the rule sits
directly beneath it.

- deadlines-new.tsx: pull the Regel select out of the Due-date row and
  drop it directly under the Typ picker; Due becomes its own row below.
- deadlines-detail.tsx: swap the Typ and Regel <dt>/<dd> rows in the
  detail list.
- approval-edit-modal.ts: remove rule_code from the generic
  DEADLINE_FIELDS list and render it inside a new
  "Verfahrenshandlung (Typ + Regel)" section beneath the event-type
  picker. The shared per-field renderer is extracted so the bundled
  section reuses the same dirty-tracking / pre_image-hint wiring.
- New i18n key approvals.suggest.section.event_type_rule (DE/EN).

Form-level inputs stay independent (some rules attach to multiple
event types and vice versa) — the change is purely about visual
grouping and reading order.
2026-05-20 14:43:42 +02:00

436 lines
16 KiB
TypeScript

// t-paliad-216 Slice B (initial) + t-paliad-217 Slice C (rewrite) —
// modal for the "Suggest changes" approval action.
//
// The approver authors a counter-proposal: edits any field on the
// underlying deadline / appointment AND/OR leaves a free-text note. On
// submit the caller POSTs to /api/approval-requests/{id}/suggest-changes,
// which closes the OLD row as `changes_requested` and spawns a NEW pending
// row authored by the approver carrying counter_payload as its payload.
//
// Scope (t-paliad-217 m's Q1 Reading A — 2026-05-20):
// - Every editable field on the entity is in the form, not just the
// date allowlist that triggers approval (t-paliad-138 §Q4). The
// backend's counter-allowlist (buildCounterSetClauses in
// approval_service.go) accepts the wider set:
// deadline: title, due_date, original_due_date, warning_date,
// description, notes, rule_code, event_type_ids
// appointment: title, start_at, end_at, description, location,
// appointment_type
// - Lifecycle restriction: update-only. shape-list.ts hides the
// suggest_changes button for create / complete / delete; this modal
// refuses to open on them as defence-in-depth.
//
// Built on the unified openModal() primitive (t-paliad-217 Slice A) —
// the primitive owns ESC, focus, backdrop, close button, browser
// back-button, mobile takeover. This module only constructs the body.
//
// API:
// const result = await openApprovalEditModal({
// entityType: "deadline",
// lifecycleEvent: "update",
// payload: {...}, // requester's proposed values (= current entity row)
// preImage: {...}, // pre-mutation values (for "vorher" diff hints)
// });
// if (result) {
// // result.counterPayload + result.note ready to POST
// } else {
// // user cancelled
// }
import { t } from "../i18n";
import {
attachEventTypePicker,
fetchEventTypes,
type PickerHandle,
} from "../event-types";
import { openModal } from "./modal";
export interface ApprovalEditModalArgs {
entityType: "deadline" | "appointment";
lifecycleEvent: string;
payload: Record<string, unknown> | null;
preImage: Record<string, unknown> | null;
// Optional context for the read-only context section. The caller can
// hydrate these from the row's API response (project_title,
// requester_name, requested_at) when available; the modal degrades
// gracefully when they're missing.
projectTitle?: string;
requesterName?: string;
requestedAt?: string;
}
export interface ApprovalEditModalResult {
counterPayload: Record<string, unknown>;
note: string;
}
// FieldSpec — one editable input row. The type determines the <input>
// (or <textarea>) shape; getValue / setValue normalise the form-element
// value to the server-friendly counter_payload shape.
interface FieldSpec {
key: string;
labelKey: string; // i18n key
inputType: "text" | "date" | "datetime-local" | "textarea";
// Required = title (NOT NULL on the column). Other fields are nullable;
// empty string clears (server's addText helper handles this).
required?: boolean;
}
// Deadline-only fields rendered in the editable section. `rule_code` and
// `event_type_ids` are intentionally NOT here — they're bundled into the
// dedicated "Verfahrenshandlung" section below the base fields so the
// event-type (parent concept) reads before the rule (m/paliad#56).
const DEADLINE_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "deadlines.field.title", inputType: "text", required: true },
{ key: "due_date", labelKey: "deadlines.field.due", inputType: "date" },
{ key: "original_due_date", labelKey: "approvals.suggest.field.original_due_date", inputType: "date" },
{ key: "warning_date", labelKey: "approvals.suggest.field.warning_date", inputType: "date" },
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
];
const APPOINTMENT_FIELDS: ReadonlyArray<FieldSpec> = [
{ key: "title", labelKey: "appointments.field.title", inputType: "text", required: true },
{ key: "start_at", labelKey: "appointments.field.start", inputType: "datetime-local" },
{ key: "end_at", labelKey: "appointments.field.end", inputType: "datetime-local" },
{ key: "location", labelKey: "appointments.field.location", inputType: "text" },
{ key: "appointment_type", labelKey: "appointments.field.type", inputType: "text" },
{ key: "description", labelKey: "appointments.field.description", inputType: "textarea" },
];
export async function openApprovalEditModal(
args: ApprovalEditModalArgs,
): Promise<ApprovalEditModalResult | null> {
if (args.lifecycleEvent !== "update") {
window.alert(t("approvals.suggest.unsupported_lifecycle"));
return null;
}
const fields = args.entityType === "deadline" ? DEADLINE_FIELDS : APPOINTMENT_FIELDS;
const original = (args.payload ?? {}) as Record<string, unknown>;
const preImage = (args.preImage ?? {}) as Record<string, unknown>;
// Build the body element imperatively so we can wire input handlers
// before openModal mounts the dialog.
const body = document.createElement("div");
body.className = "approval-suggest-body";
body.appendChild(renderIntro());
body.appendChild(renderFieldsSection(fields, original, preImage));
// event_type_ids picker (deadline-only) — async because the picker
// needs to fetch the firm's event-type catalogue. We attach a host
// element synchronously and populate it once the fetch returns.
let eventTypePicker: PickerHandle | null = null;
let eventTypePickerLoaded = false;
if (args.entityType === "deadline") {
const pickerSection = renderEventTypePickerSection(original, preImage);
body.appendChild(pickerSection.section);
void (async () => {
try {
await fetchEventTypes();
eventTypePicker = attachEventTypePicker(pickerSection.host, {
initialIDs: (original.event_type_ids as string[] | undefined) ?? [],
});
eventTypePickerLoaded = true;
} catch (_e) {
// Fail-soft: leave the section empty; counter still works
// without event_type_ids in the payload.
pickerSection.host.textContent = t("approvals.suggest.event_type_picker_unavailable");
}
})();
}
body.appendChild(renderContextSection(args, original));
const noteEl = renderNoteSection();
body.appendChild(noteEl.section);
// Read inputs back at submit time. The same list is what we listen to
// for the dirty-state gate.
const fieldInputs = Array.from(
body.querySelectorAll<HTMLInputElement | HTMLTextAreaElement>("[data-suggest-field]"),
);
return openModal<ApprovalEditModalResult>({
title: `${t("approvals.suggest.modal_title")}${t(("approvals.entity." + args.entityType) as never)}`,
body,
size: "lg",
primary: {
label: t("approvals.suggest.submit"),
handler: (close) => {
const result = buildResult(fieldInputs, noteEl.textarea, original, eventTypePicker, eventTypePickerLoaded);
if (!result.dirty && !result.note) {
// Server enforces too. Client-side guard avoids the 400 round-trip.
window.alert(t("approvals.suggest.submit_disabled_hint"));
return;
}
close({
counterPayload: result.counterPayload,
note: result.note,
});
},
},
secondary: { label: t("approvals.suggest.cancel") },
});
}
function renderIntro(): HTMLElement {
const p = document.createElement("p");
p.className = "approval-suggest-intro muted";
p.textContent = t("approvals.suggest.intro");
return p;
}
function renderFieldsSection(
fields: ReadonlyArray<FieldSpec>,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): HTMLElement {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.editable");
section.appendChild(h);
for (const f of fields) {
section.appendChild(renderSingleField(f, original, preImage));
}
return section;
}
// Verfahrenshandlung section — bundles the event-type picker and the
// rule_code input so the editor reads "what procedural step? which rule
// cites it?" instead of two disconnected fields with rule above type
// (m/paliad#56). The hint underneath spells out the parent/child
// relationship so first-time editors don't read them as peers.
function renderEventTypePickerSection(
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): { section: HTMLElement; host: HTMLElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--editable";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.event_type_rule");
section.appendChild(h);
const host = document.createElement("div");
host.className = "approval-suggest-event-type-picker";
section.appendChild(host);
// Rule citation — rendered as a sub-field directly beneath the picker so
// the visual hierarchy matches the conceptual one (rule is meta on the
// event type, not a peer).
const ruleField: FieldSpec = {
key: "rule_code",
labelKey: "approvals.suggest.field.rule_code",
inputType: "text",
};
section.appendChild(renderSingleField(ruleField, original, preImage));
return { section, host };
}
// renderSingleField builds one labelled input in the same shape as the
// fields-section loop. Extracted so the Verfahrenshandlung section can
// host the rule_code input next to the picker without duplicating the
// wiring (dirty-tracking, pre_image hint, label/for binding).
function renderSingleField(
f: FieldSpec,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): HTMLElement {
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-field";
const label = document.createElement("label");
label.textContent = t(f.labelKey as never);
wrap.appendChild(label);
const value = formatFieldForInput(original[f.key], f.inputType);
let input: HTMLInputElement | HTMLTextAreaElement;
if (f.inputType === "textarea") {
input = document.createElement("textarea");
input.rows = 3;
(input as HTMLTextAreaElement).value = value;
} else {
input = document.createElement("input");
(input as HTMLInputElement).type = f.inputType;
(input as HTMLInputElement).value = value;
}
input.dataset.suggestField = f.key;
input.dataset.suggestOriginal = value;
input.dataset.suggestInputType = f.inputType;
if (f.required) input.required = true;
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
const preVal = formatFieldForInput(preImage[f.key], f.inputType);
if (preVal && preVal !== value) {
const hint = document.createElement("span");
hint.className = "approval-suggest-prehint";
hint.textContent = `${t("approvals.diff.before")}: ${preVal}`;
wrap.appendChild(hint);
}
return wrap;
}
function renderContextSection(
args: ApprovalEditModalArgs,
original: Record<string, unknown>,
): HTMLElement {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--context";
const h = document.createElement("h3");
h.className = "approval-suggest-section-title";
h.textContent = t("approvals.suggest.section.context");
section.appendChild(h);
const rows: Array<[string, string]> = [];
if (args.projectTitle) {
rows.push([t("approvals.suggest.context.project"), args.projectTitle]);
}
if (args.requesterName) {
rows.push([t("approvals.suggest.context.requester"), args.requesterName]);
}
if (args.requestedAt) {
rows.push([t("approvals.suggest.context.requested_at"), formatDateForDisplay(args.requestedAt)]);
}
// Approval status — entity row's current approval_status (typically
// "pending" while the modal is open, but display the requester's
// perspective for completeness).
const approvalStatus = original.approval_status as string | undefined;
if (approvalStatus) {
rows.push([
t("approvals.suggest.context.approval_status"),
t(("approvals.status." + approvalStatus) as never) || approvalStatus,
]);
}
if (rows.length === 0) {
section.style.display = "none";
return section;
}
const dl = document.createElement("dl");
dl.className = "approval-suggest-context-grid";
for (const [label, value] of rows) {
const dt = document.createElement("dt");
dt.textContent = label;
const dd = document.createElement("dd");
dd.textContent = value;
dl.appendChild(dt);
dl.appendChild(dd);
}
section.appendChild(dl);
return section;
}
function renderNoteSection(): { section: HTMLElement; textarea: HTMLTextAreaElement } {
const section = document.createElement("section");
section.className = "approval-suggest-section approval-suggest-section--note";
const wrap = document.createElement("div");
wrap.className = "form-field approval-suggest-note";
const label = document.createElement("label");
label.textContent = t("approvals.suggest.note_label");
label.setAttribute("for", "suggest-note");
wrap.appendChild(label);
const textarea = document.createElement("textarea");
textarea.id = "suggest-note";
textarea.rows = 3;
textarea.placeholder = t("approvals.suggest.note_placeholder");
textarea.dataset.suggestNote = "true";
wrap.appendChild(textarea);
section.appendChild(wrap);
return { section, textarea };
}
interface BuildResult {
counterPayload: Record<string, unknown>;
note: string;
dirty: boolean;
}
function buildResult(
fieldInputs: ReadonlyArray<HTMLInputElement | HTMLTextAreaElement>,
noteEl: HTMLTextAreaElement,
original: Record<string, unknown>,
eventTypePicker: PickerHandle | null,
eventTypePickerLoaded: boolean,
): BuildResult {
const counterPayload: Record<string, unknown> = {};
let dirty = false;
for (const el of fieldInputs) {
const key = el.dataset.suggestField || "";
const orig = el.dataset.suggestOriginal || "";
const inputType = el.dataset.suggestInputType || "text";
if (el.value === orig) continue;
counterPayload[key] = formatFieldForServer(el.value, inputType);
dirty = true;
}
if (eventTypePicker && eventTypePickerLoaded) {
const currentIDs = eventTypePicker.getIDs().slice().sort();
const originalIDs = ((original.event_type_ids as string[] | undefined) ?? []).slice().sort();
if (currentIDs.length !== originalIDs.length
|| currentIDs.some((id, i) => id !== originalIDs[i])) {
counterPayload.event_type_ids = currentIDs;
dirty = true;
}
}
return {
counterPayload,
note: noteEl.value.trim(),
dirty,
};
}
// formatFieldForInput — convert a server-side payload value to the format
// the <input> wants. Dates round-trip as YYYY-MM-DD; datetime-local wants
// YYYY-MM-DDTHH:MM. Server returns ISO 8601 / RFC 3339 timestamps; we
// trim to the local-input shape. Text passes through verbatim.
function formatFieldForInput(v: unknown, inputType: string): string {
if (v == null) return "";
const s = String(v);
if (inputType === "date") {
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const m = s.match(/^(\d{4}-\d{2}-\d{2})/);
return m ? m[1] : s;
}
if (inputType === "datetime-local") {
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
return m ? `${m[1]}T${m[2]}` : s;
}
return s;
}
// formatFieldForServer — convert input value back to server-friendly
// shape. Empty string means "clear this nullable field"; the server's
// addText helper writes NULL for "". Required fields (title) reach the
// server's non-empty CHECK on the column, which surfaces as a 400.
function formatFieldForServer(value: string, inputType: string): unknown {
if (inputType === "date" || inputType === "datetime-local") {
return value || null;
}
return value;
}
function formatDateForDisplay(iso: string): string {
const d = Date.parse(iso);
if (isNaN(d)) return iso;
return new Date(d).toLocaleString();
}