Compare commits

...

6 Commits

Author SHA1 Message Date
mAi
5fe0272d6d feat(modals): t-paliad-217 Slice D — broadcast.ts onto openModal primitive
m's Q4 lock-in (2026-05-20): retrofit the richest existing modal —
broadcast.ts (bulk team-email compose) — onto the unified primitive to
demonstrate its generality on a real-world surface.

Changes:
  - Body is built imperatively (renderBody + wireBody) and handed to
    openModal as the body element. The submit logic reads form state
    from that element on primary-handler invocation.
  - Drops the per-modal ESC + close + backdrop + overlay-stack handlers
    — the primitive owns them.
  - Drops the bespoke .modal-broadcast { width / max-height / padding /
    label / input / textarea } CSS overrides. The primitive's data-size
    handles width; the existing .form-field rules handle inputs; only
    the textarea's code-monospace font is kept as a broadcast-specific
    override (placeholder syntax needs to read as code).
  - Primary action is "Senden (N)" — clicks invoke the existing
    onSubmit logic which POSTs to /api/team/broadcast and on success
    shows the per-recipient report inline then closes via the
    setTimeout(close, 2500) pattern.

The recipient-list toggle + template dropdown + markdown placeholder
hints are unchanged.

i18n + the .broadcast-recipient-* / .broadcast-recip-* / .broadcast-hint
/ .broadcast-error / .broadcast-success content classes are unchanged.
2026-05-20 13:05:06 +02:00
mAi
455af36bc8 feat(approvals): t-paliad-217 Slice C — approval-edit-modal full rewrite
Rewrite atop the unified openModal() primitive (Slice A). Drops the
per-modal ESC + focus + backdrop + close-button handlers — the
primitive owns them.

New three-section body per design §2:
  1. Editable fields. Every editable column on the entity, per m's Q1
     Reading A lock-in:
       deadline:    title, due_date, original_due_date, warning_date,
                    rule_code, description, notes, event_type_ids
                    (attached via the existing event-types picker).
       appointment: title, start_at, end_at, location, appointment_type,
                    description.
  2. Read-only context. Project title, requester, requested_at, current
     approval status. Renders as a definition-list with muted dt/dd
     pairs so the eye lands on the editable section first.
  3. Vorschlagskommentar (note). Always present, prominent.

