Compare commits
6 Commits
mai/tesla/
...
mai/hertz/
| Author | SHA1 | Date | |
|---|---|---|---|
| 5fe0272d6d | |||
| 455af36bc8 | |||
| 1bf64213ae | |||
| abab97ea33 | |||
| d438da2c39 | |||
| e505126e8d |
415
docs/design-modal-pattern-2026-05-20.md
Normal file
415
docs/design-modal-pattern-2026-05-20.md
Normal 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 793–815). |
|
||||
| `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.
|
||||
@@ -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) + " <" + esc(r.email) + ">")
|
||||
.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")}">×</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})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"))}">×</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 "&";
|
||||
case "<": return "<";
|
||||
case ">": return ">";
|
||||
case '"': return """;
|
||||
default: return c;
|
||||
}
|
||||
});
|
||||
function formatDateForDisplay(iso: string): string {
|
||||
const d = Date.parse(iso);
|
||||
if (isNaN(d)) return iso;
|
||||
return new Date(d).toLocaleString();
|
||||
}
|
||||
|
||||
200
frontend/src/client/components/modal.ts
Normal file
200
frontend/src/client/components/modal.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user