@actuate-media/cms-admin 0.23.0 → 0.24.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/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
- package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-entries.render.test.js +112 -0
- package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
- package/dist/actuate-admin.css +1 -1
- package/dist/components/forms/EntriesTable.d.ts +28 -0
- package/dist/components/forms/EntriesTable.d.ts.map +1 -0
- package/dist/components/forms/EntriesTable.js +26 -0
- package/dist/components/forms/EntriesTable.js.map +1 -0
- package/dist/layout/Sidebar.d.ts.map +1 -1
- package/dist/layout/Sidebar.js +44 -21
- package/dist/layout/Sidebar.js.map +1 -1
- package/dist/views/FormEntries.d.ts +5 -0
- package/dist/views/FormEntries.d.ts.map +1 -0
- package/dist/views/FormEntries.js +211 -0
- package/dist/views/FormEntries.js.map +1 -0
- package/dist/views/FormSubmissions.d.ts.map +1 -1
- package/dist/views/FormSubmissions.js +4 -24
- package/dist/views/FormSubmissions.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
- package/src/__tests__/views/form-entries.render.test.tsx +133 -0
- package/src/components/forms/EntriesTable.tsx +197 -0
- package/src/layout/Sidebar.tsx +75 -17
- package/src/views/FormEntries.tsx +449 -0
- package/src/views/FormSubmissions.tsx +4 -149
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* All Entries — a cross-form submissions inbox. Same token-only table, search,
|
|
5
|
+
* status pills (All / Unread / Starred / Archived), bulk actions, CSV export,
|
|
6
|
+
* pagination, and detail drawer as the per-form Submissions view, plus a Form
|
|
7
|
+
* filter and a Form column so editors can triage every form from one place.
|
|
8
|
+
*/
|
|
9
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
10
|
+
import { Download, Inbox, Search } from 'lucide-react'
|
|
11
|
+
import { toast } from 'sonner'
|
|
12
|
+
import {
|
|
13
|
+
bulkUpdateEntries,
|
|
14
|
+
deleteEntry,
|
|
15
|
+
fetchEntries,
|
|
16
|
+
fetchEntryCounts,
|
|
17
|
+
fetchForms,
|
|
18
|
+
updateEntry,
|
|
19
|
+
type EntryCounts,
|
|
20
|
+
type FetchFormEntriesParams,
|
|
21
|
+
type FormListItem,
|
|
22
|
+
type FormSubmission,
|
|
23
|
+
} from '../lib/forms-service.js'
|
|
24
|
+
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
25
|
+
import { EntryDetailDrawer } from '../components/forms/EntryDetailDrawer.js'
|
|
26
|
+
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
27
|
+
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
28
|
+
import {
|
|
29
|
+
FormsEmptyState,
|
|
30
|
+
FormsErrorState,
|
|
31
|
+
FormsLoading,
|
|
32
|
+
FormsPagination,
|
|
33
|
+
SummaryChip,
|
|
34
|
+
btnSecondary,
|
|
35
|
+
} from '../components/forms/primitives.js'
|
|
36
|
+
|
|
37
|
+
export interface FormEntriesProps {
|
|
38
|
+
onNavigate?: (path: string) => void
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
type StatusFilter = 'all' | 'unread' | 'starred' | 'archived'
|
|
42
|
+
|
|
43
|
+
const FILTERS: { id: StatusFilter; label: string }[] = [
|
|
44
|
+
{ id: 'all', label: 'All' },
|
|
45
|
+
{ id: 'unread', label: 'Unread' },
|
|
46
|
+
{ id: 'starred', label: 'Starred' },
|
|
47
|
+
{ id: 'archived', label: 'Archived' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
const PAGE_SIZE = 25
|
|
51
|
+
|
|
52
|
+
function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
|
|
53
|
+
switch (filter) {
|
|
54
|
+
case 'unread':
|
|
55
|
+
return { unread: true }
|
|
56
|
+
case 'starred':
|
|
57
|
+
return { starred: true }
|
|
58
|
+
case 'archived':
|
|
59
|
+
return { archived: true }
|
|
60
|
+
default:
|
|
61
|
+
return {}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function csvEscape(value: unknown): string {
|
|
66
|
+
const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
67
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function exportCsv(entries: FormSubmission[]) {
|
|
71
|
+
const dataKeys = [...new Set(entries.flatMap((e) => Object.keys(e.data ?? {})))]
|
|
72
|
+
const header = ['Entry', 'Form', 'Submitted', 'Name', 'Email', 'Phone', 'Status', ...dataKeys]
|
|
73
|
+
const lines = [header.map(csvEscape).join(',')]
|
|
74
|
+
for (const e of entries) {
|
|
75
|
+
const row = [
|
|
76
|
+
e.entryNumber ?? '',
|
|
77
|
+
e.formName ?? '',
|
|
78
|
+
e.submittedAt,
|
|
79
|
+
e.senderName ?? '',
|
|
80
|
+
e.senderEmail ?? '',
|
|
81
|
+
e.senderPhone ?? '',
|
|
82
|
+
e.unread ? 'unread' : 'read',
|
|
83
|
+
...dataKeys.map((k) => e.data?.[k]),
|
|
84
|
+
]
|
|
85
|
+
lines.push(row.map(csvEscape).join(','))
|
|
86
|
+
}
|
|
87
|
+
const blob = new Blob([lines.join('\n')], { type: 'text/csv;charset=utf-8' })
|
|
88
|
+
const url = URL.createObjectURL(blob)
|
|
89
|
+
const a = document.createElement('a')
|
|
90
|
+
a.href = url
|
|
91
|
+
a.download = 'all-entries.csv'
|
|
92
|
+
a.click()
|
|
93
|
+
URL.revokeObjectURL(url)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function FormEntries({ onNavigate }: FormEntriesProps) {
|
|
97
|
+
const [entries, setEntries] = useState<FormSubmission[]>([])
|
|
98
|
+
const [total, setTotal] = useState(0)
|
|
99
|
+
const [counts, setCounts] = useState<EntryCounts | null>(null)
|
|
100
|
+
const [forms, setForms] = useState<FormListItem[]>([])
|
|
101
|
+
const [loading, setLoading] = useState(true)
|
|
102
|
+
const [error, setError] = useState<string | null>(null)
|
|
103
|
+
const [search, setSearch] = useState('')
|
|
104
|
+
const [filter, setFilter] = useState<StatusFilter>('all')
|
|
105
|
+
const [formFilter, setFormFilter] = useState<string>('all')
|
|
106
|
+
const [page, setPage] = useState(1)
|
|
107
|
+
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
108
|
+
const [activeEntry, setActiveEntry] = useState<string | null>(null)
|
|
109
|
+
const [drawerOpen, setDrawerOpen] = useState(false)
|
|
110
|
+
const [confirmBulkDelete, setConfirmBulkDelete] = useState(false)
|
|
111
|
+
|
|
112
|
+
const scopedFormId = formFilter === 'all' ? undefined : formFilter
|
|
113
|
+
|
|
114
|
+
// Forms for the filter dropdown (loads once).
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
let cancelled = false
|
|
117
|
+
void fetchForms()
|
|
118
|
+
.then((list) => {
|
|
119
|
+
if (!cancelled) setForms(list)
|
|
120
|
+
})
|
|
121
|
+
.catch(() => {})
|
|
122
|
+
return () => {
|
|
123
|
+
cancelled = true
|
|
124
|
+
}
|
|
125
|
+
}, [])
|
|
126
|
+
|
|
127
|
+
const loadCounts = useCallback(() => {
|
|
128
|
+
void fetchEntryCounts({ formId: scopedFormId })
|
|
129
|
+
.then(setCounts)
|
|
130
|
+
.catch(() => {})
|
|
131
|
+
}, [scopedFormId])
|
|
132
|
+
|
|
133
|
+
const load = useCallback(async () => {
|
|
134
|
+
setLoading(true)
|
|
135
|
+
setError(null)
|
|
136
|
+
try {
|
|
137
|
+
const result = await fetchEntries({
|
|
138
|
+
formId: scopedFormId,
|
|
139
|
+
search: search.trim() || undefined,
|
|
140
|
+
page,
|
|
141
|
+
pageSize: PAGE_SIZE,
|
|
142
|
+
...filterToParams(filter),
|
|
143
|
+
})
|
|
144
|
+
setEntries(result.entries)
|
|
145
|
+
setTotal(result.total)
|
|
146
|
+
setSelected(new Set())
|
|
147
|
+
} catch (e) {
|
|
148
|
+
setError(e instanceof Error ? e.message : 'Failed to load entries')
|
|
149
|
+
} finally {
|
|
150
|
+
setLoading(false)
|
|
151
|
+
}
|
|
152
|
+
}, [scopedFormId, search, filter, page])
|
|
153
|
+
|
|
154
|
+
useEffect(() => {
|
|
155
|
+
void load()
|
|
156
|
+
}, [load])
|
|
157
|
+
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
loadCounts()
|
|
160
|
+
}, [loadCounts])
|
|
161
|
+
|
|
162
|
+
useEffect(() => onFormsChanged(() => loadCounts()), [loadCounts])
|
|
163
|
+
|
|
164
|
+
const refresh = useCallback(() => {
|
|
165
|
+
void load()
|
|
166
|
+
loadCounts()
|
|
167
|
+
}, [load, loadCounts])
|
|
168
|
+
|
|
169
|
+
const openEntry = useCallback((id: string) => {
|
|
170
|
+
setActiveEntry(id)
|
|
171
|
+
setDrawerOpen(true)
|
|
172
|
+
setEntries((cur) => cur.map((e) => (e.id === id ? { ...e, unread: false } : e)))
|
|
173
|
+
}, [])
|
|
174
|
+
|
|
175
|
+
const toggleStar = useCallback(async (entry: FormSubmission) => {
|
|
176
|
+
const next = !entry.starred
|
|
177
|
+
setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: next } : e)))
|
|
178
|
+
const res = await updateEntry(entry.id, { starred: next })
|
|
179
|
+
if (res.error) {
|
|
180
|
+
setEntries((cur) => cur.map((e) => (e.id === entry.id ? { ...e, starred: !next } : e)))
|
|
181
|
+
toast.error(res.error)
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
emitFormsChanged()
|
|
185
|
+
}, [])
|
|
186
|
+
|
|
187
|
+
const allSelected = entries.length > 0 && selected.size === entries.length
|
|
188
|
+
const toggleSelectAll = useCallback(() => {
|
|
189
|
+
setSelected((cur) =>
|
|
190
|
+
cur.size === entries.length ? new Set() : new Set(entries.map((e) => e.id)),
|
|
191
|
+
)
|
|
192
|
+
}, [entries])
|
|
193
|
+
|
|
194
|
+
const toggleSelect = useCallback((id: string) => {
|
|
195
|
+
setSelected((cur) => {
|
|
196
|
+
const next = new Set(cur)
|
|
197
|
+
if (next.has(id)) next.delete(id)
|
|
198
|
+
else next.add(id)
|
|
199
|
+
return next
|
|
200
|
+
})
|
|
201
|
+
}, [])
|
|
202
|
+
|
|
203
|
+
const bulkPatch = useCallback(
|
|
204
|
+
async (patch: { unread?: boolean; starred?: boolean; archived?: boolean }, label: string) => {
|
|
205
|
+
const ids = [...selected]
|
|
206
|
+
if (ids.length === 0) return
|
|
207
|
+
const res = await bulkUpdateEntries(ids, patch)
|
|
208
|
+
if (res.error) {
|
|
209
|
+
toast.error(res.error)
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} ${label}`)
|
|
213
|
+
emitFormsChanged()
|
|
214
|
+
refresh()
|
|
215
|
+
},
|
|
216
|
+
[selected, refresh],
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
const handleBulkDelete = useCallback(async () => {
|
|
220
|
+
const ids = [...selected]
|
|
221
|
+
if (ids.length === 0) return
|
|
222
|
+
const results = await Promise.allSettled(ids.map((id) => deleteEntry(id)))
|
|
223
|
+
const failed = results.filter((r) => r.status === 'rejected' || r.value.error).length
|
|
224
|
+
if (failed > 0) toast.error(`${failed} of ${ids.length} could not be deleted`)
|
|
225
|
+
else toast.success(`${ids.length} ${ids.length === 1 ? 'entry' : 'entries'} deleted`)
|
|
226
|
+
emitFormsChanged()
|
|
227
|
+
refresh()
|
|
228
|
+
}, [selected, refresh])
|
|
229
|
+
|
|
230
|
+
const showEmpty = !loading && !error && entries.length === 0
|
|
231
|
+
const isFiltered = filter !== 'all' || formFilter !== 'all' || search.trim().length > 0
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<div className="space-y-5 p-6">
|
|
235
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
|
|
236
|
+
<div>
|
|
237
|
+
<h1 className="text-foreground text-2xl font-medium">All Entries</h1>
|
|
238
|
+
<p className="text-muted-foreground mt-1 text-sm">
|
|
239
|
+
{total} {total === 1 ? 'submission' : 'submissions'} across your forms
|
|
240
|
+
</p>
|
|
241
|
+
</div>
|
|
242
|
+
<button
|
|
243
|
+
type="button"
|
|
244
|
+
className={btnSecondary}
|
|
245
|
+
onClick={() => exportCsv(entries)}
|
|
246
|
+
disabled={entries.length === 0}
|
|
247
|
+
>
|
|
248
|
+
<Download className="h-4 w-4" aria-hidden />
|
|
249
|
+
Export CSV
|
|
250
|
+
</button>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
{counts && (
|
|
254
|
+
<div className="flex flex-wrap gap-2">
|
|
255
|
+
<SummaryChip value={counts.total} label="total" />
|
|
256
|
+
<SummaryChip
|
|
257
|
+
value={counts.unread}
|
|
258
|
+
label="unread"
|
|
259
|
+
tone={counts.unread > 0 ? 'destructive' : 'neutral'}
|
|
260
|
+
/>
|
|
261
|
+
<SummaryChip value={counts.starred} label="starred" tone="warning" />
|
|
262
|
+
<SummaryChip value={counts.thisWeek} label="this week" />
|
|
263
|
+
</div>
|
|
264
|
+
)}
|
|
265
|
+
|
|
266
|
+
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
267
|
+
<div className="flex flex-1 flex-col gap-3 sm:flex-row sm:items-center">
|
|
268
|
+
<div className="relative w-full sm:max-w-xs">
|
|
269
|
+
<Search
|
|
270
|
+
className="text-muted-foreground absolute top-1/2 left-3 h-4 w-4 -translate-y-1/2"
|
|
271
|
+
aria-hidden
|
|
272
|
+
/>
|
|
273
|
+
<input
|
|
274
|
+
type="search"
|
|
275
|
+
placeholder="Search entries…"
|
|
276
|
+
value={search}
|
|
277
|
+
onChange={(e) => {
|
|
278
|
+
setSearch(e.target.value)
|
|
279
|
+
setPage(1)
|
|
280
|
+
}}
|
|
281
|
+
aria-label="Search entries"
|
|
282
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring w-full rounded-lg border py-2 pr-3 pl-9 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
283
|
+
/>
|
|
284
|
+
</div>
|
|
285
|
+
<select
|
|
286
|
+
value={formFilter}
|
|
287
|
+
onChange={(e) => {
|
|
288
|
+
setFormFilter(e.target.value)
|
|
289
|
+
setPage(1)
|
|
290
|
+
}}
|
|
291
|
+
aria-label="Filter by form"
|
|
292
|
+
className="border-border bg-input-background text-foreground focus-visible:ring-ring rounded-lg border px-3 py-2 text-sm focus-visible:ring-2 focus-visible:outline-none"
|
|
293
|
+
>
|
|
294
|
+
<option value="all">All forms</option>
|
|
295
|
+
{forms.map((f) => (
|
|
296
|
+
<option key={f.id} value={f.id}>
|
|
297
|
+
{f.name}
|
|
298
|
+
</option>
|
|
299
|
+
))}
|
|
300
|
+
</select>
|
|
301
|
+
</div>
|
|
302
|
+
<div className="flex gap-1" role="group" aria-label="Filter entries">
|
|
303
|
+
{FILTERS.map((f) => (
|
|
304
|
+
<button
|
|
305
|
+
key={f.id}
|
|
306
|
+
type="button"
|
|
307
|
+
onClick={() => {
|
|
308
|
+
setFilter(f.id)
|
|
309
|
+
setPage(1)
|
|
310
|
+
}}
|
|
311
|
+
aria-pressed={filter === f.id}
|
|
312
|
+
className={`rounded-full px-3 py-1.5 text-sm font-medium transition-colors ${
|
|
313
|
+
filter === f.id
|
|
314
|
+
? 'bg-primary text-primary-foreground'
|
|
315
|
+
: 'text-muted-foreground hover:bg-accent hover:text-foreground'
|
|
316
|
+
}`}
|
|
317
|
+
>
|
|
318
|
+
{f.label}
|
|
319
|
+
</button>
|
|
320
|
+
))}
|
|
321
|
+
</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
{selected.size > 0 && (
|
|
325
|
+
<div
|
|
326
|
+
className="border-border bg-accent/40 flex flex-wrap items-center gap-2 rounded-lg border px-3 py-2 text-sm"
|
|
327
|
+
role="region"
|
|
328
|
+
aria-label="Bulk actions"
|
|
329
|
+
>
|
|
330
|
+
<span className="text-foreground font-medium">{selected.size} selected</span>
|
|
331
|
+
<span className="bg-border mx-1 h-4 w-px" aria-hidden />
|
|
332
|
+
<button
|
|
333
|
+
type="button"
|
|
334
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
|
|
335
|
+
onClick={() => void bulkPatch({ unread: false }, 'marked read')}
|
|
336
|
+
>
|
|
337
|
+
Mark read
|
|
338
|
+
</button>
|
|
339
|
+
<button
|
|
340
|
+
type="button"
|
|
341
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
|
|
342
|
+
onClick={() => void bulkPatch({ starred: true }, 'starred')}
|
|
343
|
+
>
|
|
344
|
+
Star
|
|
345
|
+
</button>
|
|
346
|
+
<button
|
|
347
|
+
type="button"
|
|
348
|
+
className="text-foreground hover:bg-accent rounded-md px-2 py-1 font-medium transition-colors"
|
|
349
|
+
onClick={() =>
|
|
350
|
+
void bulkPatch(
|
|
351
|
+
{ archived: filter !== 'archived' },
|
|
352
|
+
filter !== 'archived' ? 'archived' : 'unarchived',
|
|
353
|
+
)
|
|
354
|
+
}
|
|
355
|
+
>
|
|
356
|
+
{filter === 'archived' ? 'Unarchive' : 'Archive'}
|
|
357
|
+
</button>
|
|
358
|
+
<button
|
|
359
|
+
type="button"
|
|
360
|
+
className="text-destructive hover:bg-destructive/10 rounded-md px-2 py-1 font-medium transition-colors"
|
|
361
|
+
onClick={() => setConfirmBulkDelete(true)}
|
|
362
|
+
>
|
|
363
|
+
Delete
|
|
364
|
+
</button>
|
|
365
|
+
<button
|
|
366
|
+
type="button"
|
|
367
|
+
className="text-muted-foreground hover:text-foreground ml-auto rounded-md px-2 py-1 font-medium transition-colors"
|
|
368
|
+
onClick={() => setSelected(new Set())}
|
|
369
|
+
>
|
|
370
|
+
Clear
|
|
371
|
+
</button>
|
|
372
|
+
</div>
|
|
373
|
+
)}
|
|
374
|
+
|
|
375
|
+
{error ? (
|
|
376
|
+
<FormsErrorState message={error} onRetry={load} />
|
|
377
|
+
) : loading ? (
|
|
378
|
+
<FormsLoading label="Loading entries…" />
|
|
379
|
+
) : showEmpty ? (
|
|
380
|
+
<FormsEmptyState
|
|
381
|
+
icon={<Inbox className="h-6 w-6" aria-hidden />}
|
|
382
|
+
title={isFiltered ? 'No entries match your filters' : 'No entries yet'}
|
|
383
|
+
description={
|
|
384
|
+
isFiltered
|
|
385
|
+
? 'Try a different search term, form, or status filter.'
|
|
386
|
+
: 'Submissions across all your forms will appear here.'
|
|
387
|
+
}
|
|
388
|
+
action={
|
|
389
|
+
isFiltered ? (
|
|
390
|
+
<button
|
|
391
|
+
type="button"
|
|
392
|
+
onClick={() => {
|
|
393
|
+
setSearch('')
|
|
394
|
+
setFilter('all')
|
|
395
|
+
setFormFilter('all')
|
|
396
|
+
setPage(1)
|
|
397
|
+
}}
|
|
398
|
+
className="text-primary text-sm font-medium hover:underline"
|
|
399
|
+
>
|
|
400
|
+
Clear filters
|
|
401
|
+
</button>
|
|
402
|
+
) : undefined
|
|
403
|
+
}
|
|
404
|
+
/>
|
|
405
|
+
) : (
|
|
406
|
+
<EntriesTable
|
|
407
|
+
entries={entries}
|
|
408
|
+
selected={selected}
|
|
409
|
+
allSelected={allSelected}
|
|
410
|
+
onToggleSelectAll={toggleSelectAll}
|
|
411
|
+
onToggleSelect={toggleSelect}
|
|
412
|
+
onOpen={openEntry}
|
|
413
|
+
onToggleStar={toggleStar}
|
|
414
|
+
showForm
|
|
415
|
+
onOpenForm={(id) => onNavigate?.(`/forms/${id}/submissions`)}
|
|
416
|
+
ariaLabel="All entries"
|
|
417
|
+
footer={
|
|
418
|
+
<FormsPagination
|
|
419
|
+
page={page}
|
|
420
|
+
pageSize={PAGE_SIZE}
|
|
421
|
+
total={total}
|
|
422
|
+
onPageChange={setPage}
|
|
423
|
+
noun="submission"
|
|
424
|
+
label="Entries pagination"
|
|
425
|
+
/>
|
|
426
|
+
}
|
|
427
|
+
/>
|
|
428
|
+
)}
|
|
429
|
+
|
|
430
|
+
<EntryDetailDrawer
|
|
431
|
+
entryId={activeEntry}
|
|
432
|
+
open={drawerOpen}
|
|
433
|
+
onOpenChange={setDrawerOpen}
|
|
434
|
+
onChanged={refresh}
|
|
435
|
+
onDeleted={() => refresh()}
|
|
436
|
+
/>
|
|
437
|
+
|
|
438
|
+
<ConfirmDialog
|
|
439
|
+
open={confirmBulkDelete}
|
|
440
|
+
onClose={() => setConfirmBulkDelete(false)}
|
|
441
|
+
onConfirm={() => void handleBulkDelete()}
|
|
442
|
+
title={`Delete ${selected.size} ${selected.size === 1 ? 'submission' : 'submissions'}?`}
|
|
443
|
+
description="This permanently removes the selected submissions and any attached files. This cannot be undone."
|
|
444
|
+
confirmLabel="Delete"
|
|
445
|
+
destructive
|
|
446
|
+
/>
|
|
447
|
+
</div>
|
|
448
|
+
)
|
|
449
|
+
}
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
* row click (marking the entry read). Loading / empty / filtered-empty / error
|
|
11
11
|
* states are all handled.
|
|
12
12
|
*/
|
|
13
|
-
import { useCallback, useEffect, useState
|
|
14
|
-
import { ArrowLeft, Download, Inbox, Search
|
|
13
|
+
import { useCallback, useEffect, useState } from 'react'
|
|
14
|
+
import { ArrowLeft, Download, Inbox, Search } from 'lucide-react'
|
|
15
15
|
import { toast } from 'sonner'
|
|
16
16
|
import {
|
|
17
17
|
bulkUpdateEntries,
|
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
} from '../lib/forms-service.js'
|
|
27
27
|
import { emitFormsChanged, onFormsChanged } from '../lib/forms-events.js'
|
|
28
28
|
import { EntryDetailDrawer } from '../components/forms/EntryDetailDrawer.js'
|
|
29
|
+
import { EntriesTable } from '../components/forms/EntriesTable.js'
|
|
29
30
|
import { ConfirmDialog } from '../components/ui/ConfirmDialog.js'
|
|
30
31
|
import {
|
|
31
32
|
FormsEmptyState,
|
|
@@ -33,7 +34,6 @@ import {
|
|
|
33
34
|
FormsLoading,
|
|
34
35
|
FormsPagination,
|
|
35
36
|
SummaryChip,
|
|
36
|
-
btnGhostIcon,
|
|
37
37
|
btnSecondary,
|
|
38
38
|
} from '../components/forms/primitives.js'
|
|
39
39
|
|
|
@@ -66,19 +66,6 @@ function filterToParams(filter: StatusFilter): Partial<FetchFormEntriesParams> {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
function formatDateTime(iso: string | null | undefined): string {
|
|
70
|
-
if (!iso) return '—'
|
|
71
|
-
const d = new Date(iso)
|
|
72
|
-
if (Number.isNaN(d.getTime())) return '—'
|
|
73
|
-
return d.toLocaleString(undefined, {
|
|
74
|
-
month: 'short',
|
|
75
|
-
day: 'numeric',
|
|
76
|
-
year: 'numeric',
|
|
77
|
-
hour: 'numeric',
|
|
78
|
-
minute: '2-digit',
|
|
79
|
-
})
|
|
80
|
-
}
|
|
81
|
-
|
|
82
69
|
function csvEscape(value: unknown): string {
|
|
83
70
|
const s = value == null ? '' : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
84
71
|
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
|
|
@@ -420,7 +407,7 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
420
407
|
}
|
|
421
408
|
/>
|
|
422
409
|
) : (
|
|
423
|
-
<
|
|
410
|
+
<EntriesTable
|
|
424
411
|
entries={entries}
|
|
425
412
|
selected={selected}
|
|
426
413
|
allSelected={allSelected}
|
|
@@ -461,135 +448,3 @@ export function FormSubmissions({ formId, onNavigate }: FormSubmissionsProps) {
|
|
|
461
448
|
</div>
|
|
462
449
|
)
|
|
463
450
|
}
|
|
464
|
-
|
|
465
|
-
function SubmissionsTable({
|
|
466
|
-
entries,
|
|
467
|
-
selected,
|
|
468
|
-
allSelected,
|
|
469
|
-
onToggleSelectAll,
|
|
470
|
-
onToggleSelect,
|
|
471
|
-
onOpen,
|
|
472
|
-
onToggleStar,
|
|
473
|
-
footer,
|
|
474
|
-
}: {
|
|
475
|
-
entries: FormSubmission[]
|
|
476
|
-
selected: Set<string>
|
|
477
|
-
allSelected: boolean
|
|
478
|
-
onToggleSelectAll: () => void
|
|
479
|
-
onToggleSelect: (id: string) => void
|
|
480
|
-
onOpen: (id: string) => void
|
|
481
|
-
onToggleStar: (entry: FormSubmission) => void
|
|
482
|
-
footer?: ReactNode
|
|
483
|
-
}) {
|
|
484
|
-
return (
|
|
485
|
-
<div className="border-border bg-card rounded-lg border">
|
|
486
|
-
<div className="overflow-x-auto">
|
|
487
|
-
<table className="w-full min-w-[720px] text-sm" aria-label="Submissions">
|
|
488
|
-
<thead>
|
|
489
|
-
<tr className="border-border text-muted-foreground border-b text-left text-xs">
|
|
490
|
-
<th scope="col" className="w-10 px-4 py-2.5">
|
|
491
|
-
<input
|
|
492
|
-
type="checkbox"
|
|
493
|
-
checked={allSelected}
|
|
494
|
-
onChange={onToggleSelectAll}
|
|
495
|
-
aria-label="Select all submissions"
|
|
496
|
-
className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
|
|
497
|
-
/>
|
|
498
|
-
</th>
|
|
499
|
-
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
500
|
-
Sender
|
|
501
|
-
</th>
|
|
502
|
-
<th scope="col" className="px-4 py-2.5 font-medium">
|
|
503
|
-
Submission
|
|
504
|
-
</th>
|
|
505
|
-
<th scope="col" className="px-4 py-2.5 font-medium whitespace-nowrap">
|
|
506
|
-
Submitted
|
|
507
|
-
</th>
|
|
508
|
-
<th scope="col" className="w-10 px-4 py-2.5 text-center font-medium">
|
|
509
|
-
<span className="sr-only">Starred</span>
|
|
510
|
-
</th>
|
|
511
|
-
</tr>
|
|
512
|
-
</thead>
|
|
513
|
-
<tbody>
|
|
514
|
-
{entries.map((entry) => {
|
|
515
|
-
const isSelected = selected.has(entry.id)
|
|
516
|
-
const name = entry.senderName || 'Anonymous'
|
|
517
|
-
return (
|
|
518
|
-
<tr
|
|
519
|
-
key={entry.id}
|
|
520
|
-
className={`border-border hover:bg-accent/40 group cursor-pointer border-b transition-colors last:border-b-0 ${
|
|
521
|
-
isSelected ? 'bg-accent/30' : ''
|
|
522
|
-
}`}
|
|
523
|
-
onClick={() => onOpen(entry.id)}
|
|
524
|
-
>
|
|
525
|
-
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
526
|
-
<input
|
|
527
|
-
type="checkbox"
|
|
528
|
-
checked={isSelected}
|
|
529
|
-
onChange={() => onToggleSelect(entry.id)}
|
|
530
|
-
aria-label={`Select submission from ${name}`}
|
|
531
|
-
className="border-border text-primary focus-visible:ring-ring h-4 w-4 rounded"
|
|
532
|
-
/>
|
|
533
|
-
</td>
|
|
534
|
-
<td className="px-4 py-3">
|
|
535
|
-
<div className="flex items-center gap-3">
|
|
536
|
-
<span
|
|
537
|
-
className="bg-muted text-muted-foreground flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-medium"
|
|
538
|
-
aria-hidden
|
|
539
|
-
>
|
|
540
|
-
{name.charAt(0).toUpperCase()}
|
|
541
|
-
</span>
|
|
542
|
-
<div className="min-w-0">
|
|
543
|
-
<div className="flex items-center gap-2">
|
|
544
|
-
{entry.unread && (
|
|
545
|
-
<span
|
|
546
|
-
className="bg-primary h-1.5 w-1.5 shrink-0 rounded-full"
|
|
547
|
-
aria-label="Unread"
|
|
548
|
-
/>
|
|
549
|
-
)}
|
|
550
|
-
<span
|
|
551
|
-
className={`text-foreground truncate ${entry.unread ? 'font-semibold' : 'font-medium'}`}
|
|
552
|
-
>
|
|
553
|
-
{name}
|
|
554
|
-
</span>
|
|
555
|
-
</div>
|
|
556
|
-
{entry.senderEmail && (
|
|
557
|
-
<span className="text-muted-foreground block truncate text-xs">
|
|
558
|
-
{entry.senderEmail}
|
|
559
|
-
</span>
|
|
560
|
-
)}
|
|
561
|
-
</div>
|
|
562
|
-
</div>
|
|
563
|
-
</td>
|
|
564
|
-
<td className="px-4 py-3">
|
|
565
|
-
<span className="text-muted-foreground line-clamp-1 max-w-md">
|
|
566
|
-
{entry.preview || '—'}
|
|
567
|
-
</span>
|
|
568
|
-
</td>
|
|
569
|
-
<td className="text-muted-foreground px-4 py-3 whitespace-nowrap">
|
|
570
|
-
{formatDateTime(entry.submittedAt)}
|
|
571
|
-
</td>
|
|
572
|
-
<td className="px-4 py-3 text-center" onClick={(e) => e.stopPropagation()}>
|
|
573
|
-
<button
|
|
574
|
-
type="button"
|
|
575
|
-
className={btnGhostIcon}
|
|
576
|
-
aria-pressed={entry.starred}
|
|
577
|
-
aria-label={entry.starred ? 'Unstar submission' : 'Star submission'}
|
|
578
|
-
onClick={() => onToggleStar(entry)}
|
|
579
|
-
>
|
|
580
|
-
<Star
|
|
581
|
-
className={`h-4 w-4 ${entry.starred ? 'fill-warning text-warning' : ''}`}
|
|
582
|
-
aria-hidden
|
|
583
|
-
/>
|
|
584
|
-
</button>
|
|
585
|
-
</td>
|
|
586
|
-
</tr>
|
|
587
|
-
)
|
|
588
|
-
})}
|
|
589
|
-
</tbody>
|
|
590
|
-
</table>
|
|
591
|
-
</div>
|
|
592
|
-
{footer}
|
|
593
|
-
</div>
|
|
594
|
-
)
|
|
595
|
-
}
|