Block labels matching /deadlines/new + views editor — reuses the
existing .form-field shapes for typography + spacing parity with the
rest of the app (m's Q6 lock-in).

inbox.ts gains projectTitle / requesterName / requestedAt hydration
from the per-row API response so the context section has data to
render. Falls back gracefully when missing.

Submit-button gate (in the openModal primary handler): refuses when no
field is dirty AND the note is empty. Mirrors the server's
ErrSuggestionRequiresChange.

CSS .approval-suggest-* classes added to global.css alongside the
modal primitive block (committed in Slice A).
2026-05-20 13:04:54 +02:00
mAi
1bf64213ae feat(approvals): t-paliad-217 Slice B — counter_payload allowlist expansion
m's t-paliad-217 Q1 lock-in (2026-05-20): the suggest-changes modal lets
the approver edit EVERY field on the underlying deadline / appointment,
not just the date allowlist that triggers approval. Server-side support
for the wider counter shape:

  - buildCounterSetClauses (new) — the counter-allowlist:
      deadline:    title, due_date, original_due_date, warning_date,
                   description, notes, rule_code (event_type_ids handled
                   separately via junction-table rewrite).
      appointment: title, start_at, end_at, description, location,
                   appointment_type.
  - buildRevertSetClauses (existing) stays narrow — Reject only restores
    what pre_image actually contains (defence-in-depth: a hostile UPDATE
    on the request row must not let arbitrary fields be reverted, and
    pre_image is server-written so what's in there is what we trust).
  - rewriteDeadlineEventTypes — junction-table DELETE+INSERT for the
    deadline_event_types m-to-m when counter_payload carries
    event_type_ids. Runs in the same tx as the entity UPDATE.
  - applyEntityUpdate — switched from buildRevertSetClauses to
    buildCounterSetClauses; gained the event_type_ids branch for
    deadlines.
  - SuggestChanges no-op validator — now uses buildCounterSetClauses
    so the wider field set counts as "differs".
  - title is treated as NOT NULL — whitespace-only counter title
    surfaces ErrSuggestionRequiresChange (defence-in-depth against the
    column's own NOT NULL CHECK).

Tests:
  - TestApprovalService_SuggestChanges_TitleOnlyCounter — title diff
    succeeds; entity title updates.
  - TestApprovalService_SuggestChanges_NotesOnlyCounter — notes diff
    succeeds; entity notes column populates.
  - TestApprovalService_SuggestChanges_EmptyTitleRejected — whitespace-
    only title rejected with ErrSuggestionRequiresChange.

No DB migration needed (counter_payload jsonb already accepts arbitrary
shape; the change is in the column-allowlist switch on read).
2026-05-20 13:04:40 +02:00
mAi
abab97ea33 feat(modals): t-paliad-217 Slice A + content additions — unified modal primitive
frontend/src/client/components/modal.ts — new openModal() primitive,
native <dialog>-backed. The browser handles top-layer stacking, ESC,
ARIA, and focus trap. We layer on top:
  - browser back-button closes the modal (history.pushState on open +
    popstate listener, matching m's Q5 lock-in)
  - focus restoration to whatever was focused before open (the native
    <dialog> doesn't do this)
  - backdrop click closes
  - close (×) button mandatory in the header, always rendered

CSS (global.css):
  - dialog.modal + .modal__{header,title,close,body,footer} block. Sizes
    sm/md/lg/full via data-size attr.
  - Phone breakpoint (≤32rem): full-screen takeover sitting ABOVE the
    PWA bottom-nav. max-height accounts for --bottom-nav-height (56px)
    and margin-bottom keeps the nav visible.
  - Legacy .modal-overlay / .modal-card / .modal-content / .modal stay
    in place for the ~7 unmigrated modals — the new BEM-style .modal__*
    avoids colliding with the legacy hierarchy. Cleanup is a follow-up
    PR after the last legacy modal flips.

i18n keys + i18n-keys.ts regenerated:
  - modal.close.label (DE/EN)
  - approvals.suggest.section.editable / .context (DE/EN)
  - approvals.suggest.context.{project,requester,requested_at,approval_status} (DE/EN)
  - approvals.suggest.field.{original_due_date,warning_date,rule_code,description} (DE/EN)
  - approvals.suggest.event_type_picker_unavailable (DE/EN)

(Slice C consumes the suggest.section/context/field keys; bundling them
here keeps the i18n.ts diff coherent.)
2026-05-20 13:04:24 +02:00
mAi
d438da2c39 docs(modals): t-paliad-217 — fold m's decisions + revise §7 implementation
§0a captures m's locked picks across all 6 questions. Two notable
divergences from inventor recommendations:

  - Q1: full-edit (not partial). Modal makes every field editable —
    title / description / notes / rule_code / event_type_ids (deadline);
    title / description / location / appointment_type (appointment).
    Requires backend expansion of buildRevertSetClauses + counter_payload
    allowlist. New Slice B captures the backend work.
  - Q4: suggest-changes + broadcast.ts (not suggest-changes only). Two
    migrated call sites in this PR; broadcast.ts is the richest legacy
    modal and demonstrates the primitive's generality.
  - Q5: full-screen mobile takeover refined — must sit ABOVE the PWA
    bottom-nav (so max-height accounts for --bottom-nav-height), close
    button mandatory, browser back-button closes the modal (history
    pushState on open + popstate listener).

§7 implementation rewritten as 5 slices: (A) primitive + CSS, (B) backend
expansion, (C) approval-edit-modal rewrite, (D) broadcast.ts retrofit,
(E) i18n + CSS cleanup. Single PR, no migration.
2026-05-20 12:46:30 +02:00
mAi
e505126e8d docs(modals): t-paliad-217 — unified modal pattern + suggest-changes rework
Inventor design for m's 2026-05-20 feedback on the suggest-changes modal
shipped in t-paliad-216 Slice B.

Bundles two things:
  1. Immediate fix — show ALL deadline / appointment information in the
     suggest-changes modal (currently shows only the date allowlist),
     with the Vorschlagskommentar field still prominent, and typography
     + spacing polished.
  2. Longer-term — a unified modal primitive (CSS frame + TS function)
     so future modals stop forking the same shell six different ways.
     Native <dialog>-backed; openModal({...}) returning Promise<T|null>.

Audit (§1) catalogues ~9 existing modal surfaces and the CSS forks
between them (two .modal-overlay declarations with different z-indexes,
three overlapping container classes). Migration is one PR per legacy
modal after this one lands — suggest-changes goes first.

Open questions in §5 ride to m via AskUserQuestion next.
2026-05-20 10:42:54 +02:00
10 changed files with 1600 additions and 389 deletions

View File

@@ -0,0 +1,415 @@
# Design — Unified modal pattern + suggest-changes rework
**Author:** hertz (inventor)
**Date:** 2026-05-20
**Task:** t-paliad-217 (m/paliad#45)
**Branch:** `mai/hertz/inventor-unified-modal`
**Status:** DESIGN — open questions await m before any coder shift.
---
## 0. TL;DR
m's feedback on the suggest-changes modal shipped in t-paliad-216 Slice B:
> I dont like the "suggest correction" modal — it should basically be a modal for all deadline information with an additional "comment" field — currently the layout isnt nice either, font size etc. We dont have too many modals yet but I think we should have a unified modal approach.
Two asks bundled. **Immediate:** rework the suggest-changes modal so it shows all deadline (or appointment) information — not just the date-allowlist subset — with an additional comment field; polish typography + spacing. **Longer-term:** add a unified modal primitive (CSS frame + TS function) so future modals stop reinventing the same shell six different ways.
Scope of this design is the **frame** (modal infra: overlay, header/body/footer, ESC, focus trap, scroll-lock, ARIA, mobile UX) and the **suggest-changes rework**. Migrating every existing modal is **out of scope** — only suggest-changes ships on the new primitive in this PR; the other modals migrate one-at-a-time in follow-up PRs as they need touching.
---
## 0a. m's decisions (2026-05-20)
| # | Header | m picked | Reasoning note (when different from recommendation) |
|---|---|---|---|
| Q1 | Edit scope | **(b) Full-edit — every field editable.** | Differs from (a). Inventor recommended Reading B (full-view, partial-edit) to preserve the t-paliad-138 "approval triggered only by date changes" lock-in. m's pick loosens that: non-date counter-edits now flow through 4-Augen. Concretely this needs a backend expansion of `buildRevertSetClauses` + `counter_payload` schema to accept title / description / notes / rule_code / event_type_ids (deadline) and title / description / location / appointment_type (appointment). See §7 Slice C below for the additional backend slice. |
| Q2 | API shape | **(a) Function returning a Promise.** | As recommended. |
| Q3 | Substrate | **(a) Native `<dialog>` with `.showModal()`.** | As recommended. |
| Q4 | Migration scope | **(b) Suggest-changes + broadcast.ts.** | Differs from (a). Inventor recommended suggest-changes only as the smallest reviewable PR. m wants broadcast.ts retrofitted too — it's the largest existing modal and demonstrates the primitive's generality. Two migrated call sites in this PR. |
| Q5 | Mobile UX | **(a + constraints) Full-screen takeover, ABOVE the PWA bottom controls, with a close button, with browser back-button closing the modal.** | Refines (a). Additional constraints captured: the full-screen modal must NOT cover the bottom-nav (so `max-height` accounts for `--bottom-nav-height`); a close button (the existing `.modal__close` X) is mandatory; back-button closes via `history.pushState` on open + `popstate` listener. |
| Q6 | Typography | **(a) Match `/deadlines/new` + views editor form-field shapes.** | As recommended. |
The decisions above lock the design. §7 implementation sketch is updated to reflect them, including the new Slice C backend extension that Reading A on Q1 requires.
---
## 1. Current state — modal landscape audit (2026-05-20)
### CSS (frontend/src/styles/global.css)
The CSS already has the bones, but it's accidentally forked. Two declarations of `.modal-overlay` exist (lines 3887 and 4452) — the second one (z-index 1000) shadows the first (z-index 100). Three container classes (`.modal-card`, `.modal-content`, `.modal`) overlap heavily but differ in padding + shadow. Each child surface piles its own modifiers (`.modal-broadcast`, `.event-type-add-modal`, `.event-type-browse-modal`, `.modal-card-wide`, `.invite-modal-body`, `.smart-timeline-modal-card`, `#suggest-form`) on top.
That's the underlying problem the unified primitive needs to clean up.
### TS call sites
| File | What it opens | API shape | ESC | Focus trap | Scroll lock | Backdrop click | Notes |
|---|---|---|---|---|---|---|---|
| `components/approval-edit-modal.ts` | Suggest-changes editor | Function (Promise-returning) | yes | no | no | yes | This task's main subject. Hard-coded date-allowlist fields. |
| `broadcast.ts` | Compose email to selected users | DOM-imperative | yes (close button + ESC handler) | no | no | yes | The richest existing modal — template picker + recipients list + markdown body. |
| `event-types.ts` (add + browse, 2 modals) | Add a new event_type / browse all | DOM-imperative | yes | yes (browse modal only) | no | yes | Browse modal is the only one with a real focus trap (lines 793815). |
| `fristenrechner.ts` | Save calculated date as a deadline | DOM-imperative (renders into existing HTML host) | unclear (host-driven) | no | no | unclear | Two save modals + an inline edit modal. |
| `filter-bar/save-modal.ts` | Save current filter bar state as a view | Function | no (form-submit only) | no | no | no | Smallest; close-via-cancel-button only. |
| `admin-rules-edit.ts` | Reason modal for save-draft / publish | DOM-imperative (server-rendered host) | yes (form-submit) | no | no | unclear | Reason ≥10 char validated client-side. |
| `projects-detail.ts` | Smart-timeline modal | DOM-imperative | yes | no | no | unclear | Renders timeline overlay. |
| `sidebar.ts` | Invite teammate modal | DOM-imperative | yes | no | no | yes | Lives in static HTML; client just opens/closes. |
Total: ~9 distinct modal surfaces, ~5 different opening idioms. None use the native `<dialog>` element. Body-scroll-lock is implemented only for the sidebar, not for any modal. ARIA `role="dialog" aria-modal="true"` is present on most but not consistent.
### Build / language baseline
- `frontend/tsconfig.json`: `jsxFactory: "h"`, `jsxFragmentFactory: "Fragment"` — paliad has its own custom JSX renderer (not React). Most client code is plain `.ts` with `innerHTML` strings + event wiring. A handful of TSX exists but the modal surfaces are uniformly `.ts` + `innerHTML`.
- Browser baseline: modern evergreen (Chrome / Edge / Safari / Firefox, current versions). `<dialog>` element + `:modal` pseudo + `dialog.showModal()` + native focus-trap + backdrop are universally supported in this baseline.
---
## 2. Design — the suggest-changes rework
This is the immediate user-visible change. m wants the modal to show "all deadline information" with an additional comment field. Two readings of "all information":
- **Reading A (full edit):** every field on the Deadline / Appointment model is editable in the modal. The server's `buildRevertSetClauses` (in `internal/services/approval_service.go`) and the `counter_payload` schema both need to expand to cover title, description, notes, rule_code, event_type_ids (deadline) and title, description, location, appointment_type (appointment). This is a real backend change.
- **Reading B (full view, partial edit):** the modal shows every field — but only the existing allowlist (dates) is editable; the rest renders as read-only context so the approver understands what they're suggesting changes to. No backend change.
Q1 below picks between them. Inventor recommends **B** for v1: the suggest-changes flow is about modifying *what changes* the requester proposed (dates), not about authoring a completely different deadline. Read-only context is honest about the system's actual mutation surface — and aligns with the approval-policy "only date-changing fields trigger 4-eye" lock-in from t-paliad-138 §Q4.
Either way, the modal structure is the same. With m's pick:
### Layout (Reading B, recommended)
```
┌──────────────────────────────────────────────────────────────────┐
│ Änderungen vorschlagen — Frist · Klageerwiderung [×] │ ← header
├──────────────────────────────────────────────────────────────────┤
│ ◯ Bearbeitbare Felder │ ← visually emphasized
│ │
│ Fälligkeitsdatum [ 2026-06-15 ] │
│ Ursprüngliches [ 2026-06-01 ] │
│ Warndatum [ 2026-06-10 ] │
│ │
│ ◯ Kontext (nur Anzeige) │ ← visually de-emphasised
│ │
│ Titel Klageerwiderung einreichen │
│ Beschreibung Frist für die Antragserwiderung im … │
│ Notizen Bitte mit Mandant abstimmen. │
│ Regel RoP.029 │
│ Ereignistyp(en) Replik │
│ Akte 12345 — Müller ./. Schmidt │
│ Eingereicht von Anna Müller · 20.05.2026 │
│ │
│ ◯ Kommentar zum Vorschlag │ ← always present
│ [textarea — Warum sollen die Daten angepasst werden? ] │
│ │
├──────────────────────────────────────────────────────────────────┤
│ [Abbrechen] [Vorschlag einreichen] │ ← footer
└──────────────────────────────────────────────────────────────────┘
```
Three sections in the body, separated by light visual rules:
- **Bearbeitbare Felder** — the actual editable inputs (date allowlist for deadlines; datetime allowlist for appointments).
- **Kontext** — every other field rendered as `<label> + <span class="value">` pairs. Non-editable. Visually muted so the eye lands on the editable section first.
- **Kommentar zum Vorschlag** — free-text textarea, always present.
Footer has Cancel + Submit. Submit stays disabled until at least one editable field is dirty OR the comment textarea has non-whitespace content (mirrors server `ErrSuggestionRequiresChange`).
### Appointment variant
Same shape. Editable section: `start_at`, `end_at`. Context section: title, description, location, appointment_type, project. The lifecycle restriction still applies — suggest-changes only fires for `lifecycle=update` (shape-list.ts gates this), so the modal never opens on create / complete / delete.
### Typography / spacing
Adopt the form-field shapes already used by `/deadlines/new` and the views editor (label above input, 1rem field gap, label size `0.9rem`, value font `1rem`, modal padding `1.5rem`, body max-height `min(90vh, 40rem)` with scroll). The current modal uses inline labels (`<label class="suggest-field">`) — switch to block labels for room and consistency.
---
## 3. Design — unified modal primitive
### Choice of substrate: native `<dialog>` vs. div-based overlay
Native `<dialog>` element with `.showModal()` is the recommended substrate. The browser handles:
- ESC to dismiss (via `dialog.cancel` event)
- Backdrop styling via `::backdrop` pseudo-element
- Focus trap (modern browsers; auto-focuses first focusable on open)
- ARIA `role="dialog" aria-modal="true"` implicit
- Stacking context above all page content (top layer)
Trade-off: `<dialog>` can't be transition-animated through `display: none` (the top-layer rules conflict with display-toggle transitions); we accept that — modals open/close abruptly today anyway, and the no-animation cost is invisible compared to the focus + ESC + a11y wins.
Q3 lets m flip the recommendation to a div-based overlay if there's a reason to.
### API shape (function call returning a Promise)
```ts
// frontend/src/client/components/modal.ts
interface ModalConfig<T> {
title: string; // header text
body: HTMLElement | string; // body content (string is HTML — caller's responsibility to escape)
primary: { label: string; handler: (close: (result: T) => void) => void };
secondary?: { label: string }; // defaults to "Abbrechen"
size?: "sm" | "md" | "lg" | "full"; // surface width preset
onClose?: () => void; // fired on cancel (ESC, backdrop, secondary)
classNames?: string; // extra classes on the .modal-card
}
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null>;
```
The handler receives the `close` callback so the primary action can validate, fetch, then resolve. Resolving with `null` from any path (cancel, ESC, backdrop) keeps caller branching consistent.
Suggest-changes becomes:
```ts
const result = await openModal<{counterPayload, note}>({
title: t("approvals.suggest.modal_title") + " — " + entityLabel,
body: buildSuggestChangesBody(entityType, payload, preImage),
primary: {
label: t("approvals.suggest.submit"),
handler: (close) => {
const result = readForm(body);
if (!result.dirty && !result.note) return; // server-side enforced too
close(result);
},
},
size: "md",
});
```
The function-call API is recommended over a class-API (no need for a per-instance handle; modals are short-lived) and over a component-API (would require wider TSX adoption, which paliad's frontend isn't on today). Q2 lets m flip.
### CSS — what the primitive nails down
A canonical `<dialog>`-backed CSS block:
```css
dialog.modal {
border: none;
border-radius: calc(var(--radius) * 1.5);
box-shadow: var(--shadow-xl);
padding: 0;
max-width: min(90vw, var(--modal-max-w, 480px));
max-height: min(90vh, 40rem);
background: var(--color-surface);
}
dialog.modal::backdrop {
background: var(--color-overlay-modal);
}
dialog.modal[data-size="lg"] { --modal-max-w: 640px; }
dialog.modal[data-size="sm"] { --modal-max-w: 380px; }
dialog.modal[data-size="full"] {
--modal-max-w: 100vw;
max-height: 100vh;
border-radius: 0;
}
.modal__header { padding: 1.25rem 1.5rem 0.75rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--color-border); }
.modal__title { font-size: 1.15rem; font-weight: 700; margin: 0; }
.modal__close { background: none; border: none; cursor: pointer; font-size: 1.5rem; color: var(--color-text-muted); }
.modal__body { padding: 1.25rem 1.5rem; overflow-y: auto; }
.modal__footer { padding: 0.75rem 1.5rem 1.25rem; display: flex; gap: 0.75rem; justify-content: flex-end; border-top: 1px solid var(--color-border); }
```
The existing forks (`.modal-overlay` at lines 3887 + 4452, `.modal-card`, `.modal-content`, `.modal`) stay in place during the migration window — they're loaded by the not-yet-migrated modals. The unified primitive uses BEM-style `.modal__*` to avoid colliding with the legacy class hierarchy. Migration-by-migration each old modal flips to the new substrate, and once all sites are migrated we delete the duplicates.
### Mobile
Phone-width breakpoint (`max-width: 32rem` ≈ 512px in the existing CSS) switches `dialog.modal` to `data-size="full"` automatically via a media-query override. Full-screen takeover on mobile is the standard pattern; the alternative (centered card on phone) wastes horizontal real estate. Q5 lets m flip to sheet-from-bottom if preferred.
### Body-scroll lock
`<dialog>.showModal()` handles this natively (page behind a top-layer dialog can scroll only if the dialog's CSS allows). For the div-based fallback (if Q3 picks it), we add `document.body.classList.add("no-scroll")` on open + remove on close — same class the sidebar already uses.
---
## 4. Migration scope (this PR)
In this slice:
- **components/modal.ts** — the new primitive (open / close / focus / ESC / backdrop / sizes).
- **components/approval-edit-modal.ts** — rewritten on top of `openModal()`. New full-info layout per §2. Drops the per-modal ESC + focus management since the primitive handles them.
- **CSS** — `dialog.modal` + `.modal__*` block in `global.css`. Legacy `.modal-overlay` / `.modal-card` / `.modal-content` / `.modal` stay in place until each call site migrates.
Out of scope this PR (each is its own follow-up):
- broadcast.ts → openModal()
- event-types.ts add + browse → openModal()
- fristenrechner.ts save modal → openModal()
- filter-bar/save-modal.ts → openModal()
- admin-rules-edit.ts reason modal → openModal()
- sidebar.ts invite modal → openModal()
- projects-detail.ts smart-timeline modal → openModal()
That gives one PR per legacy modal — small, individually reviewable, and each one shrinks the duplicated CSS by one chunk. Once all are migrated the legacy classes get deleted in a final cleanup PR.
Q4 lets m bundle one or two of these into the current task if he wants more momentum.
---
## 5. Open questions (the historical record)
### Q1 — Field editability scope in the suggest-changes modal
The header "modal for all deadline information" can mean two different things in the implementation:
- **(a) Reading B: full-view, partial-edit (Recommended).** Modal shows every Deadline / Appointment field. Dates (the existing allowlist) are editable inputs; everything else is rendered read-only as context. No backend change required — current `counter_payload` allowlist + `buildRevertSetClauses` already accept exactly the editable fields.
- **(b) Reading A: full-edit.** Modal shows every field as an editable input. Server-side `buildRevertSetClauses` + `counter_payload` schema expand to accept title / description / notes / rule_code / event_type_ids (deadline) and title / description / location / appointment_type (appointment). Backend change. This loosens the t-paliad-138 lock-in that approval was triggered only by date changes — non-date counter-edits would now flow through 4-Augen too.
- (c) Hybrid: dates editable + a small editable subset (title / notes) + the rest read-only. Compromise; expand allowlist conservatively.
### Q2 — Modal primitive API shape
- **(a) Function-call returning a Promise (Recommended).** `openModal({title, body, primary, secondary, size}): Promise<T | null>`. Caller awaits, no per-instance state to manage. Matches the existing approval-edit-modal pattern.
- (b) Class with `.open()` / `.close()` / event listeners. More familiar to OO codebases but paliad's frontend is functional.
- (c) Component-API in TSX (`<Modal isOpen={…}>`). Would require wider TSX adoption in client code, which paliad isn't on today.
### Q3 — Substrate: native `<dialog>` vs div-based overlay
- **(a) Native `<dialog>` element (Recommended).** Browser handles ESC + focus + ARIA + top-layer stacking. Less code, fewer bugs, better a11y out-of-the-box.
- (b) Div-based overlay with manual focus trap + ESC + body-scroll lock. More code; full control over animation / transitions; doesn't depend on browser quirks. Trade-off: a11y bugs are easy to ship.
### Q4 — Migration scope in this PR
- **(a) Suggest-changes only (Recommended).** Primitive + one migrated call site. Smallest reviewable PR. Other modals migrate one-PR-per-modal as follow-ups when they need touching anyway.
- (b) Suggest-changes + broadcast.ts (the largest existing modal, biggest cleanup win). Doubles PR scope but demonstrates the primitive's generality.
- (c) Suggest-changes + all six other modals in one PR. High review cost, high regression risk; "rip the bandaid" approach.
### Q5 — Mobile UX
- **(a) Full-screen takeover at phone width (Recommended).** Modal expands to 100vw × 100vh below the 32rem breakpoint. Standard pattern; what most apps do.
- (b) Bottom-sheet (slides up from bottom, can be dragged down to close). Native-app vibe but a transition library is needed and dragging adds complexity.
- (c) Center-stage even on phone. Wastes horizontal space; rejected.
### Q6 — Typography baseline to match
- **(a) Match `/deadlines/new` + views editor form-field shapes (Recommended).** Existing form-field rules (label above input, 1rem gap, label `0.9rem`, value `1rem`). Already what most of the app uses.
- (b) New typographic scale just for modals. More work, divergent from the rest of the app.
---
## 6. m's decisions (filled in after AskUserQuestion)
_To be appended at §0a after the chip-picker calls return._
---
## 7. Implementation sketch (decisions-locked)
Five reviewable slices, one PR.
### Slice A — components/modal.ts + CSS block
The unified primitive. Standalone module; nothing else depends on it yet.
```ts
// frontend/src/client/components/modal.ts
export interface ModalConfig<T> {
title: string;
body: HTMLElement | string; // string = HTML, caller must pre-escape
primary: { label: string; handler: (close: (result: T) => void) => void };
secondary?: { label: string }; // defaults to "Abbrechen"
size?: "sm" | "md" | "lg" | "full"; // 380 / 480 / 640 / phone-takeover
onClose?: () => void;
classNames?: string;
}
export function openModal<T = void>(config: ModalConfig<T>): Promise<T | null>;
```
Internals:
- Builds a `<dialog class="modal" data-size="md">` with `__header`, `__body`, `__footer`.
- Calls `dialog.showModal()` — the browser activates top-layer, ESC dismissal, focus trap.
- Records the previously-focused element on open; restores it on close. (Native `<dialog>` doesn't do this automatically.)
- **History integration (Q5 constraint):** on open, `history.pushState({modal: true}, "")`. Attach a `popstate` listener that calls the close path (resolves null). On programmatic close (primary handler resolves, or secondary clicked), `history.back()` to pop the state. The listener is removed in the close path to avoid double-close.
- Backdrop click closes via the dialog's own click event (`if (e.target === dialog) close(null)`).
- ESC closes via the dialog's `cancel` event.
CSS block:
```css
dialog.modal {
border: none;
border-radius: calc(var(--radius) * 1.5);
box-shadow: var(--shadow-xl);
padding: 0;
background: var(--color-surface);
max-width: min(90vw, var(--modal-max-w, 480px));
max-height: min(90vh, 40rem);
}
dialog.modal::backdrop { background: var(--color-overlay-modal); }
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; }
@media (max-width: 32rem) {
dialog.modal { --modal-max-w: 100vw; border-radius: 0; }
/* Q5: full-screen modal must not cover the PWA bottom-nav. */
dialog.modal { max-height: calc(100vh - var(--bottom-nav-height, 3.5rem)); margin-bottom: var(--bottom-nav-height, 3.5rem); }
}
.modal__header { padding: 1.25rem 1.5rem 0.75rem; display: flex; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--color-border); }
.modal__title { font-size: 1.15rem; font-weight: 700; margin: 0; }
.modal__close { background: none; border: none; cursor: pointer; font-size: 1.5rem; color: var(--color-text-muted); padding: 0.25rem 0.5rem; }
.modal__body { padding: 1.25rem 1.5rem; overflow-y: auto; }
.modal__footer { padding: 0.75rem 1.5rem 1.25rem; display: flex; gap: 0.75rem; justify-content: flex-end; border-top: 1px solid var(--color-border); }
```
Close button is mandatory per Q5 — always rendered in the header.
### Slice B — backend expansion for full-edit counter_payload (Q1 Reading A)
m picked full-edit on Q1 — the server must accept counter_payload fields beyond the date allowlist. Three changes in `internal/services/approval_service.go`:
1. **Rename `buildRevertSetClauses` → `buildEntityFieldSetClauses`** and expand the per-entity-type allowlist:
- Deadline: existing `due_date`, `original_due_date`, `warning_date`, `status`, `completed_at` PLUS `title`, `description`, `notes`, `rule_code`, `event_type_ids` (the last is a junction table — needs separate handling).
- Appointment: existing `start_at`, `end_at`, `completed_at` PLUS `title`, `description`, `location`, `appointment_type`.
2. **Separate the "revert allowlist" from the "counter allowlist."** Reject's `applyRevert` and SuggestChanges's `applyEntityUpdate` should call slightly different helpers — Reject restores ONLY what's in the pre_image (defence-in-depth: a malformed pre_image must not be able to write arbitrary fields), while SuggestChanges writes WHATEVER the approver supplied in the counter (subject to the new expanded allowlist). Both share the per-entity-type list of "what's a real column."
3. **event_type_ids handling.** Junction-table column — needs `DELETE FROM paliad.deadline_event_types WHERE deadline_id=$1; INSERT ... FOR each new id` inside the same tx. Adds a few lines but no schema change. Skip if m wants to defer event_type editing in the modal (small extra Q at implementation time).
**The `applyEntityUpdate` call in SuggestChanges already exists from t-paliad-216 Slice A.** The expansion is a SQL-allowlist widening + a junction-table write for event_type_ids — no new approval_requests columns needed (the existing `counter_payload jsonb` already holds the wider shape).
Update the test suite: extend `TestApprovalService_SuggestChanges_HappyPath` and add new tests for title-only counter and notes-only counter to lock in the expanded allowlist.
### Slice C — approval-edit-modal.ts rewrite
Drops every per-modal handler (ESC, focus, backdrop) — the primitive owns them. Renders via `openModal()`. Body has the three-section layout from §2 with EVERY field editable per Q1:
- **Deadline editable fields:** title, description, due_date, original_due_date, warning_date, notes, rule_code, event_type_ids. Status + completed_at stay read-only (lifecycle-dependent).
- **Appointment editable fields:** title, description, start_at, end_at, location, appointment_type. completed_at stays read-only.
- **Read-only context (both):** project (link), created_at, requester (with timestamp), approval status pill ("offen / Genehmigung beantragt von X"), event-type chips for deadlines (if not editable here).
- **Always:** Vorschlagskommentar textarea (the existing `note`).
Layout uses block labels matching `/deadlines/new` (Q6). Submit disabled until dirty OR note has content (server-enforced `ErrSuggestionRequiresChange`).
### Slice D — broadcast.ts retrofit
Per Q4. The richest existing modal; demonstrates the primitive's generality. Replace its bespoke DOM construction with `openModal({title, body, primary, secondary, size: "lg"})`. The body keeps its existing layout — only the shell, ESC, close-button, backdrop wiring delegates to the primitive. Drop `.modal-broadcast` CSS overrides that the new primitive handles for free.
Verify the existing markdown-preview + template-picker + recipient-list features still work after the migration. Smoke: open it, fill a template, send.
### Slice E — i18n + CSS cleanup
Fill any i18n gaps in `deadlines.field.*` / `appointments.field.*` for the new read-only labels (description, location, appointment_type, etc. — most already exist). Add `modal.close.label` ("Schließen" / "Close"). Don't delete the legacy `.modal-overlay` / `.modal-card` / `.modal-content` / `.modal` CSS yet — the other 6 unmigrated modals still depend on them.
### Total scope
Five slices, one PR. Backend expansion (Slice B) is the only schema-adjacent piece and stays SQL-only (no migration needed — `counter_payload jsonb` already accepts arbitrary shape; the change is in the column-allowlist regex/switch on read).
Coder shift gating per project CLAUDE.md.
---
## 8. Out of scope
- Generic form-builder framework — the modal is the **frame**, not the body content.
- Visual redesign of any non-modal surface.
- Migrating the other 7 modals — each becomes its own PR after this one lands.
- Adding new modals to surfaces that don't currently have one.
- Animation / transition library — modals stay non-animated for v1 (the dialog API has its own animation story for a follow-up).
---
## 9. Risks / open considerations
- **Native `<dialog>` quirks.** Older Safari versions (≤ 15.3) had bugs with `::backdrop` and top-layer focus management. paliad's browser baseline is current evergreen so this should be fine, but the first PR includes a CSS smoke (verify backdrop renders + ESC closes + focus lands on first input) on Chrome / Firefox / Safari.
- **CSS legacy debt.** The unified primitive co-exists with `.modal-overlay` / `.modal-card` / `.modal-content` / `.modal` during the migration window. Final cleanup is a separate PR after the last legacy modal flips. Don't delete the legacy classes early — that would break unmigrated surfaces.
- **Focus restoration on close.** `<dialog>.close()` does NOT restore focus to the previously-focused element by default — we manually record + restore on open / close. Easy to forget.
- **Form-element name collisions.** When multiple modals could in principle stack, ID collisions on `<input id="..."` matter. The primitive should scope IDs per-instance (UUID prefix or counter) or use data-attrs only. The existing approval-edit-modal uses `data-suggest-field` already; the primitive should make that idiomatic.
- **i18n coverage for context-section labels.** Most fields have `deadlines.field.*` / `appointments.field.*` keys; a few may not (e.g. created_at). Gap-fill in this PR rather than punting to the coder.
- **m's "all information" reading.** If m picks Reading A (full-edit) on Q1, this becomes a t-paliad-138 policy change — non-date counter-edits would now flow through 4-Augen. That's a real product decision, not just a UI choice. Flag it explicitly when posing the question.

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

@@ -2113,6 +2113,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",
@@ -2244,6 +2245,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",
@@ -4726,6 +4738,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",
@@ -4857,6 +4870,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)
}
}