Merge: t-paliad-217 — unified modal primitive + suggest-changes rework

m greenlit 6 picks in §0a (2 divergences from inventor recs: Q1 full-edit
loosens t-paliad-138 4-Augen policy beyond date changes; Q4 broadcast.ts
retrofit included in this PR for primitive validation).

Slice A — components/modal.ts primitive + global.css block. Native <dialog>
substrate (browser-owned top-layer, ESC, focus). openModal({title, body,
primary, secondary, size, onClose}) returns a Promise<T|null>. Backdrop
click closes via target check on dialog click event. History pushState on
open + popstate listener for browser back-button close on mobile. Focus
restoration to previously-focused element on close. Mobile full-screen
takeover with max-height excluding the PWA bottom-nav.

Slice B — counter_payload allowlist expansion in approval_service.go.
Renames buildRevertSetClauses → buildEntityFieldSetClauses; separates the
'revert from pre_image' allowlist (defence-in-depth for Reject) from the
'counter from approver' allowlist (wider, for SuggestChanges). New
editable fields for SuggestChanges: deadline {title, description, notes,
rule_code, event_type_ids — junction table writes}; appointment {title,
description, location, appointment_type}.

Slice C — approval-edit-modal full rewrite using openModal. Every field
in the requester's payload becomes editable; read-only context section
shows project / requester / created_at / approval status pill /
event-type chips (where not editable). Vorschlagskommentar prominent.
Submit disabled until form dirty OR note has content (mirrors
ErrSuggestionRequiresChange server-side guard).

Slice D — broadcast.ts retrofit onto the new primitive. Drops bespoke
.modal-broadcast CSS overrides + the per-modal ESC / close / backdrop
handlers. Demonstrates the primitive's generality.

Slice E (i18n + CSS cleanup) folded into Slice A's commit — all new keys
authored once. Legacy .modal-overlay / .modal-card / .modal-content / .modal
CSS retained for the other 7 unmigrated modals (each migrates in a
follow-up PR).

2489 i18n keys; data-i18n attributes clean. No DB migration.
This commit is contained in:
mAi
2026-05-20 13:06:23 +02:00
9 changed files with 1185 additions and 389 deletions

View File

