@eifi1/ui-kit 0.26.0 → 0.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (158) hide show
  1. package/README.md +15 -4
  2. package/dist/chart.d.ts +13 -9
  3. package/dist/components/account-chips.d.ts +1 -1
  4. package/dist/components/amount-input.d.ts +13 -9
  5. package/dist/components/button-group.d.ts +13 -9
  6. package/dist/components/calculator.d.ts +13 -9
  7. package/dist/components/confirm-dialog.d.ts +13 -9
  8. package/dist/components/copy-button.d.ts +10 -6
  9. package/dist/components/danger-confirm.d.ts +13 -9
  10. package/dist/components/data-table-cells.d.ts +1 -1
  11. package/dist/components/data-table-filter-popover.d.ts +1 -1
  12. package/dist/components/data-table-filters.d.ts +1 -1
  13. package/dist/components/data-table-labels.d.ts +1 -1
  14. package/dist/components/data-table-pagination.d.ts +1 -1
  15. package/dist/components/data-table.d.ts +1 -1
  16. package/dist/components/data-table.js.map +1 -1
  17. package/dist/components/facing-pair.d.ts +13 -9
  18. package/dist/components/file-button.d.ts +13 -9
  19. package/dist/components/file-dropzone.d.ts +13 -9
  20. package/dist/components/form-actions.d.ts +13 -9
  21. package/dist/components/iban-input.d.ts +13 -9
  22. package/dist/components/language-select.d.ts +13 -9
  23. package/dist/components/money-field.d.ts +13 -9
  24. package/dist/components/number-field.d.ts +13 -9
  25. package/dist/components/number-input.d.ts +13 -9
  26. package/dist/components/numpad-sheet.d.ts +13 -9
  27. package/dist/components/phone-input.d.ts +13 -9
  28. package/dist/components/series-chart.d.ts +13 -9
  29. package/dist/components/settings-fields.d.ts +13 -9
  30. package/dist/components/share-card.d.ts +13 -9
  31. package/dist/components/text-link.d.ts +13 -9
  32. package/dist/components/time-input.d.ts +13 -9
  33. package/dist/components/ui.d.ts +10 -6
  34. package/dist/components/use-table-state.d.ts +1 -1
  35. package/dist/{data-table-labels-Cdn1QS2k.d.ts → data-table-labels-DUELeXpa.d.ts} +5 -0
  36. package/dist/data-table.d.ts +1 -1
  37. package/dist/feedback/feedback-attachment.d.ts +13 -9
  38. package/dist/feedback/feedback-attachment.js +3 -3
  39. package/dist/feedback/feedback-attachment.js.map +1 -1
  40. package/dist/feedback/feedback-capture.d.ts +71 -0
  41. package/dist/feedback/feedback-capture.js +194 -0
  42. package/dist/feedback/feedback-capture.js.map +1 -0
  43. package/dist/feedback/feedback-context.d.ts +71 -0
  44. package/dist/feedback/feedback-context.js +96 -0
  45. package/dist/feedback/feedback-context.js.map +1 -0
  46. package/dist/feedback/feedback-crash.d.ts +276 -0
  47. package/dist/feedback/feedback-crash.js +225 -0
  48. package/dist/feedback/feedback-crash.js.map +1 -0
  49. package/dist/feedback/feedback-dialog.d.ts +13 -9
  50. package/dist/feedback/feedback-dialog.js +5 -3
  51. package/dist/feedback/feedback-dialog.js.map +1 -1
  52. package/dist/feedback/feedback-inbox.d.ts +13 -9
  53. package/dist/feedback/feedback-inbox.js +89 -49
  54. package/dist/feedback/feedback-inbox.js.map +1 -1
  55. package/dist/feedback/feedback-labels.d.ts +71 -0
  56. package/dist/feedback/feedback-labels.js +48 -0
  57. package/dist/feedback/feedback-labels.js.map +1 -0
  58. package/dist/feedback/feedback-menu.d.ts +71 -0
  59. package/dist/feedback/feedback-menu.js +61 -0
  60. package/dist/feedback/feedback-menu.js.map +1 -0
  61. package/dist/feedback/feedback-record.d.ts +71 -0
  62. package/dist/feedback/feedback-record.js +104 -0
  63. package/dist/feedback/feedback-record.js.map +1 -0
  64. package/dist/feedback/feedback-row-detail.d.ts +71 -0
  65. package/dist/feedback/feedback-row-detail.js +437 -0
  66. package/dist/feedback/feedback-row-detail.js.map +1 -0
  67. package/dist/feedback/feedback-status-undo.d.ts +71 -0
  68. package/dist/feedback/feedback-status-undo.js +40 -0
  69. package/dist/feedback/feedback-status-undo.js.map +1 -0
  70. package/dist/feedback/feedback-submit.d.ts +182 -0
  71. package/dist/feedback/feedback-submit.js +101 -0
  72. package/dist/feedback/feedback-submit.js.map +1 -0
  73. package/dist/feedback/feedback-swipe.d.ts +152 -0
  74. package/dist/feedback/feedback-swipe.js +73 -0
  75. package/dist/feedback/feedback-swipe.js.map +1 -0
  76. package/dist/feedback/feedback-table.d.ts +71 -0
  77. package/dist/feedback/feedback-table.js +357 -0
  78. package/dist/feedback/feedback-table.js.map +1 -0
  79. package/dist/feedback/feedback-thread.d.ts +13 -9
  80. package/dist/feedback.d.ts +17 -9
  81. package/dist/feedback.js +11 -0
  82. package/dist/feedback.js.map +1 -1
  83. package/dist/hooks/use-file-drop.d.ts +13 -9
  84. package/dist/i18n/defaults.d.ts +13 -9
  85. package/dist/i18n/defaults.js +16 -0
  86. package/dist/i18n/defaults.js.map +1 -1
  87. package/dist/i18n/german.d.ts +13 -9
  88. package/dist/i18n/german.js +91 -5
  89. package/dist/i18n/german.js.map +1 -1
  90. package/dist/i18n/kit-labels.d.ts +7 -3
  91. package/dist/i18n/kit-labels.js.map +1 -1
  92. package/dist/i18n/languages.d.ts +13 -9
  93. package/dist/i18n/locales/de-CH.d.ts +13 -9
  94. package/dist/i18n/locales/en.d.ts +13 -9
  95. package/dist/i18n/locales/en.js +10 -1
  96. package/dist/i18n/locales/en.js.map +1 -1
  97. package/dist/i18n/locales/es.d.ts +13 -9
  98. package/dist/i18n/locales/es.js +89 -5
  99. package/dist/i18n/locales/es.js.map +1 -1
  100. package/dist/i18n/locales/fr.d.ts +13 -9
  101. package/dist/i18n/locales/fr.js +89 -5
  102. package/dist/i18n/locales/fr.js.map +1 -1
  103. package/dist/i18n/locales/hu.d.ts +13 -9
  104. package/dist/i18n/locales/hu.js +90 -5
  105. package/dist/i18n/locales/hu.js.map +1 -1
  106. package/dist/i18n/locales/it.d.ts +13 -9
  107. package/dist/i18n/locales/it.js +89 -5
  108. package/dist/i18n/locales/it.js.map +1 -1
  109. package/dist/i18n/locales/zh.d.ts +13 -9
  110. package/dist/i18n/locales/zh.js +89 -5
  111. package/dist/i18n/locales/zh.js.map +1 -1
  112. package/dist/i18n/review.d.ts +16 -11
  113. package/dist/i18n/review.js +11 -0
  114. package/dist/i18n/review.js.map +1 -1
  115. package/dist/i18n/swiss.js +3 -1
  116. package/dist/i18n/swiss.js.map +1 -1
  117. package/dist/index.d.ts +6 -2
  118. package/dist/index.js +11 -0
  119. package/dist/index.js.map +1 -1
  120. package/dist/{feedback-BXKH-ToU.d.ts → kit-labels-DQg82eHB.d.ts} +1275 -25
  121. package/dist/rhf/fields.d.ts +13 -9
  122. package/dist/rhf/form.d.ts +13 -9
  123. package/dist/rhf.d.ts +13 -9
  124. package/dist/shell/app-shell.d.ts +13 -9
  125. package/dist/shell/top-bar-brand.d.ts +13 -9
  126. package/dist/shell.d.ts +11 -11
  127. package/dist/wizard/stepper-nav.d.ts +13 -9
  128. package/dist/wizard.d.ts +13 -9
  129. package/package.json +6 -1
  130. package/src/components/data-table.tsx +5 -0
  131. package/src/feedback/feedback-attachment.tsx +19 -3
  132. package/src/feedback/feedback-capture.ts +285 -0
  133. package/src/feedback/feedback-context.tsx +217 -0
  134. package/src/feedback/feedback-crash.ts +547 -0
  135. package/src/feedback/feedback-dialog.tsx +29 -6
  136. package/src/feedback/feedback-inbox.tsx +131 -27
  137. package/src/feedback/feedback-labels.ts +127 -0
  138. package/src/feedback/feedback-menu.tsx +132 -0
  139. package/src/feedback/feedback-record.ts +345 -0
  140. package/src/feedback/feedback-row-detail.tsx +825 -0
  141. package/src/feedback/feedback-status-undo.ts +111 -0
  142. package/src/feedback/feedback-submit.tsx +224 -0
  143. package/src/feedback/feedback-swipe.tsx +170 -0
  144. package/src/feedback/feedback-table.tsx +757 -0
  145. package/src/feedback.ts +13 -0
  146. package/src/i18n/defaults.ts +16 -0
  147. package/src/i18n/german.ts +98 -5
  148. package/src/i18n/kit-labels.tsx +20 -0
  149. package/src/i18n/locales/en.ts +15 -1
  150. package/src/i18n/locales/es.ts +96 -5
  151. package/src/i18n/locales/fr.ts +95 -5
  152. package/src/i18n/locales/hu.ts +94 -5
  153. package/src/i18n/locales/it.ts +97 -5
  154. package/src/i18n/locales/zh.ts +90 -5
  155. package/src/i18n/review.ts +14 -2
  156. package/src/i18n/swiss.ts +5 -1
  157. package/src/index.ts +13 -0
  158. package/tokens.css +4 -0
