anton-bakker-feedback 0.1.0 → 0.3.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.
package/README.md ADDED
@@ -0,0 +1,78 @@
1
+ # anton-bakker-feedback
2
+
3
+ Zero-dependency feedback collection package with LLM-powered dialogue intake, auto-capture, classification, and observability.
4
+
5
+ ## Exports
6
+
7
+ | Entry point | Purpose |
8
+ |-------------|---------|
9
+ | `anton-bakker-feedback` | Core: types, capture, transport, config, i18n, observability |
10
+ | `anton-bakker-feedback/react` | Widget: FeedbackProvider, FeedbackPanel, FeedbackButton, etc. |
11
+ | `anton-bakker-feedback/react/admin` | Admin pages: List, Detail, Update |
12
+ | `anton-bakker-feedback/server` | Server handlers: DynamoDB + Bedrock factories |
13
+ | `anton-bakker-feedback/manifest` | Package manifest for plugin discovery |
14
+
15
+ ## Admin Pages
16
+
17
+ The `./react/admin` entry point provides three headless page-level components for managing submitted feedback. Components are unstyled — pass `className` props to integrate with Tailwind CSS or any other CSS framework.
18
+
19
+ ### Import
20
+
21
+ ```typescript
22
+ import {
23
+ FeedbackListPage,
24
+ FeedbackDetailPage,
25
+ FeedbackUpdatePage,
26
+ } from 'anton-bakker-feedback/react/admin';
27
+ ```
28
+
29
+ Admin pages are tree-shakeable. Apps that import only from `./react` (the widget) do not bundle admin page code.
30
+
31
+ ### Props contract
32
+
33
+ All three pages use **Inversion of Control** — you provide the data and navigation functions; the component provides the UI structure.
34
+
35
+ | Prop | Type | Used by | Description |
36
+ |------|------|---------|-------------|
37
+ | `items` | `StoredFeedbackItem[]` | List | Feedback items to display |
38
+ | `loading` | `boolean` | List | Show loading state |
39
+ | `fetchItem` | `(id: string) => Promise<StoredFeedbackItem>` | Detail, Update | Load a single item |
40
+ | `updateItem` | `(id: string, patch: Partial<StoredFeedbackItem>) => Promise<void>` | Update | Persist changes |
41
+ | `onNavigate` | `(path: string) => void` | All | Consumer-owned routing |
42
+ | `currentUser` | `{ userId: string; name?: string }` | All (optional) | For assignee display |
43
+ | `className` | `string` | All (optional) | Root element class |
44
+ | `feedbackId` | `string` | Detail, Update | Item to load/edit |
45
+
46
+ ### Minimal example
47
+
48
+ ```tsx
49
+ import { FeedbackListPage } from 'anton-bakker-feedback/react/admin';
50
+
51
+ export function AdminFeedbackRoute({ items, loading }) {
52
+ return (
53
+ <FeedbackListPage
54
+ items={items}
55
+ loading={loading}
56
+ onNavigate={(path) => router.push(path)}
57
+ className="my-admin-list"
58
+ />
59
+ );
60
+ }
61
+ ```
62
+
63
+ ### Error handling
64
+
65
+ Components handle loading and empty-state UI internally. Data fetch errors should be caught in your `fetchItem`/`updateItem` implementations and surfaced via your consumer-level error boundary.
66
+
67
+ ## Development
68
+
69
+ ```bash
70
+ npm install
71
+ npm run build # Build all entry points
72
+ npx vitest run # Run tests
73
+ npx tsc --noEmit # Type check
74
+ ```
75
+
76
+ ## License
77
+
78
+ MIT
@@ -0,0 +1,12 @@
1
+ import { FeedbackDetailPageProps } from './types.js';
2
+ /**
3
+ * FeedbackDetailPage.tsx
4
+ * Location: src/react/admin/FeedbackDetailPage.tsx
5
+ *
6
+ * Admin detail page for viewing a single feedback item.
7
+ * Uses DetailPage + DetailSection + DetailRow from anton-bakker-tailwind-components.
8
+ */
9
+ import * as React from 'react';
10
+ declare const FeedbackDetailPage: React.ForwardRefExoticComponent<FeedbackDetailPageProps & React.RefAttributes<HTMLDivElement>>;
11
+ export { FeedbackDetailPage };
12
+ //# sourceMappingURL=FeedbackDetailPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedbackDetailPage.d.ts","sourceRoot":"","sources":["../../../src/react/admin/FeedbackDetailPage.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAG1D,QAAA,MAAM,kBAAkB,gGAoGvB,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
@@ -0,0 +1,20 @@
1
+ import { FeedbackStatus, FeedbackType } from '../../core/types.js';
2
+ import { FeedbackListPageProps } from './types.js';
3
+ /**
4
+ * FeedbackListPage.tsx
5
+ * Location: src/react/admin/FeedbackListPage.tsx
6
+ *
7
+ * Admin list page for browsing and filtering feedback items.
8
+ * Thin wrapper around ListPage from anton-bakker-tailwind-components
9
+ * for visual consistency with sagrosoft20.
10
+ */
11
+ import * as React from 'react';
12
+ declare function StatusBadge({ status }: {
13
+ status: FeedbackStatus;
14
+ }): React.JSX.Element;
15
+ declare function TypeBadge({ type }: {
16
+ type: FeedbackType;
17
+ }): React.JSX.Element;
18
+ declare const FeedbackListPage: React.ForwardRefExoticComponent<FeedbackListPageProps & React.RefAttributes<HTMLDivElement>>;
19
+ export { FeedbackListPage, StatusBadge, TypeBadge };
20
+ //# sourceMappingURL=FeedbackListPage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedbackListPage.d.ts","sourceRoot":"","sources":["../../../src/react/admin/FeedbackListPage.tsx"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,KAAK,EAAsB,cAAc,EAAE,YAAY,EAAE,MAAM,qBAAqB,CAAC;AAC5F,OAAO,KAAK,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAC;AAiBxD,iBAAS,WAAW,CAAC,EAAE,MAAM,EAAE,EAAE;IAAE,MAAM,EAAE,cAAc,CAAA;CAAE,qBAE1D;AAED,iBAAS,SAAS,CAAC,EAAE,IAAI,EAAE,EAAE;IAAE,IAAI,EAAE,YAAY,CAAA;CAAE,qBAElD;AAED,QAAA,MAAM,gBAAgB,8FA0FrB,CAAC;AAGF,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,SAAS,EAAE,CAAC"}
@@ -0,0 +1,12 @@
1
+ import { FeedbackUpdatePageProps } from './types.js';
2
+ /**
3
+ * FeedbackUpdatePage.tsx
4
+ * Location: src/react/admin/FeedbackUpdatePage.tsx
5
+ *
6
+ * Admin update page for editing mutable fields of a feedback item.
7
+ * Uses UpdatePage + EnumField + TextField + TextareaField from anton-bakker-tailwind-components.
8
+ */
9
+ import * as React from 'react';
10
+ declare const FeedbackUpdatePage: React.ForwardRefExoticComponent<FeedbackUpdatePageProps & React.RefAttributes<HTMLDivElement>>;
11
+ export { FeedbackUpdatePage };
12
+ //# sourceMappingURL=FeedbackUpdatePage.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedbackUpdatePage.d.ts","sourceRoot":"","sources":["../../../src/react/admin/FeedbackUpdatePage.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,KAAK,MAAM,OAAO,CAAC;AAG/B,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,YAAY,CAAC;AAiC1D,QAAA,MAAM,kBAAkB,gGAiMvB,CAAC;AAGF,OAAO,EAAE,kBAAkB,EAAE,CAAC"}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=FeedbackDetailPage.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedbackDetailPage.test.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/__tests__/FeedbackDetailPage.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=FeedbackListPage.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedbackListPage.test.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/__tests__/FeedbackListPage.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,2 @@
1
+ export {};
2
+ //# sourceMappingURL=FeedbackUpdatePage.test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FeedbackUpdatePage.test.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/__tests__/FeedbackUpdatePage.test.tsx"],"names":[],"mappings":""}
@@ -0,0 +1,18 @@
1
+ import { FeedbackStatus } from '../../../core/types.js';
2
+
3
+ export interface UseStatusTransitionResult {
4
+ /** Statuses reachable from the current status (current status excluded) */
5
+ allowedTargets: FeedbackStatus[];
6
+ }
7
+ /**
8
+ * Returns the allowed target statuses from the given current status.
9
+ *
10
+ * @param currentStatus - The status the item currently has
11
+ * @returns Object with `allowedTargets` — statuses the item can transition to
12
+ *
13
+ * @example
14
+ * const { allowedTargets } = useStatusTransition('open');
15
+ * // allowedTargets → ['approved', 'deferred', 'wontfix']
16
+ */
17
+ export declare function useStatusTransition(currentStatus: FeedbackStatus): UseStatusTransitionResult;
18
+ //# sourceMappingURL=useStatusTransition.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"useStatusTransition.d.ts","sourceRoot":"","sources":["../../../../src/react/admin/hooks/useStatusTransition.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,wBAAwB,CAAC;AAc7D,MAAM,WAAW,yBAAyB;IACxC,2EAA2E;IAC3E,cAAc,EAAE,cAAc,EAAE,CAAC;CAClC;AAED;;;;;;;;;GASG;AACH,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,cAAc,GAAG,yBAAyB,CAG5F"}
@@ -0,0 +1,7 @@
1
+ export { FeedbackListPage, StatusBadge, TypeBadge } from './FeedbackListPage.js';
2
+ export { FeedbackDetailPage } from './FeedbackDetailPage.js';
3
+ export { FeedbackUpdatePage } from './FeedbackUpdatePage.js';
4
+ export { useStatusTransition } from './hooks/useStatusTransition.js';
5
+ export type { AdminPageProps, FeedbackListPageProps, FeedbackDetailPageProps, FeedbackUpdatePageProps, } from './types.js';
6
+ export type { StoredFeedbackItem, FeedbackStatus, FeedbackType, FeedbackScope, FeedbackSize, FeedbackEnvironment, Reporter, DialogueEntry, CorrectionEntry, FeedbackContext, } from '../../core/types.js';
7
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/react/admin/index.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,gBAAgB,EAAE,WAAW,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AACjF,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAC7D,OAAO,EAAE,kBAAkB,EAAE,MAAM,yBAAyB,CAAC;AAG7D,OAAO,EAAE,mBAAmB,EAAE,MAAM,gCAAgC,CAAC;AAGrE,YAAY,EACV,cAAc,EACd,qBAAqB,EACrB,uBAAuB,EACvB,uBAAuB,GACxB,MAAM,YAAY,CAAC;AAGpB,YAAY,EACV,kBAAkB,EAClB,cAAc,EACd,YAAY,EACZ,aAAa,EACb,YAAY,EACZ,mBAAmB,EACnB,QAAQ,EACR,aAAa,EACb,eAAe,EACf,eAAe,GAChB,MAAM,qBAAqB,CAAC"}
@@ -0,0 +1,406 @@
1
+ import { jsx, jsxs, Fragment } from "react/jsx-runtime";
2
+ import * as React from "react";
3
+ import { ListPage, Badge, DetailPage, Button, DetailSection, DetailRow, UpdatePage, EnumField, TextField, TextareaField, SaveFooter } from "anton-bakker-tailwind-components";
4
+ const STATUS_VARIANT = {
5
+ open: "warning",
6
+ approved: "default",
7
+ deferred: "secondary",
8
+ resolved: "success",
9
+ wontfix: "destructive"
10
+ };
11
+ const TYPE_VARIANT = {
12
+ bug: "destructive",
13
+ feature: "default",
14
+ ux: "warning",
15
+ other: "secondary"
16
+ };
17
+ function StatusBadge({ status }) {
18
+ return /* @__PURE__ */ jsx(Badge, { variant: STATUS_VARIANT[status] ?? "secondary", children: status });
19
+ }
20
+ function TypeBadge({ type }) {
21
+ return /* @__PURE__ */ jsx(Badge, { variant: TYPE_VARIANT[type] ?? "secondary", children: type });
22
+ }
23
+ const FeedbackListPage = React.forwardRef(
24
+ ({ items, loading = false, onNavigate, className, emptyStateMessage, currentUser, ...rest }, ref) => {
25
+ const [search, setSearch] = React.useState("");
26
+ const [statusFilter, setStatusFilter] = React.useState("");
27
+ const [typeFilter, setTypeFilter] = React.useState("");
28
+ const filteredItems = React.useMemo(() => {
29
+ let result = items;
30
+ if (statusFilter) result = result.filter((i) => i.status === statusFilter);
31
+ if (typeFilter) result = result.filter((i) => i.type === typeFilter);
32
+ if (search) {
33
+ const q = search.toLowerCase();
34
+ result = result.filter((i) => i.content?.toLowerCase().includes(q) || i.id.toLowerCase().includes(q));
35
+ }
36
+ return result;
37
+ }, [items, statusFilter, typeFilter, search]);
38
+ const columns = React.useMemo(() => [
39
+ { header: "Content", accessor: (row) => row.content?.slice(0, 60) || row.id, sortable: true },
40
+ { header: "Type", accessor: (row) => /* @__PURE__ */ jsx(TypeBadge, { type: row.type }), sortable: true },
41
+ { header: "Status", accessor: (row) => /* @__PURE__ */ jsx(StatusBadge, { status: row.status }), sortable: true },
42
+ { header: "Priority", accessor: "priority", sortable: true },
43
+ { header: "Reporters", accessor: "reporterCount", sortable: true },
44
+ {
45
+ header: "Date",
46
+ accessor: (row) => row.submittedAt ? new Date(row.submittedAt).toLocaleDateString() : "—",
47
+ sortable: true
48
+ }
49
+ ], []);
50
+ const filterBar = statusFilter || typeFilter ? {
51
+ filters: [
52
+ ...statusFilter ? [{ field: "status", label: `Status: ${statusFilter}`, value: statusFilter }] : [],
53
+ ...typeFilter ? [{ field: "type", label: `Type: ${typeFilter}`, value: typeFilter }] : []
54
+ ],
55
+ onFilterRemove: (field) => {
56
+ if (field === "status") setStatusFilter("");
57
+ if (field === "type") setTypeFilter("");
58
+ },
59
+ onFiltersClear: () => {
60
+ setStatusFilter("");
61
+ setTypeFilter("");
62
+ }
63
+ } : void 0;
64
+ const headerActions = /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
65
+ /* @__PURE__ */ jsxs(
66
+ "select",
67
+ {
68
+ value: statusFilter,
69
+ onChange: (e) => setStatusFilter(e.target.value),
70
+ className: "h-8 rounded-md border border-border bg-transparent px-2 text-sm",
71
+ "aria-label": "Filter by status",
72
+ children: [
73
+ /* @__PURE__ */ jsx("option", { value: "", children: "All statuses" }),
74
+ /* @__PURE__ */ jsx("option", { value: "open", children: "Open" }),
75
+ /* @__PURE__ */ jsx("option", { value: "approved", children: "Approved" }),
76
+ /* @__PURE__ */ jsx("option", { value: "deferred", children: "Deferred" }),
77
+ /* @__PURE__ */ jsx("option", { value: "resolved", children: "Resolved" }),
78
+ /* @__PURE__ */ jsx("option", { value: "wontfix", children: "Won't fix" })
79
+ ]
80
+ }
81
+ ),
82
+ /* @__PURE__ */ jsxs(
83
+ "select",
84
+ {
85
+ value: typeFilter,
86
+ onChange: (e) => setTypeFilter(e.target.value),
87
+ className: "h-8 rounded-md border border-border bg-transparent px-2 text-sm",
88
+ "aria-label": "Filter by type",
89
+ children: [
90
+ /* @__PURE__ */ jsx("option", { value: "", children: "All types" }),
91
+ /* @__PURE__ */ jsx("option", { value: "bug", children: "Bug" }),
92
+ /* @__PURE__ */ jsx("option", { value: "feature", children: "Feature" }),
93
+ /* @__PURE__ */ jsx("option", { value: "ux", children: "UX" }),
94
+ /* @__PURE__ */ jsx("option", { value: "other", children: "Other" })
95
+ ]
96
+ }
97
+ )
98
+ ] });
99
+ const listProps = {
100
+ title: "Feedback",
101
+ columns,
102
+ items: filteredItems,
103
+ loading,
104
+ onRowClick: (row) => onNavigate(`/feedback/${row.id}`),
105
+ search,
106
+ onSearchChange: setSearch,
107
+ searchPlaceholder: "Search feedback...",
108
+ headerActions,
109
+ testId: "feedback-list-page",
110
+ ...filterBar ? { filterBar } : {},
111
+ ...emptyStateMessage ? { emptyState: emptyStateMessage } : {},
112
+ ...className ? { className } : {}
113
+ };
114
+ return /* @__PURE__ */ jsx(ListPage, { ref, ...listProps });
115
+ }
116
+ );
117
+ FeedbackListPage.displayName = "FeedbackListPage";
118
+ const FeedbackDetailPage = React.forwardRef(
119
+ ({ feedbackId, fetchItem, onNavigate, currentUser, className }, ref) => {
120
+ const [item, setItem] = React.useState(null);
121
+ const [loading, setLoading] = React.useState(true);
122
+ const [error, setError] = React.useState(null);
123
+ React.useEffect(() => {
124
+ let cancelled = false;
125
+ setLoading(true);
126
+ setError(null);
127
+ fetchItem(feedbackId).then((data) => {
128
+ if (!cancelled) setItem(data);
129
+ }).catch((err) => {
130
+ if (!cancelled) setError(err?.message ?? "Failed to load item");
131
+ }).finally(() => {
132
+ if (!cancelled) setLoading(false);
133
+ });
134
+ return () => {
135
+ cancelled = true;
136
+ };
137
+ }, [feedbackId, fetchItem]);
138
+ if (error) {
139
+ return /* @__PURE__ */ jsx("div", { ref, className, children: /* @__PURE__ */ jsx(DetailPage, { title: "Feedback", headerActions: /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate("/feedback"), children: "Back to list" }), children: /* @__PURE__ */ jsx("div", { className: "p-6 text-destructive", children: error }) }) });
140
+ }
141
+ const headerActions = /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
142
+ /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate("/feedback"), children: "Back to list" }),
143
+ /* @__PURE__ */ jsx(Button, { onClick: () => onNavigate(`/feedback/${feedbackId}/edit`), children: "Edit" })
144
+ ] });
145
+ return /* @__PURE__ */ jsx("div", { ref, className, children: /* @__PURE__ */ jsx(
146
+ DetailPage,
147
+ {
148
+ title: item?.content?.slice(0, 50) || `Feedback ${feedbackId.slice(0, 12)}…`,
149
+ headerActions,
150
+ loading,
151
+ testId: "feedback-detail-page",
152
+ children: item && /* @__PURE__ */ jsxs(Fragment, { children: [
153
+ /* @__PURE__ */ jsxs(DetailSection, { title: "Overview", children: [
154
+ /* @__PURE__ */ jsx(DetailRow, { label: "Status", children: /* @__PURE__ */ jsx(StatusBadge, { status: item.status }) }),
155
+ /* @__PURE__ */ jsx(DetailRow, { label: "Type", children: /* @__PURE__ */ jsx(TypeBadge, { type: item.type }) }),
156
+ /* @__PURE__ */ jsx(DetailRow, { label: "Priority", children: item.priority }),
157
+ /* @__PURE__ */ jsx(DetailRow, { label: "Environment", children: item.environment || "—" }),
158
+ /* @__PURE__ */ jsx(DetailRow, { label: "Submitted", children: item.submittedAt ? new Date(item.submittedAt).toLocaleString() : "—" }),
159
+ /* @__PURE__ */ jsx(DetailRow, { label: "Reporters", children: item.reporterCount ?? 1 }),
160
+ item.assignedTo && /* @__PURE__ */ jsx(DetailRow, { label: "Assigned to", children: item.assignedTo }),
161
+ item.scope && /* @__PURE__ */ jsx(DetailRow, { label: "Scope", children: /* @__PURE__ */ jsx(Badge, { variant: "outline", children: item.scope }) }),
162
+ item.size && /* @__PURE__ */ jsx(DetailRow, { label: "Size", children: /* @__PURE__ */ jsx(Badge, { variant: "outline", children: item.size }) })
163
+ ] }),
164
+ /* @__PURE__ */ jsx(DetailSection, { title: "Content", children: /* @__PURE__ */ jsx(DetailRow, { label: "Description", children: /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap", children: item.content }) }) }),
165
+ item.dialogueHistory && item.dialogueHistory.length > 0 && /* @__PURE__ */ jsx(DetailSection, { title: "Dialogue History", collapsible: true, defaultOpen: false, children: item.dialogueHistory.map((entry, i) => /* @__PURE__ */ jsxs(DetailRow, { label: entry.role === "user" ? "User" : "Assistant", children: [
166
+ /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap text-sm", children: entry.content }),
167
+ entry.timestamp && /* @__PURE__ */ jsx("span", { className: "text-xs text-muted-foreground", children: new Date(entry.timestamp).toLocaleString() })
168
+ ] }, i)) }),
169
+ item.context && /* @__PURE__ */ jsxs(DetailSection, { title: "Browser Context", collapsible: true, defaultOpen: false, children: [
170
+ item.context.url && /* @__PURE__ */ jsx(DetailRow, { label: "URL", children: item.context.url }),
171
+ item.context.userAgent && /* @__PURE__ */ jsx(DetailRow, { label: "User Agent", children: item.context.userAgent }),
172
+ item.context.viewport && /* @__PURE__ */ jsx(DetailRow, { label: "Viewport", children: `${item.context.viewport.width}×${item.context.viewport.height}` })
173
+ ] }),
174
+ item.tags && item.tags.length > 0 && /* @__PURE__ */ jsx(DetailSection, { title: "Tags", children: /* @__PURE__ */ jsx(DetailRow, { label: "Tags", children: /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: item.tags.map((tag) => /* @__PURE__ */ jsx(Badge, { variant: "outline", children: tag }, tag)) }) }) })
175
+ ] })
176
+ }
177
+ ) });
178
+ }
179
+ );
180
+ FeedbackDetailPage.displayName = "FeedbackDetailPage";
181
+ const ALLOWED_TRANSITIONS = {
182
+ open: ["approved", "deferred", "wontfix"],
183
+ approved: ["open", "resolved", "deferred"],
184
+ deferred: ["open", "approved", "wontfix"],
185
+ resolved: ["open", "approved"],
186
+ wontfix: ["open", "deferred"]
187
+ };
188
+ function useStatusTransition(currentStatus) {
189
+ const allowedTargets = ALLOWED_TRANSITIONS[currentStatus] ?? [];
190
+ return { allowedTargets };
191
+ }
192
+ const STATUS_OPTIONS = [
193
+ { value: "open", label: "Open" },
194
+ { value: "approved", label: "Approved" },
195
+ { value: "deferred", label: "Deferred" },
196
+ { value: "resolved", label: "Resolved" },
197
+ { value: "wontfix", label: "Won't Fix" }
198
+ ];
199
+ const TYPE_OPTIONS = [
200
+ { value: "bug", label: "Bug" },
201
+ { value: "feature", label: "Feature" },
202
+ { value: "ux", label: "UX Issue" },
203
+ { value: "other", label: "Other" }
204
+ ];
205
+ const SCOPE_OPTIONS = [
206
+ { value: "", label: "— not set —" },
207
+ { value: "component", label: "Component" },
208
+ { value: "page", label: "Page" },
209
+ { value: "workflow", label: "Workflow" }
210
+ ];
211
+ const SIZE_OPTIONS = [
212
+ { value: "", label: "— not set —" },
213
+ { value: "trivial", label: "Trivial" },
214
+ { value: "small", label: "Small" },
215
+ { value: "medium", label: "Medium" },
216
+ { value: "large", label: "Large" }
217
+ ];
218
+ const FeedbackUpdatePage = React.forwardRef(
219
+ ({ feedbackId, fetchItem, updateItem, onNavigate, currentUser, className }, ref) => {
220
+ const [item, setItem] = React.useState(null);
221
+ const [loading, setLoading] = React.useState(true);
222
+ const [saving, setSaving] = React.useState(false);
223
+ const [status, setStatus] = React.useState("open");
224
+ const [type, setType] = React.useState("bug");
225
+ const [scope, setScope] = React.useState("");
226
+ const [size, setSize] = React.useState("");
227
+ const [assignedTo, setAssignedTo] = React.useState("");
228
+ const [tags, setTags] = React.useState("");
229
+ const [correctionNote, setCorrectionNote] = React.useState("");
230
+ React.useEffect(() => {
231
+ let cancelled = false;
232
+ setLoading(true);
233
+ fetchItem(feedbackId).then((data) => {
234
+ if (cancelled) return;
235
+ setItem(data);
236
+ setStatus(data.status);
237
+ setType(data.type);
238
+ setScope(data.scope ?? "");
239
+ setSize(data.size ?? "");
240
+ setAssignedTo(data.assignedTo ?? "");
241
+ setTags(data.tags?.join(", ") ?? "");
242
+ }).finally(() => {
243
+ if (!cancelled) setLoading(false);
244
+ });
245
+ return () => {
246
+ cancelled = true;
247
+ };
248
+ }, [feedbackId, fetchItem]);
249
+ const { allowedTargets } = useStatusTransition(status);
250
+ const statusOptions = STATUS_OPTIONS.filter(
251
+ (opt) => opt.value === status || allowedTargets.includes(opt.value)
252
+ );
253
+ const classificationChanged = item && (type !== item.type || scope !== (item.scope ?? "") || size !== (item.size ?? ""));
254
+ const isDirty = item && (status !== item.status || type !== item.type || scope !== (item.scope ?? "") || size !== (item.size ?? "") || assignedTo !== (item.assignedTo ?? "") || tags !== (item.tags?.join(", ") ?? ""));
255
+ const handleSave = async () => {
256
+ if (!item) return;
257
+ setSaving(true);
258
+ try {
259
+ const patch = { lastModifiedAt: (/* @__PURE__ */ new Date()).toISOString() };
260
+ if (status !== item.status) patch.status = status;
261
+ if (type !== item.type) patch.type = type;
262
+ if (scope !== (item.scope ?? "")) patch.scope = scope || void 0;
263
+ if (size !== (item.size ?? "")) patch.size = size || void 0;
264
+ if (assignedTo !== (item.assignedTo ?? "")) patch.assignedTo = assignedTo || void 0;
265
+ const newTags = tags.split(",").map((t) => t.trim()).filter(Boolean);
266
+ if (JSON.stringify(newTags) !== JSON.stringify(item.tags ?? [])) patch.tags = newTags;
267
+ if (status === "resolved" && item.status !== "resolved") {
268
+ patch.resolvedAt = (/* @__PURE__ */ new Date()).toISOString();
269
+ patch.resolvedBy = currentUser?.userId ?? "unknown";
270
+ }
271
+ if (classificationChanged && correctionNote) {
272
+ patch.corrections = [
273
+ ...item.corrections ?? [],
274
+ {
275
+ field: [type !== item.type && "type", scope !== (item.scope ?? "") && "scope", size !== (item.size ?? "") && "size"].filter(Boolean).join(", "),
276
+ before: { type: item.type, scope: item.scope, size: item.size },
277
+ after: { type, scope: scope || void 0, size: size || void 0 },
278
+ correctedBy: currentUser?.userId ?? "unknown",
279
+ correctedAt: (/* @__PURE__ */ new Date()).toISOString(),
280
+ reason: correctionNote
281
+ }
282
+ ];
283
+ }
284
+ await updateItem(feedbackId, patch);
285
+ onNavigate(`/feedback/${feedbackId}`);
286
+ } finally {
287
+ setSaving(false);
288
+ }
289
+ };
290
+ const headerActions = /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate(`/feedback/${feedbackId}`), children: "Cancel" });
291
+ return /* @__PURE__ */ jsx("div", { ref, className, children: /* @__PURE__ */ jsx(
292
+ UpdatePage,
293
+ {
294
+ title: `Edit: ${item?.content?.slice(0, 40) || feedbackId.slice(0, 12)}…`,
295
+ headerActions,
296
+ dataLoading: loading,
297
+ actions: /* @__PURE__ */ jsxs(SaveFooter, { dirty: !!isDirty, saving, children: [
298
+ /* @__PURE__ */ jsx(Button, { variant: "outline", onClick: () => onNavigate(`/feedback/${feedbackId}`), children: "Cancel" }),
299
+ /* @__PURE__ */ jsx(
300
+ Button,
301
+ {
302
+ onClick: handleSave,
303
+ disabled: saving || !isDirty || !!classificationChanged && !correctionNote,
304
+ children: saving ? "Saving…" : "Save changes"
305
+ }
306
+ )
307
+ ] }),
308
+ testId: "feedback-update-page",
309
+ children: item && /* @__PURE__ */ jsxs("div", { className: "space-y-6", children: [
310
+ /* @__PURE__ */ jsxs("div", { className: "rounded-lg border border-border p-4 bg-muted/30", children: [
311
+ /* @__PURE__ */ jsx("p", { className: "text-sm text-muted-foreground mb-2", children: "Original submission" }),
312
+ /* @__PURE__ */ jsx("p", { className: "whitespace-pre-wrap text-sm", children: item.content }),
313
+ /* @__PURE__ */ jsxs("div", { className: "flex gap-3 mt-3 text-xs text-muted-foreground", children: [
314
+ /* @__PURE__ */ jsx("span", { children: item.submittedAt ? new Date(item.submittedAt).toLocaleString() : "" }),
315
+ /* @__PURE__ */ jsx("span", { children: item.environment }),
316
+ /* @__PURE__ */ jsxs("span", { children: [
317
+ item.reporterCount ?? 1,
318
+ " reporter(s)"
319
+ ] })
320
+ ] })
321
+ ] }),
322
+ /* @__PURE__ */ jsxs("div", { className: "grid grid-cols-1 md:grid-cols-2 gap-4", children: [
323
+ /* @__PURE__ */ jsx(
324
+ EnumField,
325
+ {
326
+ label: "Status",
327
+ options: statusOptions,
328
+ value: status,
329
+ onChange: (v) => setStatus(v)
330
+ }
331
+ ),
332
+ /* @__PURE__ */ jsx(
333
+ EnumField,
334
+ {
335
+ label: "Type",
336
+ options: TYPE_OPTIONS,
337
+ value: type,
338
+ onChange: (v) => setType(v)
339
+ }
340
+ ),
341
+ /* @__PURE__ */ jsx(
342
+ EnumField,
343
+ {
344
+ label: "Scope",
345
+ options: SCOPE_OPTIONS,
346
+ value: scope,
347
+ onChange: setScope
348
+ }
349
+ ),
350
+ /* @__PURE__ */ jsx(
351
+ EnumField,
352
+ {
353
+ label: "Size",
354
+ options: SIZE_OPTIONS,
355
+ value: size,
356
+ onChange: setSize
357
+ }
358
+ ),
359
+ /* @__PURE__ */ jsx(
360
+ TextField,
361
+ {
362
+ label: "Assigned to",
363
+ value: assignedTo,
364
+ onChange: (e) => setAssignedTo(e.target.value),
365
+ placeholder: currentUser?.name ?? currentUser?.userId ?? "",
366
+ ...currentUser ? { description: `You: ${currentUser.name ?? currentUser.userId}` } : {}
367
+ }
368
+ ),
369
+ /* @__PURE__ */ jsx(
370
+ TextField,
371
+ {
372
+ label: "Tags",
373
+ value: tags,
374
+ onChange: (e) => setTags(e.target.value),
375
+ placeholder: "Comma-separated tags",
376
+ description: "Separate multiple tags with commas"
377
+ }
378
+ )
379
+ ] }),
380
+ classificationChanged && /* @__PURE__ */ jsx(
381
+ TextareaField,
382
+ {
383
+ label: "Correction note",
384
+ value: correctionNote,
385
+ onChange: (e) => setCorrectionNote(e.target.value),
386
+ required: true,
387
+ ...!correctionNote ? { error: "Required when changing type, scope, or size" } : {},
388
+ placeholder: "Explain why this classification is being changed..."
389
+ }
390
+ ),
391
+ item.tags && item.tags.length > 0 && /* @__PURE__ */ jsx("div", { className: "flex flex-wrap gap-1", children: item.tags.map((tag) => /* @__PURE__ */ jsx(Badge, { variant: "outline", children: tag }, tag)) })
392
+ ] })
393
+ }
394
+ ) });
395
+ }
396
+ );
397
+ FeedbackUpdatePage.displayName = "FeedbackUpdatePage";
398
+ export {
399
+ FeedbackDetailPage,
400
+ FeedbackListPage,
401
+ FeedbackUpdatePage,
402
+ StatusBadge,
403
+ TypeBadge,
404
+ useStatusTransition
405
+ };
406
+ //# sourceMappingURL=index.js.map