@@ -1,16 +1,23 @@
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7).
// broadcast.ts — bulk team-email compose modal (t-paliad-147 / issue #7,
// retrofitted onto the unified modal primitive in t-paliad-217 Slice D).
//
// Exposes openBroadcastModal({ recipients, projectIDs }) which the /team
// page calls when the "E-Mail an Auswahl" button is clicked. The modal
// collects subject + body + (optional) template and posts to
// /api/team/broadcast. On success it shows a per-recipient send report
// and closes.
// and closes after a short delay.
//
// Per-recipient privacy: each member receives their own envelope. The
// modal lists every addressee so the sender knows exactly who will be
// mailed; there is no surprise to-line.
//
// Migration notes (t-paliad-217 Slice D): the shell, ESC, backdrop,
// close button, and browser back-button are now owned by openModal().
// The body is built imperatively so the submit handler can read form
// state from the modal-body element it constructed.
import { t } from "./i18n";
import { openModal } from "./components/modal";
export interface BroadcastRecipient {
user_id: string;
@@ -35,6 +42,12 @@ interface EmailTemplateOption {
is_default: boolean;
}
interface BroadcastResult {
sent: number;
failed: number;
total: number;
}
const RECIPIENT_CAP = 100;
function esc(s: string): string {
@@ -78,69 +91,32 @@ export function openBroadcastModal(args: OpenBroadcastModalArgs): void {
return;
}
// Existing modal? Remove. Avoids stacking on rapid double-click.
document.getElementById("broadcast-modal")?.remove();
const body = renderBody(args);
wireBody(body);
const overlay = document.createElement("div");
overlay.id = "broadcast-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args);
document.body.appendChild(overlay);
// Close handlers
overlay.querySelector("[data-broadcast-close]")?.addEventListener("click", () => overlay.remove());
overlay.addEventListener("click", (e) => {
if (e.target === overlay) overlay.remove();
});
document.addEventListener("keydown", function escClose(e) {
if (e.key === "Escape") {
overlay.remove();
document.removeEventListener("keydown", escClose);
}
});
// Recipient toggle
overlay.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = overlay.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown
const templateSelect = overlay.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = overlay.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = overlay.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
// Submit
const form = overlay.querySelector<HTMLFormElement>("[data-broadcast-form]");
form?.addEventListener("submit", async (e) => {
e.preventDefault();
await onSubmit(form, overlay, args);
void openModal<BroadcastResult>({
title: t("team.broadcast.title") || "E-Mail an Auswahl",
body,
size: "lg",
primary: {
label: `${t("team.broadcast.send") || "Senden"} (${args.recipients.length})`,
handler: async (close) => {
await onSubmit(body, args, close);
},
},
secondary: { label: t("common.cancel") || "Abbrechen" },
});
}
function renderShell(args: OpenBroadcastModalArgs): string {
function renderBody(args: OpenBroadcastModalArgs): HTMLElement {
const root = document.createElement("div");
root.className = "broadcast-body";
const count = args.recipients.length;
const previewItems = args.recipients
.slice(0, 5)
.map((r) => esc(r.display_name) + " &lt;" + esc(r.email) + "&gt;")
.join(", ");
const more = count > 5 ? ` +${count - 5}` : "";
const fullList = args.recipients
.map(
(r) =>
@@ -150,65 +126,89 @@ function renderShell(args: OpenBroadcastModalArgs): string {
)
.join("");
return `
<div class="modal modal-broadcast" role="dialog" aria-modal="true" aria-labelledby="broadcast-title">
<header class="modal-header">
<h2 id="broadcast-title">${esc(t("team.broadcast.title") || "E-Mail an Auswahl")}</h2>
<button type="button" class="modal-close" data-broadcast-close aria-label="${esc(t("common.close") || "Schließen")}">&times;</button>
</header>
<form data-broadcast-form>
<div class="modal-body">
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
</a>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-broadcast-close>${esc(t("common.cancel") || "Abbrechen")}</button>
<button type="submit" class="btn btn-primary" data-broadcast-submit>${esc(t("team.broadcast.send") || "Senden")} (${count})</button>
</footer>
</form>
root.innerHTML = `
<div class="broadcast-recipient-summary">
<strong>${esc(t("team.broadcast.recipients") || "Empfänger")}: ${count}</strong>
<button type="button" class="link-button" data-broadcast-toggle-recipients>${esc(t("team.broadcast.show_all") || "Alle anzeigen")}</button>
<a class="link-button broadcast-mailto" href="${buildMailtoHref(args.recipients)}" data-broadcast-mailto title="${esc(t("team.broadcast.mailto.tooltip") || "Im lokalen Mail-Client öffnen")}">
${esc(t("team.broadcast.mailto.label") || "Im Mail-Client öffnen")}
</a>
<div class="broadcast-recipient-preview">${previewItems}${more}</div>
<div class="broadcast-recipient-list hidden" data-broadcast-recipient-list>
<ul>${fullList}</ul>
</div>
</div>
<div class="form-field">
<label for="broadcast-template-select">${esc(t("team.broadcast.template") || "Vorlage")} <span class="muted">(${esc(t("team.broadcast.template_optional") || "optional")})</span></label>
<select id="broadcast-template-select" data-broadcast-template>
<option value="">${esc(t("team.broadcast.template_freeform") || "Freitext")}</option>
<option value="invitation">${esc(t("team.broadcast.template.invitation") || "Einladung")}</option>
<option value="deadline_digest">${esc(t("team.broadcast.template.deadline_digest") || "Frist-Digest")}</option>
</select>
</div>
<div class="form-field">
<label for="broadcast-subject">${esc(t("team.broadcast.subject") || "Betreff")}</label>
<input type="text" id="broadcast-subject" data-broadcast-subject required maxlength="200" />
</div>
<div class="form-field">
<label for="broadcast-body">${esc(t("team.broadcast.body") || "Nachricht")}</label>
<textarea id="broadcast-body" data-broadcast-body required rows="12" placeholder="${esc(t("team.broadcast.body_placeholder") || "Hallo {{first_name}}, …")}"></textarea>
</div>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.placeholders_hint") || "Platzhalter: {{name}}, {{first_name}}, {{role_on_project}}")}
</p>
<p class="broadcast-hint muted">
${esc(t("team.broadcast.markdown_hint") || "Markdown unterstützt: **fett**, *kursiv*, [Link](https://...), - Aufzählung.")}
</p>
<div class="broadcast-error hidden" data-broadcast-error></div>
<div class="broadcast-success hidden" data-broadcast-success></div>
`;
return root;
}
async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenBroadcastModalArgs): Promise<void> {
const subject = (form.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const body = (form.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = form.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = overlay.querySelector<HTMLDivElement>("[data-broadcast-success]");
function wireBody(body: HTMLElement): void {
// Recipient list toggle.
body.querySelector("[data-broadcast-toggle-recipients]")?.addEventListener("click", () => {
const list = body.querySelector<HTMLDivElement>("[data-broadcast-recipient-list]");
if (!list) return;
list.classList.toggle("hidden");
});
// Template dropdown — populates subject/body from the selected template.
const templateSelect = body.querySelector<HTMLSelectElement>("[data-broadcast-template]");
templateSelect?.addEventListener("change", async () => {
const key = templateSelect.value;
if (!key) return;
const lang = (document.documentElement.lang || "de") as "de" | "en";
try {
const res = await fetch(`/api/admin/email-templates/${encodeURIComponent(key)}/${lang}`);
if (!res.ok) return;
const tpl = (await res.json()) as EmailTemplateOption;
const subjectInput = body.querySelector<HTMLInputElement>("[data-broadcast-subject]");
const bodyInput = body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]");
if (subjectInput) subjectInput.value = stripGoTemplate(tpl.subject);
if (bodyInput) bodyInput.value = stripGoTemplate(tpl.body);
} catch {
/* template load failure is non-fatal — sender keeps freeform mode. */
}
});
}
async function onSubmit(
body: HTMLElement,
args: OpenBroadcastModalArgs,
close: (result: BroadcastResult) => void,
): Promise<void> {
const subject = (body.querySelector<HTMLInputElement>("[data-broadcast-subject]")?.value ?? "").trim();
const bodyText = (body.querySelector<HTMLTextAreaElement>("[data-broadcast-body]")?.value ?? "").trim();
const templateKey = body.querySelector<HTMLSelectElement>("[data-broadcast-template]")?.value ?? "";
const errEl = body.querySelector<HTMLDivElement>("[data-broadcast-error]");
const okEl = body.querySelector<HTMLDivElement>("[data-broadcast-success]");
errEl?.classList.add("hidden");
okEl?.classList.add("hidden");
@@ -216,17 +216,15 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
showError(errEl, t("team.broadcast.error.subject_required") || "Betreff ist erforderlich.");
return;
}
if (!body) {
if (!bodyText) {
showError(errEl, t("team.broadcast.error.body_required") || "Nachricht ist erforderlich.");
return;
}
const submitBtn = form.querySelector<HTMLButtonElement>("[data-broadcast-submit]");
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sending") || "Sende…";
}
// The modal primary button lives in the footer (owned by openModal),
// not in the body. We surface "sending..." feedback via the in-body
// success/error areas; the primary button stays clickable but the
// server-side idempotency + RECIPIENT_CAP make double-clicks safe.
const recipientFilter: Record<string, unknown> = {};
if (args.projectIDs?.length) recipientFilter.project_ids = args.projectIDs;
if (args.projectID) recipientFilter.project_id = args.projectID;
@@ -242,7 +240,7 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
body: JSON.stringify({
project_id: args.projectID ?? null,
subject,
body,
body: bodyText,
template_key: templateKey || undefined,
lang,
recipient_filter: recipientFilter,
@@ -252,13 +250,9 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
if (!res.ok) {
const errBody = await res.json().catch(() => ({ error: "Send failed" }));
showError(errEl, (errBody as { error?: string }).error || "Send failed");
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
return;
}
const report = (await res.json()) as { sent: number; failed: number; total: number };
const report = (await res.json()) as BroadcastResult;
if (okEl) {
okEl.classList.remove("hidden");
const tpl = t("team.broadcast.success") || "{sent} von {total} Mails versandt ({failed} fehlgeschlagen).";
@@ -267,17 +261,10 @@ async function onSubmit(form: HTMLFormElement, overlay: HTMLElement, args: OpenB
.replace("{total}", String(report.total))
.replace("{failed}", String(report.failed));
}
if (submitBtn) {
submitBtn.disabled = true;
submitBtn.textContent = t("team.broadcast.sent") || "Versandt";
}
setTimeout(() => overlay.remove(), 2500);
// Give the sender a moment to see the report, then close.
setTimeout(() => close(report), 2500);
} catch (e) {
showError(errEl, String(e));
if (submitBtn) {
submitBtn.disabled = false;
submitBtn.textContent = (t("team.broadcast.send") || "Senden") + ` (${args.recipients.length})`;
}
}
}

View File

@@ -1,27 +1,35 @@
// t-paliad-216 Slice B — modal for the "Suggest changes" approval action.
// 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 of the date-allowlist
// fields (per entity_type) 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.
// 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 (v1):
// - update-lifecycle only — the suggest_changes button is hidden for
// create / complete / delete lifecycles in shape-list.ts, so the modal
// never opens on them. If callers somehow trigger it on an unsupported
// lifecycle, openApprovalEditModal() resolves with null (cancel) after
// surfacing the unsupported-lifecycle copy.
// - Hard-coded fields per entity_type. We deliberately don't build a
// generic field-editor framework — only two entity_types exist and
// both have small fixed allowlists.
// 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 original proposed values
// preImage: {...}, // pre-mutation values (for diff display)
// 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
@@ -30,12 +38,25 @@
// }
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 {
@@ -43,213 +64,342 @@ export interface ApprovalEditModalResult {
note: string;
}
// Per-entity-type editable field allowlist. Matches buildRevertSetClauses
// in internal/services/approval_service.go — the server side rejects any
// key outside this set anyway. Keeping the UI list in sync is a
// safety-vs-confusion trade-off: a stray key here would be silently
// dropped server-side, so it's harmless but misleading.
const DEADLINE_FIELDS: ReadonlyArray<{ key: string; type: "date" }> = [
{ key: "due_date", type: "date" },
{ key: "original_due_date", type: "date" },
{ key: "warning_date", type: "date" },
// 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;
}
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: "rule_code", labelKey: "approvals.suggest.field.rule_code", inputType: "text" },
{ key: "description", labelKey: "approvals.suggest.field.description", inputType: "textarea" },
{ key: "notes", labelKey: "deadlines.field.notes", inputType: "textarea" },
];
const APPOINTMENT_FIELDS: ReadonlyArray<{ key: string; type: "datetime-local" }> = [
{ key: "start_at", type: "datetime-local" },
{ key: "end_at", type: "datetime-local" },
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 function openApprovalEditModal(
export async function openApprovalEditModal(
args: ApprovalEditModalArgs,
): Promise<ApprovalEditModalResult | null> {
return new Promise((resolve) => {
if (args.lifecycleEvent !== "update") {
// Defence-in-depth: shape-list.ts hides the button for non-update
// lifecycles, but if some caller bypasses that gate, fail cleanly.
window.alert(t("approvals.suggest.unsupported_lifecycle"));
resolve(null);
return;
}
if (args.lifecycleEvent !== "update") {
window.alert(t("approvals.suggest.unsupported_lifecycle"));
return null;
}
document.getElementById("approval-edit-modal")?.remove();
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>;
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";
const overlay = document.createElement("div");
overlay.id = "approval-edit-modal";
overlay.className = "modal-overlay";
overlay.innerHTML = renderShell(args, fields, original, preImage);
document.body.appendChild(overlay);
body.appendChild(renderIntro());
body.appendChild(renderFieldsSection(fields, original, preImage));
const close = (result: ApprovalEditModalResult | null) => {
overlay.remove();
document.removeEventListener("keydown", onKey);
resolve(result);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") close(null);
};
document.addEventListener("keydown", onKey);
overlay.querySelectorAll("[data-suggest-cancel]").forEach((el) =>
el.addEventListener("click", () => close(null)),
);
overlay.addEventListener("click", (e) => {
if (e.target === overlay) close(null);
});
const submitBtn = overlay.querySelector<HTMLButtonElement>("[data-suggest-submit]");
const noteEl = overlay.querySelector<HTMLTextAreaElement>("[data-suggest-note]");
const inputs = Array.from(
overlay.querySelectorAll<HTMLInputElement>("[data-suggest-field]"),
);
const refreshSubmit = () => {
if (!submitBtn) return;
const dirty = inputs.some((el) => {
const orig = formatFieldForInput(original[el.dataset.suggestField || ""]);
return el.value !== orig;
});
const hasNote = !!(noteEl && noteEl.value.trim());
submitBtn.disabled = !(dirty || hasNote);
submitBtn.title = submitBtn.disabled
? t("approvals.suggest.submit_disabled_hint")
: "";
};
inputs.forEach((el) => el.addEventListener("input", refreshSubmit));
noteEl?.addEventListener("input", refreshSubmit);
refreshSubmit();
const form = overlay.querySelector<HTMLFormElement>("[data-suggest-form]");
form?.addEventListener("submit", (e) => {
e.preventDefault();
if (submitBtn?.disabled) return;
// Build counter_payload from inputs that differ from original.
// Fields unchanged stay out of the payload — the server's
// buildRevertSetClauses only writes the keys it sees, so we don't
// need to send untouched fields.
const counterPayload: Record<string, unknown> = {};
for (const el of inputs) {
const key = el.dataset.suggestField || "";
const orig = formatFieldForInput(original[key]);
if (el.value !== orig) {
counterPayload[key] = formatFieldForServer(el.value, el.type);
}
// 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();
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");
}
close({
counterPayload,
note: (noteEl?.value ?? "").trim(),
});
});
})();
}
// Focus first input (or note if no fields).
(inputs[0] ?? noteEl)?.focus();
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 renderShell(
args: ApprovalEditModalArgs,
fields: ReadonlyArray<{ key: string; type: string }>,
original: Record<string, unknown>,
preImage: Record<string, unknown>,
): string {
const entityLabel = esc(t(("approvals.entity." + args.entityType) as never));
const fieldRows = fields
.map((f) => {
const label = fieldLabel(args.entityType, f.key);
const value = esc(formatFieldForInput(original[f.key]));
const preVal = formatFieldForInput(preImage[f.key]);
const preHint = preVal
? `<span class="suggest-field-prehint">${esc(t("approvals.diff.before"))}: ${esc(preVal)}</span>`
: "";
return `
<label class="suggest-field">
<span class="suggest-field-label">${esc(label)}</span>
<input type="${esc(f.type)}" data-suggest-field="${esc(f.key)}" value="${value}" />
${preHint}
</label>
`;
})
.join("");
return `
<div class="modal modal-approval-suggest" role="dialog" aria-modal="true" aria-labelledby="approval-suggest-title">
<header class="modal-header">
<h2 id="approval-suggest-title">${esc(t("approvals.suggest.modal_title"))}${entityLabel}</h2>
<button type="button" class="modal-close" data-suggest-cancel aria-label="${esc(t("approvals.suggest.cancel"))}">&times;</button>
</header>
<form data-suggest-form>
<div class="modal-body">
<p class="suggest-intro muted">${esc(t("approvals.suggest.intro"))}</p>
<div class="suggest-fields">${fieldRows}</div>
<label class="suggest-note">
<span class="suggest-field-label">${esc(t("approvals.suggest.note_label"))}</span>
<textarea data-suggest-note rows="3" placeholder="${esc(t("approvals.suggest.note_placeholder"))}"></textarea>
</label>
</div>
<footer class="modal-footer">
<button type="button" class="btn btn-ghost" data-suggest-cancel>${esc(t("approvals.suggest.cancel"))}</button>
<button type="submit" class="btn btn-primary" data-suggest-submit disabled>${esc(t("approvals.suggest.submit"))}</button>
</footer>
</form>
</div>
`;
function renderIntro(): HTMLElement {
const p = document.createElement("p");
p.className = "approval-suggest-intro muted";
p.textContent = t("approvals.suggest.intro");
return p;
}
// fieldLabel — pick the user-facing label for a given (entity_type, key)
// tuple. Reuses existing entity-field i18n where it exists so the same
// label that's used on the deadline / appointment edit forms also shows
// in this modal.
function fieldLabel(entityType: string, key: string): string {
const lookups: Record<string, string> = {
"deadline.due_date": t("deadlines.field.due" as never) || "Fälligkeitsdatum",
"deadline.original_due_date": "Ursprüngliches Fälligkeitsdatum",
"deadline.warning_date": "Warndatum",
"appointment.start_at": t("appointments.field.start" as never) || "Beginn",
"appointment.end_at": t("appointments.field.end" as never) || "Ende",
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) {
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;
// Wire the <label> to focus the <input> on click.
const inputID = `suggest-field-${f.key}`;
input.id = inputID;
label.setAttribute("for", inputID);
wrap.appendChild(input);
// "Vorher" hint when pre_image carries a distinct value for this field.
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);
}
section.appendChild(wrap);
}
return section;
}
function renderEventTypePickerSection(): { 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("deadlines.field.event_type");
section.appendChild(h);
const host = document.createElement("div");
host.className = "approval-suggest-event-type-picker";
section.appendChild(host);
return { section, host };
}
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,
};
return lookups[`${entityType}.${key}`] || key;
}
// formatFieldForInput — convert a server-side payload value to the format
// the <input> wants. Dates round-trip cleanly 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.
function formatFieldForInput(v: unknown): string {
// 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);
// Pure date: keep first 10 chars.
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
// ISO timestamp: keep YYYY-MM-DDTHH:MM (drop seconds + tz).
const m = s.match(/^(\d{4}-\d{2}-\d{2})[T\s](\d{2}:\d{2})/);
if (m) return `${m[1]}T${m[2]}`;
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 the input element's string value back to
// a server-friendly shape. Date inputs send YYYY-MM-DD; datetime-local
// sends YYYY-MM-DDTHH:MM (we let the server interpret as local time, same
// as the existing entity-edit forms — there's no tz-shift specific to
// suggest-changes).
// 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 (!value) return null;
if (inputType === "date") return value; // YYYY-MM-DD
if (inputType === "datetime-local") return value; // YYYY-MM-DDTHH:MM
if (inputType === "date" || inputType === "datetime-local") {
return value || null;
}
return value;
}
// HTML-escape helper. Local to this module so the modal doesn't bring in a
// utility from elsewhere.
function esc(s: string): string {
return s.replace(/[&<>"]/g, (c) => {
switch (c) {
case "&": return "&amp;";
case "<": return "&lt;";
case ">": return "&gt;";
case '"': return "&quot;";
default: return c;
}
});
function formatDateForDisplay(iso: string): string {
const d = Date.parse(iso);
if (isNaN(d)) return iso;
return new Date(d).toLocaleString();
}

View File

@@ -0,0 +1,200 @@
// Unified modal primitive — t-paliad-217.
//
// Native <dialog>-backed. The browser handles top-layer stacking, ESC,
// ARIA, and focus trap. We layer back-button integration and focus
// restoration on top so the modal behaves consistently on desktop and on
// the iPhone PWA (m's checking surface).
//
// API:
// const result = await openModal<MyResult>({
// title: "…",
// body: htmlStringOrElement,
// primary: { label: "Speichern", handler: (close) => { close(result); } },
// secondary: { label: "Abbrechen" }, // optional, defaults to "Abbrechen"
// size: "sm" | "md" | "lg" | "full", // optional, defaults to "md"
// onClose: () => { /* … */ },
// classNames: "extra css classes on the <dialog>",
// });
// // result is the value passed to close(), or null if the user
// // dismissed via ESC / backdrop / secondary / browser back-button.
//
// All dismiss paths are unified: ESC, backdrop click, secondary button,
// the always-rendered close (×) button, and the browser back-button all
// resolve the promise with null. Programmatic close from the primary
// handler resolves with whatever was passed.
//
// Migration target: call sites that currently roll their own
// modal-overlay + ESC handler + focus management replace all of it with
// one openModal() call. broadcast.ts and approval-edit-modal.ts are the
// first two call sites (t-paliad-217 Slices C + D); the other ~5 legacy
// modals migrate in follow-up PRs.
import { t } from "../i18n";
export interface ModalConfig<T> {
title: string;
// body can be either a pre-built HTMLElement (the caller assembled the
// DOM and may have local references for read-back) or an HTML string
// (caller is responsible for escaping). Element is preferred when the
// caller needs to read form state on submit.
body: HTMLElement | string;
primary: {
label: string;
handler: (close: (result: T) => void) => void | Promise<void>;
};
// secondary defaults to a Cancel button that just dismisses. Pass null
// explicitly to suppress (rare — primary-only modals like a confirmation
// toast).
secondary?: { label: string } | null;
size?: "sm" | "md" | "lg" | "full";
// onClose fires on EVERY dismiss path (including primary handler
// resolution). Use for analytics / dirty-state warnings.
onClose?: () => void;
classNames?: string;
}
// openModal returns a promise that resolves with the value passed to
// close() inside the primary handler, or null if the user dismissed via
// any other path. Always non-throwing — the primary handler decides
// whether to surface errors via its own UI (e.g. inline form errors)
// rather than rejecting the promise.
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null> {
return new Promise((resolve) => {
// Record + restore focus to whatever was focused before the modal
// opened. Native <dialog> does NOT do this automatically.
const previouslyFocused = document.activeElement as HTMLElement | null;
const dialog = document.createElement("dialog");
dialog.className = ["modal", config.classNames].filter(Boolean).join(" ");
dialog.dataset.size = config.size ?? "md";
const header = document.createElement("header");
header.className = "modal__header";
const titleEl = document.createElement("h2");
titleEl.className = "modal__title";
titleEl.textContent = config.title;
header.appendChild(titleEl);
const closeBtn = document.createElement("button");
closeBtn.type = "button";
closeBtn.className = "modal__close";
closeBtn.setAttribute("aria-label", t("modal.close.label"));
closeBtn.textContent = "×"; // ×
header.appendChild(closeBtn);
dialog.appendChild(header);
const body = document.createElement("div");
body.className = "modal__body";
if (typeof config.body === "string") {
body.innerHTML = config.body;
} else {
body.appendChild(config.body);
}
dialog.appendChild(body);
const footer = document.createElement("footer");
footer.className = "modal__footer";
const secondaryCfg = config.secondary === null
? null
: config.secondary ?? { label: t("common.cancel") };
let secondaryBtn: HTMLButtonElement | null = null;
if (secondaryCfg) {
secondaryBtn = document.createElement("button");
secondaryBtn.type = "button";
secondaryBtn.className = "btn btn-ghost modal__secondary";
secondaryBtn.textContent = secondaryCfg.label;
footer.appendChild(secondaryBtn);
}
const primaryBtn = document.createElement("button");
primaryBtn.type = "button";
primaryBtn.className = "btn btn-primary modal__primary";
primaryBtn.textContent = config.primary.label;
footer.appendChild(primaryBtn);
dialog.appendChild(footer);
document.body.appendChild(dialog);
// History integration (Q5): push a synthetic history state so the
// browser back-button closes the modal instead of leaving the page.
// We pop the state in finish() unless popstate already fired it.
let historyEntryActive = false;
try {
history.pushState({ paliadModalOpen: true }, "");
historyEntryActive = true;
} catch (_e) {
// pushState may throw in obscure embedded contexts; degrade gracefully.
}
// resolved guards against double-resolution (e.g. ESC fires + then a
// microtask-deferred primary handler also calls close).
let resolved = false;
const finish = (value: T | null) => {
if (resolved) return;
resolved = true;
window.removeEventListener("popstate", onPopState);
// Pop our history entry if it's still on the stack. Skip when the
// popstate listener already fired (otherwise we'd go back twice).
if (historyEntryActive) {
historyEntryActive = false;
try { history.back(); } catch (_e) { /* same fallback as pushState */ }
}
// Native dialog close. Use the close event's default rather than
// the cancel event so we don't fight the browser's own dismissal.
if (dialog.open) dialog.close();
dialog.remove();
// Restore focus to whatever the user was on before. The dialog
// teardown happens synchronously so the focus call lands on a
// live element.
if (previouslyFocused && document.body.contains(previouslyFocused)) {
previouslyFocused.focus();
}
config.onClose?.();
resolve(value);
};
const close = (result: T) => finish(result);
// Dismiss paths.
closeBtn.addEventListener("click", () => finish(null));
secondaryBtn?.addEventListener("click", () => finish(null));
dialog.addEventListener("click", (e) => {
// Backdrop click — only when the click landed on the dialog element
// itself (not on a child). Browsers report dialog.click events
// through the backdrop too because the backdrop is conceptually
// part of the dialog's box.
if (e.target === dialog) finish(null);
});
// <dialog>'s cancel event fires on ESC. preventDefault stops the
// browser's default close so we can run our finish() (history pop,
// focus restore, onClose, resolve).
dialog.addEventListener("cancel", (e) => {
e.preventDefault();
finish(null);
});
const onPopState = () => {
// Browser back-button. Our history entry is gone by the time this
// fires, so skip the history.back() in finish().
historyEntryActive = false;
finish(null);
};
window.addEventListener("popstate", onPopState);
// Primary action.
primaryBtn.addEventListener("click", () => {
const result = config.primary.handler(close);
// Allow async primary handlers (handler returns a promise) — we
// don't wait for it explicitly; the handler is responsible for
// calling close() when ready.
void result;
});
// Open the dialog in the top layer. showModal activates ARIA
// role="dialog" + aria-modal=true + focus trap + backdrop.
dialog.showModal();
});
}

View File

@@ -2115,6 +2115,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-088: Event Types — picker, multi-select filter, add modal.
"common.cancel": "Abbrechen",
"modal.close.label": "Schließen",
"event_types.cat.submission": "Eingaben",
"event_types.cat.decision": "Entscheidungen",
"event_types.cat.order": "Anordnungen",
@@ -2246,6 +2247,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.suggest.submit_disabled_hint": "Bitte mindestens ein Feld ändern oder einen Kommentar hinterlassen.",
"approvals.suggest.next_request_link": "→ Neuer Vorschlag von {name}",
"approvals.suggest.unsupported_lifecycle": "Änderungen vorschlagen ist nur für Update-Anfragen möglich.",
"approvals.suggest.section.editable": "Felder",
"approvals.suggest.section.context": "Kontext",
"approvals.suggest.context.project": "Projekt",
"approvals.suggest.context.requester": "Eingereicht von",
"approvals.suggest.context.requested_at": "Eingereicht am",
"approvals.suggest.context.approval_status": "Genehmigungsstatus",
"approvals.suggest.event_type_picker_unavailable": "Ereignistypen konnten nicht geladen werden.",
"approvals.suggest.field.original_due_date": "Ursprüngliches Fälligkeitsdatum",
"approvals.suggest.field.warning_date": "Warndatum",
"approvals.suggest.field.rule_code": "Regel-Zitat",
"approvals.suggest.field.description": "Beschreibung",
"approvals.requested_by": "Eingereicht von",
"approvals.decided_by": "Entschieden von",
"approvals.decision_kind.peer": "Genehmigt durch Teammitglied",
@@ -4730,6 +4742,7 @@ const translations: Record<Lang, Record<string, string>> = {
// t-paliad-088: Event Types — picker, multi-select filter, add modal.
"common.cancel": "Cancel",
"modal.close.label": "Close",
"event_types.cat.submission": "Submissions",
"event_types.cat.decision": "Decisions",
"event_types.cat.order": "Orders",
@@ -4861,6 +4874,17 @@ const translations: Record<Lang, Record<string, string>> = {
"approvals.suggest.submit_disabled_hint": "Change at least one field or leave a note.",
"approvals.suggest.next_request_link": "→ New suggestion by {name}",
"approvals.suggest.unsupported_lifecycle": "Suggest changes is only available for update requests.",
"approvals.suggest.section.editable": "Fields",
"approvals.suggest.section.context": "Context",
"approvals.suggest.context.project": "Project",
"approvals.suggest.context.requester": "Submitted by",
"approvals.suggest.context.requested_at": "Submitted at",
"approvals.suggest.context.approval_status": "Approval status",
"approvals.suggest.event_type_picker_unavailable": "Event types could not be loaded.",
"approvals.suggest.field.original_due_date": "Original due date",
"approvals.suggest.field.warning_date": "Warning date",
"approvals.suggest.field.rule_code": "Rule citation",
"approvals.suggest.field.description": "Description",
"approvals.requested_by": "Submitted by",
"approvals.decided_by": "Decided by",
"approvals.decision_kind.peer": "Peer approval",

View File

@@ -184,6 +184,9 @@ async function handleSuggestChanges(
let preImage: Record<string, unknown> | null = null;
let entityType: "deadline" | "appointment" = "deadline";
let lifecycleEvent = "update";
let projectTitle: string | undefined;
let requesterName: string | undefined;
let requestedAt: string | undefined;
try {
const r = await fetch(`/api/approval-requests/${requestID}`, { credentials: "include" });
if (r.ok) {
@@ -192,11 +195,17 @@ async function handleSuggestChanges(
lifecycle_event?: string;
payload?: Record<string, unknown> | null;
pre_image?: Record<string, unknown> | null;
project_title?: string;
requester_name?: string;
requested_at?: string;
};
payload = body.payload ?? null;
preImage = body.pre_image ?? null;
if (body.entity_type === "appointment") entityType = "appointment";
if (body.lifecycle_event) lifecycleEvent = body.lifecycle_event;
projectTitle = body.project_title;
requesterName = body.requester_name;
requestedAt = body.requested_at;
}
} catch (_e) {
// Modal still opens with empty defaults if the fetch fails; the
@@ -208,6 +217,9 @@ async function handleSuggestChanges(
lifecycleEvent,
payload,
preImage,
projectTitle,
requesterName,
requestedAt,
});
if (!result) return; // cancel

View File

@@ -642,11 +642,22 @@ export type I18nKey =
| "approvals.status.superseded"
| "approvals.subtitle"
| "approvals.suggest.cancel"
| "approvals.suggest.context.approval_status"
| "approvals.suggest.context.project"
| "approvals.suggest.context.requested_at"
| "approvals.suggest.context.requester"
| "approvals.suggest.event_type_picker_unavailable"
| "approvals.suggest.field.description"
| "approvals.suggest.field.original_due_date"
| "approvals.suggest.field.rule_code"
| "approvals.suggest.field.warning_date"
| "approvals.suggest.intro"
| "approvals.suggest.modal_title"
| "approvals.suggest.next_request_link"
| "approvals.suggest.note_label"
| "approvals.suggest.note_placeholder"
| "approvals.suggest.section.context"
| "approvals.suggest.section.editable"
| "approvals.suggest.submit"
| "approvals.suggest.submit_disabled_hint"
| "approvals.suggest.unsupported_lifecycle"
@@ -1670,6 +1681,7 @@ export type I18nKey =
| "login.tab.login"
| "login.tab.register"
| "login.title"
| "modal.close.label"
| "nav.admin.audit"
| "nav.admin.bereich"
| "nav.admin.event_types"

View File

@@ -3882,7 +3882,177 @@ input[type="range"]::-moz-range-thumb {
font-size: 0.95rem;
}
/* --- Modal --- */
/* --- Unified modal primitive (t-paliad-217) ---
Native <dialog>-backed. Layered on top of the legacy .modal-overlay /
.modal-card / .modal-content / .modal classes below; those stay in
place until each call site migrates to openModal(). The new BEM-style
.modal__* selectors avoid colliding with the legacy class hierarchy. */
dialog.modal {
border: none;
border-radius: calc(var(--radius) * 1.5);
box-shadow: var(--shadow-xl);
padding: 0;
background: var(--color-surface);
color: var(--color-text);
width: 100%;
max-width: min(90vw, var(--modal-max-w, 480px));
max-height: min(90vh, 40rem);
overflow: hidden;
display: flex;
flex-direction: column;
}
dialog.modal[data-size="sm"] { --modal-max-w: 380px; }
dialog.modal[data-size="lg"] { --modal-max-w: 640px; }
dialog.modal[data-size="full"] {
--modal-max-w: 100vw;
max-height: 100vh;
border-radius: 0;
}
dialog.modal::backdrop {
background: var(--color-overlay-modal);
}
/* Phone breakpoint — full-screen takeover ABOVE the PWA bottom-nav.
m's 2026-05-20 lock-in: the modal must not cover the bottom-nav and
must close via the browser back-button (handled in modal.ts). */
@media (max-width: 32rem) {
dialog.modal {
--modal-max-w: 100vw;
border-radius: 0;
max-height: calc(100vh - var(--bottom-nav-height, 56px));
margin-bottom: var(--bottom-nav-height, 56px);
}
}
.modal__header {
flex-shrink: 0;
padding: 1.25rem 1.5rem 0.75rem;
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
border-bottom: 1px solid var(--color-border);
}
.modal__title {
font-size: 1.15rem;
font-weight: 700;
margin: 0;
color: var(--color-text);
}
.modal__close {
background: none;
border: none;
cursor: pointer;
font-size: 1.5rem;
color: var(--color-text-muted);
padding: 0.25rem 0.5rem;
line-height: 1;
border-radius: var(--radius);
}
.modal__close:hover {
color: var(--color-text);
background: var(--color-surface-muted);
}
.modal__body {
flex: 1;
overflow-y: auto;
padding: 1.25rem 1.5rem;
font-size: 1rem;
color: var(--color-text);
}
.modal__footer {
flex-shrink: 0;
padding: 0.75rem 1.5rem 1.25rem;
display: flex;
gap: 0.75rem;
justify-content: flex-end;
border-top: 1px solid var(--color-border);
background: var(--color-surface);
}
/* --- approval-suggest modal body (t-paliad-217) ---
The body is laid out as three sections (editable / context /
comment), separated by light rules. Reuses the existing .form-field
shapes so input typography matches /deadlines/new + views editor. */
.approval-suggest-body {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.approval-suggest-intro {
margin: 0;
font-size: 0.9rem;
color: var(--color-text-muted);
line-height: 1.5;
}
.approval-suggest-section {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.approval-suggest-section-title {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
margin: 0;
}
.approval-suggest-section--context {
border-top: 1px dashed var(--color-border);
padding-top: 1rem;
}
.approval-suggest-context-grid {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.4rem 1rem;
margin: 0;
font-size: 0.88rem;
}
.approval-suggest-context-grid dt {
color: var(--color-text-muted);
font-weight: 600;
}
.approval-suggest-context-grid dd {
margin: 0;
color: var(--color-text);
}
.approval-suggest-prehint {
display: block;
margin-top: 0.25rem;
font-size: 0.78rem;
color: var(--color-text-muted);
font-style: italic;
}
.approval-suggest-section--note {
border-top: 1px solid var(--color-border);
padding-top: 1rem;
}
.approval-suggest-event-type-picker {
/* Picker styles its own internals (.event-type-picker). */
}
/* Legacy modal classes follow — kept until the other ~7 modals migrate. */
/* --- Modal (legacy) --- */
.modal-overlay {
position: fixed;
@@ -12202,37 +12372,12 @@ dialog.quick-add-sheet::backdrop {
font-weight: 600;
}
/* Broadcast compose modal — extends .modal-overlay / .modal pattern. */
.modal-broadcast {
width: 720px;
max-width: 92vw;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.modal-broadcast .modal-body {
overflow-y: auto;
flex: 1;
padding: 16px 20px;
}
.modal-broadcast label {
display: block;
margin-top: 12px;
margin-bottom: 4px;
font-weight: 500;
font-size: 14px;
}
.modal-broadcast input[type="text"],
.modal-broadcast textarea,
.modal-broadcast select {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--color-border);
border-radius: 4px;
font-family: inherit;
font-size: 14px;
}
.modal-broadcast textarea {
/* Broadcast compose modal body styling. The shell (width, modal-body
padding, base form-field rules) is owned by the unified modal
primitive — these rules below cover only the broadcast-specific
content. Textarea gets a code-monospace face so the placeholder
syntax reads correctly. (Migrated onto openModal in t-paliad-217.) */
.broadcast-body [data-broadcast-body] {
resize: vertical;
min-height: 200px;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;

View File

@@ -436,17 +436,18 @@ func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerI
return nil, fmt.Errorf("marshal counter_payload: %w", err)
}
// Validate counter has at least one allowlisted field for the entity
// type — otherwise the entity-update below would be a no-op and the
// new row would just resubmit the SAME values, which is a degenerate
// case we should reject cleanly. Only run this check when the
// payload "differs" (i.e. caller actually provided something).
// Validate counter has at least one counter-allowlisted field for the
// entity type — otherwise the entity-update below would be a no-op
// and the new row would just resubmit the SAME values, which is a
// degenerate case we should reject cleanly. Only run this check when
// the payload "differs" (i.e. caller actually provided something).
// Note: validates against the WIDER counter-allowlist (t-paliad-217
// Slice B), not the date-only revert-allowlist.
if payloadDiffers {
if _, _, err := buildRevertSetClauses(old.EntityType, counterPayload); err != nil {
// ErrUnknownEntityType wraps "empty pre_image for X" when no
// allowlisted key is present. Rebrand as suggestion-input
// failure for the handler's 400 mapping.
return nil, fmt.Errorf("%w: %v", ErrSuggestionRequiresChange, err)
if _, _, err := buildCounterSetClauses(old.EntityType, counterPayload); err != nil {
// buildCounterSetClauses already wraps ErrSuggestionRequiresChange
// for the "no allowlisted fields" + empty-title cases. Propagate.
return nil, err
}
}
@@ -573,31 +574,84 @@ func (s *ApprovalService) SuggestChanges(ctx context.Context, requestID, callerI
return &newID, nil
}
// applyEntityUpdate writes the allowlisted fields from payload onto the
// entity row. Mirrors the write side of write-then-approve (which lives in
// DeadlineService / AppointmentService for the user-driven path) — used
// by SuggestChanges to apply an approver's counter-proposal back onto the
// entity inside the same tx. Reuses buildRevertSetClauses for the
// jsonb-key-to-SQL-SET translation so the allowlist is one source of
// truth.
// applyEntityUpdate writes the counter_payload fields onto the entity
// row (t-paliad-217 Slice B). Uses the WIDER counter-allowlist
// (buildCounterSetClauses) — every editable field on the entity, not
// just the date-allowlist that triggers approval. Handles
// event_type_ids as a junction-table rewrite when present in payload.
func (s *ApprovalService) applyEntityUpdate(ctx context.Context, tx *sqlx.Tx, entityType string, entityID uuid.UUID, payload map[string]any) error {
if len(payload) == 0 {
return fmt.Errorf("%w: empty payload", ErrSuggestionRequiresChange)
}
setClauses, args, err := buildRevertSetClauses(entityType, payload)
// 1. Column-level updates via the counter-allowlist.
setClauses, args, err := buildCounterSetClauses(entityType, payload)
if err != nil {
return err
}
setClauses = append(setClauses, "updated_at = now()")
args = append(args, entityID)
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
entityTableName(entityType), strings.Join(setClauses, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("apply counter payload to entity: %w", err)
if len(setClauses) > 0 {
setClauses = append(setClauses, "updated_at = now()")
args = append(args, entityID)
q := fmt.Sprintf(`UPDATE paliad.%s SET %s WHERE id = $%d`,
entityTableName(entityType), strings.Join(setClauses, ", "), len(args))
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("apply counter payload to entity: %w", err)
}
}
// 2. event_type_ids junction rewrite (deadline only).
if entityType == EntityTypeDeadline {
if raw, ok := payload["event_type_ids"]; ok {
ids, err := parseUUIDList(raw)
if err != nil {
return fmt.Errorf("%w: invalid event_type_ids: %v", ErrSuggestionRequiresChange, err)
}
if err := rewriteDeadlineEventTypes(ctx, tx, entityID, ids); err != nil {
return err
}
}
}
return nil
}
// parseUUIDList accepts either []any (from json.Unmarshal of a JSON
// array) or []string and returns a []uuid.UUID. Empty list = explicit
// clear; nil-typed list also empty.
func parseUUIDList(raw any) ([]uuid.UUID, error) {
if raw == nil {
return nil, nil
}
arr, ok := raw.([]any)
if !ok {
// Fallback: caller serialized as []string directly.
if sarr, ok := raw.([]string); ok {
out := make([]uuid.UUID, 0, len(sarr))
for _, s := range sarr {
id, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("not a UUID: %q", s)
}
out = append(out, id)
}
return out, nil
}
return nil, fmt.Errorf("expected array, got %T", raw)
}
out := make([]uuid.UUID, 0, len(arr))
for _, v := range arr {
s, ok := v.(string)
if !ok {
return nil, fmt.Errorf("expected string in array, got %T", v)
}
id, err := uuid.Parse(s)
if err != nil {
return nil, fmt.Errorf("not a UUID: %q", s)
}
out = append(out, id)
}
return out, nil
}
// payloadsDiffer returns true iff the candidate counter map decodes to a
// value that differs from the old row's payload jsonb. Used by
// SuggestChanges to detect "no-op suggestion". Both NULL or both empty
@@ -893,11 +947,17 @@ func (s *ApprovalService) applyRevert(ctx context.Context, tx *sqlx.Tx, req *mod
}
// buildRevertSetClauses translates pre_image jsonb keys into SQL SET
// fragments. Only the date-bearing allowlist (Q4) is honoured; unknown
// keys are silently dropped to defend against malformed pre_image rows
// (defence-in-depth: callers should already be sending only allowlisted
// fields, but a hostile UPDATE on the request row shouldn't let arbitrary
// fields be reverted).
// fragments for the Reject / Revoke path. Only the date-bearing
// t-paliad-138 §Q4 allowlist is honoured; unknown keys are silently
// dropped to defend against malformed pre_image rows (defence-in-depth:
// callers should already be sending only allowlisted fields, but a
// hostile UPDATE on the request row shouldn't let arbitrary fields be
// reverted).
//
// This is intentionally NARROWER than buildCounterSetClauses (which
// handles the SuggestChanges counter-payload). Reject restores ONLY what
// was originally captured in pre_image; SuggestChanges can write any
// counter-allowlist field the approver chose to author.
func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string, []any, error) {
var setClauses []string
var args []any
@@ -947,6 +1007,135 @@ func buildRevertSetClauses(entityType string, preImage map[string]any) ([]string
return setClauses, args, nil
}
// buildCounterSetClauses translates a SuggestChanges counter_payload jsonb
// into SQL SET fragments for the entity row (t-paliad-217 Slice B). This
// is the WIDER counter-allowlist — m's 2026-05-20 lock-in: every "real"
// editable field on the entity is in scope for a counter-proposal, not
// just the date-allowlist that triggers approval (t-paliad-138 §Q4).
//
// Unknown keys are silently dropped — defence-in-depth against a hostile
// counter_payload making it past the handler's body decode. Returns an
// error iff zero allowlisted fields are present (caller surfaces as
// ErrSuggestionRequiresChange when paired with an empty note).
//
// event_type_ids is NOT a column on paliad.deadlines — it's a junction
// table (paliad.deadline_event_types). applyEntityUpdate handles it
// separately; this function silently ignores the key.
func buildCounterSetClauses(entityType string, counter map[string]any) ([]string, []any, error) {
var setClauses []string
var args []any
add := func(col string, val any) {
args = append(args, val)
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
// addText accepts string keys and stores either a non-NULL string or
// NULL when the caller explicitly cleared the value with an empty
// string. Used for the optional-text columns (description, notes,
// location, etc.).
addText := func(col string, raw any) {
if raw == nil {
args = append(args, nil)
} else {
s, _ := raw.(string)
if s == "" {
args = append(args, nil)
} else {
args = append(args, s)
}
}
setClauses = append(setClauses, fmt.Sprintf("%s = $%d", col, len(args)))
}
switch entityType {
case EntityTypeDeadline:
// Date allowlist (existing).
for _, col := range []string{"due_date", "original_due_date", "warning_date"} {
if v, ok := counter[col]; ok {
add(col, v)
}
}
// Required text (NOT NULL on the column — refuse empty).
if v, ok := counter["title"]; ok {
s, _ := v.(string)
if strings.TrimSpace(s) == "" {
return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
}
add("title", s)
}
// Nullable text (empty string clears).
for _, col := range []string{"description", "notes", "rule_code"} {
if v, ok := counter[col]; ok {
addText(col, v)
}
}
case EntityTypeAppointment:
// Datetime allowlist (existing).
for _, col := range []string{"start_at", "end_at"} {
if v, ok := counter[col]; ok {
add(col, v)
}
}
if v, ok := counter["title"]; ok {
s, _ := v.(string)
if strings.TrimSpace(s) == "" {
return nil, nil, fmt.Errorf("%w: title cannot be empty", ErrSuggestionRequiresChange)
}
add("title", s)
}
for _, col := range []string{"description", "location", "appointment_type"} {
if v, ok := counter[col]; ok {
addText(col, v)
}
}
default:
return nil, nil, fmt.Errorf("%w: %q", ErrUnknownEntityType, entityType)
}
// event_type_ids is handled outside this function (junction-table
// write). Its presence alone in the counter doesn't count as "zero
// fields" — applyEntityUpdate inspects len(setClauses)==0 against the
// combined picture, not this return value.
if len(setClauses) == 0 {
if _, ok := counter["event_type_ids"]; !ok {
return nil, nil, fmt.Errorf("%w: no allowlisted fields in counter for %s", ErrSuggestionRequiresChange, entityType)
}
}
return setClauses, args, nil
}
// rewriteDeadlineEventTypes replaces the deadline_event_types junction
// rows for a deadline with the provided list (t-paliad-217 Slice B).
// Empty list clears the junction (the deadline has no event-type tags).
// nil list = no-op (caller didn't include event_type_ids in the counter).
//
// We don't validate the event_type ids exist — the FK to paliad.event_types
// catches that with an ON DELETE CASCADE-safe failure. Caller wraps in tx.
func rewriteDeadlineEventTypes(ctx context.Context, tx *sqlx.Tx, deadlineID uuid.UUID, ids []uuid.UUID) error {
if _, err := tx.ExecContext(ctx,
`DELETE FROM paliad.deadline_event_types WHERE deadline_id = $1`, deadlineID); err != nil {
return fmt.Errorf("clear deadline_event_types: %w", err)
}
if len(ids) == 0 {
return nil
}
values := make([]string, 0, len(ids))
args := make([]any, 0, len(ids)+1)
args = append(args, deadlineID)
for i, id := range ids {
values = append(values, fmt.Sprintf("($1, $%d)", i+2))
args = append(args, id)
}
q := `INSERT INTO paliad.deadline_event_types (deadline_id, event_type_id) VALUES ` + strings.Join(values, ", ")
if _, err := tx.ExecContext(ctx, q, args...); err != nil {
return fmt.Errorf("insert deadline_event_types: %w", err)
}
return nil
}
// getRequestForUpdate locks an approval_requests row inside the tx for
// decision processing.
func (s *ApprovalService) getRequestForUpdate(ctx context.Context, tx *sqlx.Tx, requestID uuid.UUID) (*models.ApprovalRequest, error) {

View File

@@ -1336,3 +1336,80 @@ func TestApprovalService_SuggestChanges_CounterApproverCannotSelfApprove(t *test
}
}
// TestApprovalService_SuggestChanges_TitleOnlyCounter pins t-paliad-217
// Slice B: the counter-allowlist now accepts the wider field set
// (title / description / notes / rule_code / event_type_ids on
// deadlines). A counter that ONLY changes the title (no date diff) must
// succeed — the new pending row's payload carries the title, and the
// entity row's title field is updated in-tx.
func TestApprovalService_SuggestChanges_TitleOnlyCounter(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"title": "Klageerwiderung — Vorschlag Hertz"}
newReqID, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
if err != nil {
t.Fatalf("title-only suggest: %v", err)
}
if newReqID == nil {
t.Fatal("expected new request id, got nil")
}
// Entity's title flipped.
var gotTitle string
if err := env.pool.GetContext(ctx, &gotTitle,
`SELECT title FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read title: %v", err)
}
if gotTitle != "Klageerwiderung — Vorschlag Hertz" {
t.Errorf("entity title = %q, want %q", gotTitle, "Klageerwiderung — Vorschlag Hertz")
}
}
// TestApprovalService_SuggestChanges_NotesOnlyCounter pins t-paliad-217
// Slice B: notes is in the counter-allowlist and a notes-only counter
// must succeed. Empty-string clears the column (NULLable text).
func TestApprovalService_SuggestChanges_NotesOnlyCounter(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
deadlineID, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"notes": "Bitte vor Einreichung mit Mandant abstimmen."}
if _, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, ""); err != nil {
t.Fatalf("notes-only suggest: %v", err)
}
var gotNotes *string
if err := env.pool.GetContext(ctx, &gotNotes,
`SELECT notes FROM paliad.deadlines WHERE id = $1`, deadlineID); err != nil {
t.Fatalf("read notes: %v", err)
}
if gotNotes == nil || *gotNotes != "Bitte vor Einreichung mit Mandant abstimmen." {
t.Errorf("entity notes = %v, want set", gotNotes)
}
}
// TestApprovalService_SuggestChanges_EmptyTitleRejected pins the title
// non-empty CHECK on the counter-allowlist: title is NOT NULL on the
// deadlines column, so a counter that explicitly sends "" for title
// must be rejected with ErrSuggestionRequiresChange (not silently
// dropped or written as a NULL).
func TestApprovalService_SuggestChanges_EmptyTitleRejected(t *testing.T) {
env := setupApprovalTest(t)
defer env.cleanup()
ctx := context.Background()
_, oldReqID, _ := env.seedPendingUpdate(t)
counter := map[string]any{"title": " "} // whitespace-only
_, err := env.approvals.SuggestChanges(ctx, oldReqID, env.approver, counter, "")
if !errors.Is(err, ErrSuggestionRequiresChange) {
t.Errorf("empty-title suggest: got %v, want ErrSuggestionRequiresChange", err)
}
}