@@ -0,0 +1,825 @@
1
+ import { useEffect, useRef, useState } from "react";
2
+ import type { MouseEvent } from "react";
3
+ import { Download, ExternalLink, FileText, RotateCcw } from "lucide-react";
4
+ import { AuthedImage } from "../components/authed-image";
5
+ import { CopyButton } from "../components/copy-button";
6
+ import { TextLink } from "../components/text-link";
7
+ import { toast } from "../components/toast";
8
+ import { Button } from "../components/ui";
9
+ import type { AuthedFetcher } from "../hooks/use-authed-src";
10
+ import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
11
+ import { formatDate } from "../lib/format";
12
+ import { DEFAULT_MAX_ATTACHMENT_BYTES } from "./feedback-attachment";
13
+ import { DEFAULT_FEEDBACK_DIALOG_LABELS } from "./feedback-dialog";
14
+ import {
15
+ FeedbackDetail,
16
+ FeedbackDetailSection,
17
+ FeedbackNoteEditor,
18
+ FeedbackProse,
19
+ FeedbackStatusTransitions,
20
+ selectableFeedbackStatuses,
21
+ visibleFeedbackStatuses,
22
+ } from "./feedback-inbox";
23
+ import type { FeedbackNoteAttachment, FeedbackStatus } from "./feedback-inbox";
24
+ import { useFeedbackToastLabels } from "./feedback-labels";
25
+ import {
26
+ FEEDBACK_ATTACHMENT_ACCEPT,
27
+ FEEDBACK_AUTHOR_EDITABLE_STATUSES,
28
+ FEEDBACK_REWORKABLE_STATUSES,
29
+ appendRework,
30
+ attachmentName,
31
+ isImageAttachment,
32
+ replaceDescription,
33
+ splitBodyAttachments,
34
+ splitDescription,
35
+ } from "./feedback-record";
36
+ import type { FeedbackContext, FeedbackRecord } from "./feedback-record";
37
+ import { useFeedbackStatusUndo } from "./feedback-status-undo";
38
+ import type { FeedbackStatusChange } from "./feedback-status-undo";
39
+
40
+ /**
41
+ * One report, opened — the panel a feedback table unfolds under its row (§4.4 of
42
+ * docs/feedback-harmonization.md), lifted from keksdose's `FeedbackRow`
43
+ * (`frontend/src/features/feedback/feedback-page.tsx:329-612`) with the contract's
44
+ * changes applied.
45
+ *
46
+ * Until 0.27 each app wrote this panel itself out of the kit's shell
47
+ * (`FeedbackDetail` / `FeedbackDetailSection` / `FeedbackNoteEditor`), and the three
48
+ * copies had drifted in exactly the places a user notices:
49
+ *
50
+ * - **the description editor held the raw body** — keksdose `initial={fb.body}`
51
+ * (`:420`), kastlan the same (`pages/feedback-page.tsx:226`) — so an author fixing a
52
+ * typo could delete the `--- REWORK … ---` rounds and the `[screenshot]` file lines
53
+ * with it, and the history was gone. Here the editor holds the ORIGINAL description
54
+ * only ({@link splitDescription}) and saves through {@link replaceDescription}, which
55
+ * puts every appended block and file line back unchanged;
56
+ * - **kastlan's rework sent `status: "OPEN"`** from the client (`:189-195`) and offered it
57
+ * on three statuses; keksdose sends the body alone and lets the server reopen the row
58
+ * on five (live #331). The contract is keksdose's (§3.4) — {@link appendRework}, one
59
+ * file, the server reopens;
60
+ * - **kastlan showed only `screenshot_url`** (`:265-276`) and no downloads; keksdose the
61
+ * screenshot, the other files and the rework pictures, a PDF as a download (dev #578);
62
+ * - **the URL fell away on Kurvenschmiede's rows**, whose backfilled `context.url` is a
63
+ * path (`/accounts?p=2`): keksdose's `new URL(…)` throws on one and the section
64
+ * vanished. kastlan already fell back to `context.route` (`feedbackRaw`, `:97`).
65
+ *
66
+ * The sections, in the contract's order: **Description** (the body; the author's
67
+ * Edit while OPEN / IN_PROGRESS), **URL**, **Attachment**, **Outcome** (the admin's
68
+ * outcome editor, Rework, Open page), **Send for rework** (only while reworking) and
69
+ * **Status**. No comment thread (§2.2: kastlan's comments fold into the body, §7.7).
70
+ *
71
+ * The app keeps its data and its API: the row it renders, the PATCH (`onUpdate`), the
72
+ * upload (`onUpload`), the authenticated GET (`fetcher`) and who is looking (`canEdit`,
73
+ * `viewerId`). Every word is the kit's `feedbackDetail` namespace.
74
+ */
75
+
76
+ /* ── Labels ───────────────────────────────────────────────────────────────── */
77
+
78
+ /**
79
+ * `feedbackDetail` — the row detail's words (§4.4). English: keksdose's `en.json`
80
+ * (`feedback.*`, `common.*`, `more.copy_url`) with the contract's changes; the de-CH
81
+ * canon (keksdose `de-CH.json`, ss never ß, "Nacharbeit" throughout — §7.14) on each key.
82
+ */
83
+ export interface FeedbackDetailLabels {
84
+ /** Section 1's heading — keksdose's `feedback.body` (§7.3). The dialog's field asks
85
+ * "What happened? (optional)"; the description, once filed, is headed as what it is
86
+ * — and as the author's "Edit description" calls it. de-CH: "Beschreibung" */
87
+ body: string;
88
+ /** The author's button on that heading. de-CH: "Bearbeiten" */
89
+ edit: string;
90
+ /** Its accessible name — says WHAT is edited; contains the visible word.
91
+ * de-CH: "Beschreibung bearbeiten" */
92
+ editDescription: string;
93
+ /** The editors' save button (description, outcome). de-CH: "Speichern" */
94
+ save: string;
95
+ /** Every editor's cancel button. de-CH: "Abbrechen" */
96
+ cancel: string;
97
+ /** Section 2's heading. de-CH: "URL" */
98
+ url: string;
99
+ /** The copy button beside the link — copies the FULL `context.url`.
100
+ * de-CH: "URL kopieren" */
101
+ copyUrl: string;
102
+ /** Section 3's heading, and each picture's alt text. de-CH: "Anhang" */
103
+ attachment: string;
104
+ /** A PDF's / text file's download button, given the file's name.
105
+ * de-CH: "{{name}} herunterladen" */
106
+ download: (name: string) => string;
107
+ /** The toast when that download failed. de-CH: "Der Anhang konnte nicht
108
+ * heruntergeladen werden." */
109
+ downloadFailed: string;
110
+ /** Section 4's heading. de-CH: "Ergebnis" */
111
+ outcome: string;
112
+ /** On section 4's heading line once the row is settled, given the formatted
113
+ * `resolved_at` date. de-CH: "Erledigt am: {{date}}" */
114
+ resolvedAt: (date: string) => string;
115
+ /** The admin's button while there is no outcome. de-CH: "Ergebnis hinzufügen" */
116
+ outcomeAdd: string;
117
+ /** The same button once there is one. de-CH: "Aktualisieren" */
118
+ outcomeUpdate: string;
119
+ /** The line above the outcome editor. de-CH: "Was wurde umgesetzt, entschieden oder
120
+ * warum nicht." */
121
+ outcomePlaceholder: string;
122
+ /** The button that opens section 5 on an answered row. de-CH: "Nacharbeit" */
123
+ rework: string;
124
+ /** The link to the page the report was filed on. de-CH: "Seite öffnen" */
125
+ openPage: string;
126
+ /** Section 5's heading. de-CH: "Zur Nacharbeit senden" */
127
+ reworkTitle: string;
128
+ /** Section 5's send button. de-CH: "Nacharbeit senden" */
129
+ reworkSend: string;
130
+ /** The line above the rework note. de-CH: "Was muss noch angepasst werden? Neue
131
+ * Anforderungen oder Richtungswechsel." */
132
+ reworkPlaceholder: string;
133
+ /** The toast when the rework's file did not upload — and so nothing was sent. Names
134
+ * no screenshot: it fires for a PDF or a log too (§7.14; keksdose's said
135
+ * "screenshot"). de-CH: "Die Datei konnte nicht hochgeladen werden. Die Nacharbeit
136
+ * wurde nicht gesendet." */
137
+ reworkUploadFailed: string;
138
+ /** Section 6's heading. de-CH: "Status" */
139
+ status: string;
140
+ }
141
+
142
+ export const DEFAULT_FEEDBACK_DETAIL_LABELS: FeedbackDetailLabels = {
143
+ body: "Description",
144
+ edit: "Edit",
145
+ editDescription: "Edit description",
146
+ save: "Save",
147
+ cancel: "Cancel",
148
+ url: "URL",
149
+ copyUrl: "Copy URL",
150
+ attachment: "Attachment",
151
+ download: (name) => `Download ${name}`,
152
+ downloadFailed: "The attachment could not be downloaded.",
153
+ outcome: "Outcome",
154
+ resolvedAt: (date) => `Resolved: ${date}`,
155
+ outcomeAdd: "Add outcome",
156
+ outcomeUpdate: "Update",
157
+ outcomePlaceholder: "What was done, decided, or why this won't be addressed.",
158
+ rework: "Rework",
159
+ openPage: "Open page",
160
+ reworkTitle: "Send for rework",
161
+ reworkSend: "Send rework",
162
+ reworkPlaceholder: "What still needs refinement? Any new constraints or change of direction.",
163
+ reworkUploadFailed: "The file could not be uploaded. The rework was not sent.",
164
+ status: "Status",
165
+ };
166
+
167
+ /** `feedbackDetail` resolved: English, then `<UiKitProvider labels>`, then `labels`. */
168
+ export function useFeedbackDetailLabels(labels?: Partial<FeedbackDetailLabels>): FeedbackDetailLabels {
169
+ return useKitLabels("feedbackDetail", DEFAULT_FEEDBACK_DETAIL_LABELS, labels);
170
+ }
171
+
172
+ /* ── The page a report was filed on (§3.2, §4.4.2) ────────────────────────── */
173
+
174
+ /** `context.url`, else `context.route` — a parsed URL, or null when neither is set or
175
+ * the value is no http(s) address or path (a `javascript:` value is not a page). */
176
+ function parsePage(context: FeedbackContext | null | undefined): URL | null {
177
+ const raw = (context?.url || context?.route || "").trim();
178
+ if (!raw) return null;
179
+ try {
180
+ // A base, so a PATH-ONLY value parses too: Kurvenschmiede backfills its old rows
181
+ // with `url = page_path + search` (§3.2), and keksdose's `new URL(raw)` threw on
182
+ // those and dropped the section.
183
+ const url = new URL(raw, "http://localhost");
184
+ return url.protocol === "http:" || url.protocol === "https:" ? url : null;
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+
190
+ /** Collapses a leading `//` run: `/\/\/evil.example/` as a path would be a
191
+ * protocol-relative link off the app. */
192
+ const localPath = (pathname: string) => pathname.replace(/^\/{2,}/, "/");
193
+
194
+ /**
195
+ * The in-app link to the page a report was filed on — path + query + hash (keksdose
196
+ * `localHref`: the query and hash carry the state worth restoring, `?tab=import`, a
197
+ * drill-down). Read from `context.url`, else `context.route`; a path-only `url` works.
198
+ * Always a path on THIS app, never another origin's address, so it is safe as an
199
+ * `href` whatever the row says. `null` when there is nothing to link.
200
+ */
201
+ export function feedbackPageHref(context: FeedbackContext | null | undefined): string | null {
202
+ const url = parsePage(context);
203
+ return url ? `${localPath(url.pathname)}${url.search}${url.hash}` : null;
204
+ }
205
+
206
+ /** The page's path alone — the compact text a URL column shows (§4.3). */
207
+ export function feedbackPagePath(context: FeedbackContext | null | undefined): string | null {
208
+ const url = parsePage(context);
209
+ return url ? localPath(url.pathname) : null;
210
+ }
211
+
212
+ /**
213
+ * What "Copy URL" copies: the FULL `context.url` as the reporter's browser had it —
214
+ * origin included, which is the one part that says which copy of the app it was
215
+ * (feedback #278) — else `context.route`. `null` when there is no page.
216
+ */
217
+ export function feedbackPageUrl(context: FeedbackContext | null | undefined): string | null {
218
+ if (!parsePage(context)) return null;
219
+ return (context?.url || context?.route || "").trim();
220
+ }
221
+
222
+ /* ── Attachments (§3.5, §4.4.3) ───────────────────────────────────────────── */
223
+
224
+ /**
225
+ * Every file a report carries, in the order the detail shows them (§4.4.3): the
226
+ * screenshot it was filed with, then its other files, then whatever the rework rounds
227
+ * brought, in body order (keksdose dev #578, live #291). One list rather than three —
228
+ * a reader looking for "the picture" should not have to know at which point in the
229
+ * conversation it was taken.
230
+ *
231
+ * Each URL once: keksdose and Kurvenschmiede content-address their keys, so the same
232
+ * screenshot pasted twice is one URL — shown twice it is the same picture twice (and,
233
+ * keyed by URL, a React key clash keksdose had).
234
+ */
235
+ export function feedbackAttachmentUrls(
236
+ row: Pick<FeedbackRecord, "screenshot_url" | "attachment_urls" | "body">,
237
+ ): string[] {
238
+ const urls = [
239
+ ...(row.screenshot_url ? [row.screenshot_url] : []),
240
+ // `?? []`: NULL on rows filed before the column, and no key at all from a server
241
+ // that predates it (keksdose dev #578).
242
+ ...(row.attachment_urls ?? []),
243
+ ...splitBodyAttachments(row.body).urls,
244
+ ];
245
+ return [...new Set(urls)];
246
+ }
247
+
248
+ /** The bytes behind a fetcher's answer — a `Blob` as it is, a `Response` checked. */
249
+ async function toBlob(answer: Blob | Response): Promise<Blob> {
250
+ if (typeof Response !== "undefined" && answer instanceof Response) {
251
+ if (!answer.ok) throw new Error(`HTTP ${answer.status}`);
252
+ return answer.blob();
253
+ }
254
+ return answer as Blob;
255
+ }
256
+
257
+ /** No fetcher: the browser's own `fetch`, with the cookies a same-origin app uses. */
258
+ const plainFetch: AuthedFetcher = (url, { signal }) => fetch(url, { signal, credentials: "same-origin" });
259
+
260
+ /**
261
+ * A PDF or a text log (keksdose `FeedbackFileDownload`, dev #578): the bytes sit behind
262
+ * an authenticated route that serves them as `attachment`, never inline (§3.5), so a
263
+ * plain link would be a 401 the moment it was opened. Fetched through the app's
264
+ * fetcher and handed to a synthetic anchor; a failure says so in a toast.
265
+ */
266
+ function AttachmentDownload({
267
+ url,
268
+ fetcher,
269
+ labels,
270
+ }: {
271
+ url: string;
272
+ fetcher: AuthedFetcher;
273
+ labels: FeedbackDetailLabels;
274
+ }) {
275
+ const [busy, setBusy] = useState(false);
276
+ const name = attachmentName(url);
277
+ const download = async (event: MouseEvent) => {
278
+ // The row around the detail may toggle on a click.
279
+ event.stopPropagation();
280
+ setBusy(true);
281
+ try {
282
+ const blob = await toBlob(await fetcher(url, { signal: new AbortController().signal }));
283
+ const href = URL.createObjectURL(blob);
284
+ const anchor = document.createElement("a");
285
+ anchor.href = href;
286
+ anchor.download = name;
287
+ anchor.click();
288
+ // After the click has handed the URL to the download, not before.
289
+ setTimeout(() => URL.revokeObjectURL(href), 0);
290
+ } catch {
291
+ toast.error(labels.downloadFailed);
292
+ } finally {
293
+ setBusy(false);
294
+ }
295
+ };
296
+ return (
297
+ <Button
298
+ type="button"
299
+ variant="secondary"
300
+ size="sm"
301
+ pending={busy}
302
+ onClick={(event) => void download(event)}
303
+ aria-label={labels.download(name)}
304
+ className="max-w-full"
305
+ >
306
+ <FileText className="size-3.5 shrink-0" aria-hidden />
307
+ <span className="min-w-0 max-w-[14rem] truncate">{name}</span>
308
+ <Download className="size-3.5 shrink-0" aria-hidden />
309
+ </Button>
310
+ );
311
+ }
312
+
313
+ /* ── Who may do what (§3.4, §4.3, §4.4) ───────────────────────────────────── */
314
+
315
+ /** What the person looking at a row may do with it — {@link feedbackRowAccess}. */
316
+ export interface FeedbackRowAccess {
317
+ /** The viewer filed it (`viewerId === row.user_id`; never for an erased author). */
318
+ isAuthor: boolean;
319
+ /** "Edit description": the author, while the row is OPEN or IN_PROGRESS — on either
320
+ * page (keksdose `canAuthorEdit`, `feedback-page.tsx:398`, is not gated on `mine`). */
321
+ canEditDescription: boolean;
322
+ /** The outcome editor: the admin on the inbox (`canEdit`). */
323
+ canEditOutcome: boolean;
324
+ /** "Rework": the admin or the author, on an answered row
325
+ * ({@link FEEDBACK_REWORKABLE_STATUSES}) — §3.4, anyone else gets the server's 403. */
326
+ canRework: boolean;
327
+ /** The status pills: the admin on the inbox; everyone else sees them read-only. */
328
+ canChangeStatus: boolean;
329
+ }
330
+
331
+ /**
332
+ * The detail's permission rules in one place — the same answers the server gives
333
+ * (§3.4), so the panel never offers what the PATCH would refuse.
334
+ *
335
+ * `canEdit` is the app's "admin on `/feedback`": an admin on `/my-feedback` edits no
336
+ * status and no outcome there (§4.3), but as the author still edits the description
337
+ * and sends for rework. `viewerId` is the signed-in user's id.
338
+ */
339
+ export function feedbackRowAccess(
340
+ row: Pick<FeedbackRecord, "user_id" | "status">,
341
+ { canEdit, viewerId }: { canEdit: boolean; viewerId?: number | null },
342
+ ): FeedbackRowAccess {
343
+ // `user_id: null` is an erased author (keksdose live #275): nobody is that author,
344
+ // least of all a viewer whose id happens to be missing too.
345
+ const isAuthor = row.user_id !== null && viewerId !== undefined && viewerId !== null && viewerId === row.user_id;
346
+ return {
347
+ isAuthor,
348
+ canEditDescription: isAuthor && FEEDBACK_AUTHOR_EDITABLE_STATUSES.includes(row.status),
349
+ canEditOutcome: canEdit,
350
+ canRework: (canEdit || isAuthor) && FEEDBACK_REWORKABLE_STATUSES.includes(row.status),
351
+ canChangeStatus: canEdit,
352
+ };
353
+ }
354
+
355
+ /* ── The PATCH ─────────────────────────────────────────────────────────────── */
356
+
357
+ /** What the detail sends as `PATCH /feedback/{id}` (§3.4): the body (a description
358
+ * edit, a rework append) or the outcome — and, through {@link useFeedbackStatusUndo}
359
+ * when no `statusChange` is given, the status. Never two at once: a rework PATCH must
360
+ * carry the body ALONE for the server to read it as an append (keksdose live #331). */
361
+ export interface FeedbackDetailPatch {
362
+ id: number;
363
+ body?: string;
364
+ /** `null` clears it — a real edit. */
365
+ outcome?: string | null;
366
+ status?: FeedbackStatus;
367
+ }
368
+
369
+ /**
370
+ * The page's update mutation. **TanStack Query's `mutate` fits as it stands** — pass
371
+ * `update.mutate` (and `pending={update.isPending}`), the same function
372
+ * `useFeedbackStatusUndo` takes. A hand-written one sends the PATCH and calls
373
+ * `onSuccess` once it has landed, and only then: that is when an editor closes. A
374
+ * failure is the mutation's own to toast — the server's `detail`, else
375
+ * `feedbackToast.updateFailed` (§4.4) — and leaves the editor open with its draft.
376
+ */
377
+ export type FeedbackDetailUpdate = (patch: FeedbackDetailPatch, callbacks: { onSuccess: () => void }) => void;
378
+
379
+ /** `POST /feedback/attachments` (§3.3): one file up, its URL
380
+ * (`/api/v1/feedback/attachments/<key>`) back. Reject on failure. */
381
+ export type FeedbackUpload = (file: File) => Promise<string>;
382
+
383
+ /* ── Send for rework (§4.4.5) ─────────────────────────────────────────────── */
384
+
385
+ export interface FeedbackReworkSectionProps {
386
+ /** The row being sent back: its id, and the body the round is appended to. */
387
+ row: Pick<FeedbackRecord, "id" | "body">;
388
+ /** The PATCH — see {@link FeedbackDetailUpdate}. Called with `{ id, body }` only. */
389
+ onUpdate: FeedbackDetailUpdate;
390
+ /** The PATCH is in flight (`update.isPending`). The upload adds its own. */
391
+ pending?: boolean;
392
+ /** The upload. Left out, the note goes without a file field. */
393
+ onUpload?: FeedbackUpload;
394
+ /** Snapshot the app view as a File — the kit's `captureAppScreenshot`, or the app's
395
+ * own. A "Capture screenshot" button appears only with it; a rejection (no
396
+ * `modern-screenshot`, a canvas that would not draw) toasts
397
+ * `feedbackToast.captureFailed` and adds nothing. */
398
+ onCaptureScreenshot?: () => Promise<File | null>;
399
+ /** The round landed — the server has the note and, on an answered row, reopened it. */
400
+ onSent?: () => void;
401
+ onCancel: () => void;
402
+ /** Over the field's defaults: the contract's types and 10 MB (§3.5), the
403
+ * `feedbackToast` refusals, the `feedbackAttachment` words. */
404
+ attachment?: Partial<FeedbackNoteAttachment>;
405
+ /** The send COMMITS — see `FeedbackNoteEditor`'s `commit`. */
406
+ commit?: boolean;
407
+ labels?: Partial<FeedbackDetailLabels>;
408
+ }
409
+
410
+ /**
411
+ * The rework editor (§4.4.5): the submitter — or the admin — sends an answered report
412
+ * back with a note and at most one file, and the server reopens it (§3.4, keksdose
413
+ * `submitRework`, `feedback-page.tsx:350`).
414
+ *
415
+ * - **The note is required** and trimmed: Send stays disabled on a blank box (a file
416
+ * alone cannot be sent; keksdose `:577` dropped that click silently).
417
+ * - **One file**, added, captured or pasted — the dialog's types and 10 MB. It is
418
+ * uploaded FIRST and awaited: a body naming a file that never arrived would be a
419
+ * rework whose evidence is missing, and the upload is the half that fails on its
420
+ * own. If it fails nothing is sent and the toast says so; the note stays in the box.
421
+ * - **The body alone goes in the PATCH** — {@link appendRework}'s strict extension of
422
+ * the current body, which is how the server recognises the append and reopens the
423
+ * row. Never a `status` (kastlan's client sent `OPEN`; an author may not write one,
424
+ * so the server refused the whole PATCH, note and all — keksdose live #331).
425
+ *
426
+ * Exported on its own for an app that offers rework somewhere other than the row
427
+ * detail. Kurvenschmiede's author verdict ("That is it, done") is NOT here: an answered
428
+ * item stays answered unless its author sends it back (§7.2).
429
+ */
430
+ export function FeedbackReworkSection({
431
+ row,
432
+ onUpdate,
433
+ pending = false,
434
+ onUpload,
435
+ onCaptureScreenshot,
436
+ onSent,
437
+ onCancel,
438
+ attachment,
439
+ commit,
440
+ labels: labelsProp,
441
+ }: FeedbackReworkSectionProps) {
442
+ const labels = useFeedbackDetailLabels(labelsProp);
443
+ const toasts = useFeedbackToastLabels();
444
+ const [uploading, setUploading] = useState(false);
445
+ // The body as it is NOW, not as it was when Send was pressed: the list can refresh
446
+ // while the file uploads, and an append to a stale body is no longer a strict
447
+ // extension of the stored one — the server would read it as an edit, and refuse it.
448
+ const latestBody = useRef(row.body);
449
+ useEffect(() => {
450
+ latestBody.current = row.body;
451
+ }, [row.body]);
452
+
453
+ const capture = onCaptureScreenshot
454
+ ? async () => {
455
+ try {
456
+ return await onCaptureScreenshot();
457
+ } catch {
458
+ toast.error(toasts.captureFailed);
459
+ return null;
460
+ }
461
+ }
462
+ : undefined;
463
+
464
+ const send = async (value: string, file?: File | null) => {
465
+ const note = value.trim();
466
+ if (!note) return;
467
+ let url: string | null = null;
468
+ if (file && onUpload) {
469
+ setUploading(true);
470
+ try {
471
+ url = await onUpload(file);
472
+ } catch {
473
+ toast.error(labels.reworkUploadFailed);
474
+ return;
475
+ } finally {
476
+ setUploading(false);
477
+ }
478
+ }
479
+ let body: string | null;
480
+ try {
481
+ body = appendRework(latestBody.current, note, url);
482
+ } catch {
483
+ // The upload answered with something that is not an attachment URL of this
484
+ // contract — written anyway it would sit in the body as prose no reader knows to
485
+ // be a file. As far as the person is concerned, the file did not arrive.
486
+ toast.error(labels.reworkUploadFailed);
487
+ return;
488
+ }
489
+ if (body === null) return;
490
+ onUpdate({ id: row.id, body }, { onSuccess: () => onSent?.() });
491
+ };
492
+
493
+ return (
494
+ <FeedbackDetailSection title={labels.reworkTitle}>
495
+ <FeedbackNoteEditor
496
+ key={row.id}
497
+ initial=""
498
+ required
499
+ commit={commit}
500
+ pending={pending || uploading}
501
+ onSave={(value, file) => void send(value, file)}
502
+ onCancel={onCancel}
503
+ saveLabel={labels.reworkSend}
504
+ cancelLabel={labels.cancel}
505
+ placeholder={labels.reworkPlaceholder}
506
+ attachment={
507
+ onUpload
508
+ ? {
509
+ accept: FEEDBACK_ATTACHMENT_ACCEPT,
510
+ maxBytes: DEFAULT_MAX_ATTACHMENT_BYTES,
511
+ onError: (kind) => {
512
+ toast.error(kind === "type" ? toasts.attachmentUnsupported : toasts.attachmentTooLarge);
513
+ },
514
+ onCaptureScreenshot: capture,
515
+ ...attachment,
516
+ }
517
+ : undefined
518
+ }
519
+ />
520
+ </FeedbackDetailSection>
521
+ );
522
+ }
523
+
524
+ /* ── The detail (§4.4) ─────────────────────────────────────────────────────── */
525
+
526
+ export interface FeedbackRowDetailProps {
527
+ /** The report — the list's own row, as `GET /feedback` / `GET /feedback/my` sent it. */
528
+ row: FeedbackRecord;
529
+ /** The admin on the inbox (`/feedback`): status pills and the outcome editor. `false`
530
+ * on `/my-feedback`, for an admin too (§4.3). */
531
+ canEdit: boolean;
532
+ /** The signed-in user's id — who the AUTHOR is: the description editor and rework. */
533
+ viewerId?: number | null;
534
+ /** The page's PATCH — `update.mutate` (see {@link FeedbackDetailUpdate}). */
535
+ onUpdate: FeedbackDetailUpdate;
536
+ /** `update.isPending`: the editors' save buttons wait for it. */
537
+ pending?: boolean;
538
+ /** `POST /feedback/attachments` for the rework's one file. Left out, a rework is a
539
+ * note alone. */
540
+ onUpload?: FeedbackUpload;
541
+ /** The app's authenticated GET (`AuthedImage`'s) — the pictures and the downloads go
542
+ * through it, since the attachment route wants the bearer token (§3.5). Left out,
543
+ * the browser's own fetch with same-origin cookies. */
544
+ fetcher?: AuthedFetcher | null;
545
+ /**
546
+ * The undoable status change — what `useFeedbackStatusUndo(update.mutate)` returns,
547
+ * the SAME one the table's status cell and the swipes use (§4.5). Left out, the
548
+ * detail builds one from `onUpdate`, which is equivalent as long as `onUpdate` is the
549
+ * page's mutation (its observer outlives the row a status filter removes).
550
+ */
551
+ statusChange?: FeedbackStatusChange;
552
+ /** For the rework's "Capture screenshot" — see {@link FeedbackReworkSectionProps}. */
553
+ onCaptureScreenshot?: () => Promise<File | null>;
554
+ /** The rework's file field, over the contract's defaults. */
555
+ reworkAttachment?: Partial<FeedbackNoteAttachment>;
556
+ /** How the `resolved_at` date reads — the app's date cell, as a string. Default: the
557
+ * kit's `formatDate` in the provider's locale, `medium` style. */
558
+ renderDate?: (iso: string) => string;
559
+ /**
560
+ * The detail's saves COMMIT (description, outcome, rework, status): under a locked
561
+ * `WriteLockProvider` each is `aria-disabled` with the lock's reason, and nothing
562
+ * reaches `onUpdate`. Off by default — keksdose's shell lock is a read-only demo
563
+ * BUDGET, and a person viewing one still owns their own reports.
564
+ */
565
+ commit?: boolean;
566
+ labels?: Partial<FeedbackDetailLabels>;
567
+ className?: string;
568
+ }
569
+
570
+ /**
571
+ * The expanded row of a feedback table (§4.4): what was reported, where, with which
572
+ * files, what the answer was, and where it stands — with the edits each viewer may
573
+ * make. Hand it to `DataTable`'s `expandedRow`:
574
+ *
575
+ * ```tsx
576
+ * const update = useMutation({ mutationFn: ({ id, ...patch }) => api.updateFeedback(id, patch),
577
+ * onSuccess: invalidate, onError: (e) => toast.error(detailOf(e) ?? toastLabels.updateFailed) });
578
+ * const changeStatus = useFeedbackStatusUndo(update.mutate);
579
+ * <DataTable
580
+ * isExpanded={(row) => expandedId === row.id}
581
+ * expandedRow={(row) => (
582
+ * <FeedbackRowDetail row={row} canEdit={isAdmin && !mine} viewerId={user?.id}
583
+ * onUpdate={update.mutate} pending={update.isPending} statusChange={changeStatus}
584
+ * onUpload={api.uploadFeedbackAttachment} fetcher={authedImageFetcher}
585
+ * onCaptureScreenshot={captureAppScreenshot} />
586
+ * )} … />
587
+ * ```
588
+ *
589
+ * Mounted only while its row is expanded, so which editor is open lives here, and
590
+ * collapsing the row closes it. Each editor is keyed by the row's id, so a draft is
591
+ * never thrown away by a list refresh under it.
592
+ */
593
+ export function FeedbackRowDetail({
594
+ row,
595
+ canEdit,
596
+ viewerId,
597
+ onUpdate,
598
+ pending = false,
599
+ onUpload,
600
+ fetcher,
601
+ statusChange,
602
+ onCaptureScreenshot,
603
+ reworkAttachment,
604
+ renderDate,
605
+ commit,
606
+ labels: labelsProp,
607
+ className,
608
+ }: FeedbackRowDetailProps) {
609
+ const labels = useFeedbackDetailLabels(labelsProp);
610
+ // "placeholder = the dialog's body label" (§4.4.1): read from the dialog's own
611
+ // namespace, so the two can never say different things.
612
+ const dialogText = useKitLabels("feedbackDialog", DEFAULT_FEEDBACK_DIALOG_LABELS);
613
+ const locale = useKitLocale();
614
+ const ownStatusChange = useFeedbackStatusUndo(onUpdate);
615
+ const changeStatus = statusChange ?? ownStatusChange;
616
+ const [editingBody, setEditingBody] = useState(false);
617
+ const [editingOutcome, setEditingOutcome] = useState(false);
618
+ const [reworking, setReworking] = useState(false);
619
+
620
+ const access = feedbackRowAccess(row, { canEdit, viewerId });
621
+ // The body as written, without its file lines (they are shown as files below) — the
622
+ // rework rounds stay in the prose: they are what was said after the answer.
623
+ const bodyText = splitBodyAttachments(row.body).text;
624
+ const { description } = splitDescription(row.body);
625
+ const pageHref = feedbackPageHref(row.context);
626
+ const pageUrl = feedbackPageUrl(row.context);
627
+ const files = feedbackAttachmentUrls(row);
628
+ const fetchFile = fetcher ?? plainFetch;
629
+ const dateText = (iso: string) => (renderDate ? renderDate(iso) : formatDate(iso, "medium", { locale }));
630
+
631
+ const saveDescription = (value: string) => {
632
+ // Only a real change makes a round trip; clearing it is one ("" — the description
633
+ // is optional, §7.4), and the rounds and file lines go back unchanged.
634
+ if (value.trim() === description.trim()) {
635
+ setEditingBody(false);
636
+ return;
637
+ }
638
+ onUpdate({ id: row.id, body: replaceDescription(row.body, value) }, { onSuccess: () => setEditingBody(false) });
639
+ };
640
+
641
+ const saveOutcome = (value: string) => {
642
+ // Blank saves `null`: no outcome, rather than an empty one.
643
+ const next = value.trim() ? value.trim() : null;
644
+ if (next === row.outcome) {
645
+ setEditingOutcome(false);
646
+ return;
647
+ }
648
+ onUpdate({ id: row.id, outcome: next }, { onSuccess: () => setEditingOutcome(false) });
649
+ };
650
+
651
+ const showOutcomeEditor = access.canEditOutcome && editingOutcome;
652
+ const showRework = access.canRework && reworking;
653
+
654
+ return (
655
+ <FeedbackDetail className={className}>
656
+ <FeedbackDetailSection
657
+ title={labels.body}
658
+ action={
659
+ access.canEditDescription && !editingBody ? (
660
+ <Button
661
+ type="button"
662
+ variant="ghost"
663
+ size="sm"
664
+ onClick={(event) => {
665
+ event.stopPropagation();
666
+ setEditingBody(true);
667
+ }}
668
+ aria-label={labels.editDescription}
669
+ >
670
+ {labels.edit}
671
+ </Button>
672
+ ) : undefined
673
+ }
674
+ >
675
+ {access.canEditDescription && editingBody ? (
676
+ <FeedbackNoteEditor
677
+ key={row.id}
678
+ // The ORIGINAL description only — see the module note.
679
+ initial={description}
680
+ pending={pending}
681
+ commit={commit}
682
+ onSave={saveDescription}
683
+ onCancel={() => setEditingBody(false)}
684
+ saveLabel={labels.save}
685
+ cancelLabel={labels.cancel}
686
+ placeholder={dialogText.bodyOptional ?? dialogText.body}
687
+ />
688
+ ) : (
689
+ <FeedbackProse>{bodyText.trim() ? bodyText : undefined}</FeedbackProse>
690
+ )}
691
+ </FeedbackDetailSection>
692
+
693
+ {pageHref && pageUrl && (
694
+ // The page it was filed on, with the full URL a click away (keksdose #278).
695
+ <FeedbackDetailSection title={labels.url}>
696
+ <div className="flex min-w-0 items-center gap-1.5">
697
+ <TextLink href={pageHref} tone="secondary" underline="hover" stopPropagation className="min-w-0">
698
+ <code className="break-all text-xs">{pageHref}</code>
699
+ </TextLink>
700
+ <CopyButton size="xs" className="shrink-0" text={pageUrl} label={labels.copyUrl} stopPropagation />
701
+ </div>
702
+ </FeedbackDetailSection>
703
+ )}
704
+
705
+ {files.length > 0 && (
706
+ <FeedbackDetailSection title={labels.attachment}>
707
+ <div className="flex min-w-0 flex-wrap items-start gap-2">
708
+ {files.map((url) =>
709
+ isImageAttachment(url) ? (
710
+ <AuthedImage
711
+ key={url}
712
+ src={url}
713
+ alt={labels.attachment}
714
+ fetcher={fetchFile}
715
+ link
716
+ stopPropagation
717
+ // A broken attachment is simply not in the row (keksdose's
718
+ // AuthedImage default) rather than a grey tile per failure.
719
+ errorFallback={null}
720
+ wrapperClassName="inline-block max-w-full"
721
+ className="max-h-64 max-w-full rounded border border-[var(--border)]"
722
+ />
723
+ ) : (
724
+ <AttachmentDownload key={url} url={url} fetcher={fetchFile} labels={labels} />
725
+ ),
726
+ )}
727
+ </div>
728
+ </FeedbackDetailSection>
729
+ )}
730
+
731
+ <FeedbackDetailSection
732
+ title={labels.outcome}
733
+ // When the row entered DONE / WONT_DO (keksdose feedback #96).
734
+ action={
735
+ row.resolved_at ? (
736
+ <span className="text-xs text-[var(--text-muted)]">{labels.resolvedAt(dateText(row.resolved_at))}</span>
737
+ ) : undefined
738
+ }
739
+ >
740
+ {showOutcomeEditor ? (
741
+ <FeedbackNoteEditor
742
+ key={row.id}
743
+ initial={row.outcome ?? ""}
744
+ pending={pending}
745
+ commit={commit}
746
+ onSave={saveOutcome}
747
+ onCancel={() => setEditingOutcome(false)}
748
+ saveLabel={labels.save}
749
+ cancelLabel={labels.cancel}
750
+ placeholder={labels.outcomePlaceholder}
751
+ />
752
+ ) : (
753
+ <div className="space-y-2">
754
+ <FeedbackProse>{row.outcome || undefined}</FeedbackProse>
755
+ {(access.canEditOutcome || (access.canRework && !reworking) || pageHref) && (
756
+ <div className="flex flex-wrap gap-2">
757
+ {access.canEditOutcome && (
758
+ <Button
759
+ type="button"
760
+ variant="secondary"
761
+ onClick={(event) => {
762
+ event.stopPropagation();
763
+ setEditingOutcome(true);
764
+ }}
765
+ >
766
+ {row.outcome ? labels.outcomeUpdate : labels.outcomeAdd}
767
+ </Button>
768
+ )}
769
+ {access.canRework && !reworking && (
770
+ <Button
771
+ type="button"
772
+ variant="secondary"
773
+ onClick={(event) => {
774
+ event.stopPropagation();
775
+ setReworking(true);
776
+ }}
777
+ >
778
+ <RotateCcw className="size-3.5" aria-hidden /> {labels.rework}
779
+ </Button>
780
+ )}
781
+ {pageHref && (
782
+ // A real href (keksdose dev #451): middle-click and ⌘/Ctrl-click are
783
+ // the browser's, and only a link has them.
784
+ <Button href={pageHref} variant="secondary" onClick={(event) => event.stopPropagation()}>
785
+ <ExternalLink className="size-3.5" aria-hidden /> {labels.openPage}
786
+ </Button>
787
+ )}
788
+ </div>
789
+ )}
790
+ </div>
791
+ )}
792
+ </FeedbackDetailSection>
793
+
794
+ {showRework && (
795
+ <FeedbackReworkSection
796
+ row={row}
797
+ onUpdate={onUpdate}
798
+ pending={pending}
799
+ onUpload={onUpload}
800
+ onCaptureScreenshot={onCaptureScreenshot}
801
+ onSent={() => setReworking(false)}
802
+ onCancel={() => setReworking(false)}
803
+ attachment={reworkAttachment}
804
+ commit={commit}
805
+ labels={labelsProp}
806
+ />
807
+ )}
808
+
809
+ <FeedbackDetailSection title={labels.status}>
810
+ {/* Expanded: the whole enum in one click for the admin (keksdose feedback #421);
811
+ everyone else the narrow set, read-only — a picture of the workflow. */}
812
+ <FeedbackStatusTransitions
813
+ status={row.status}
814
+ statuses={
815
+ access.canChangeStatus ? selectableFeedbackStatuses(row.status) : visibleFeedbackStatuses(row.status)
816
+ }
817
+ canEdit={access.canChangeStatus}
818
+ commit={commit}
819
+ onPick={(status) => changeStatus(row, status)}
820
+ variant="pill"
821
+ />
822
+ </FeedbackDetailSection>
823
+ </FeedbackDetail>
824
+ );
825
+ }