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.
436 lines
16 KiB
TypeScript
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();
|
|
}
|