@actuate-media/cms-admin 0.22.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.
Files changed (66) hide show
  1. package/dist/AdminRoot.d.ts.map +1 -1
  2. package/dist/AdminRoot.js +4 -0
  3. package/dist/AdminRoot.js.map +1 -1
  4. package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
  5. package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
  6. package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
  7. package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
  8. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts +2 -0
  9. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.d.ts.map +1 -0
  10. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js +28 -0
  11. package/dist/__tests__/layout/sidebar-forms-submenu.render.test.js.map +1 -0
  12. package/dist/__tests__/views/form-entries.render.test.d.ts +2 -0
  13. package/dist/__tests__/views/form-entries.render.test.d.ts.map +1 -0
  14. package/dist/__tests__/views/form-entries.render.test.js +112 -0
  15. package/dist/__tests__/views/form-entries.render.test.js.map +1 -0
  16. package/dist/__tests__/views/form-submissions.render.test.d.ts +2 -0
  17. package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
  18. package/dist/__tests__/views/form-submissions.render.test.js +110 -0
  19. package/dist/__tests__/views/form-submissions.render.test.js.map +1 -0
  20. package/dist/actuate-admin.css +1 -1
  21. package/dist/components/forms/EntriesTable.d.ts +28 -0
  22. package/dist/components/forms/EntriesTable.d.ts.map +1 -0
  23. package/dist/components/forms/EntriesTable.js +26 -0
  24. package/dist/components/forms/EntriesTable.js.map +1 -0
  25. package/dist/components/forms/EntryDetailDrawer.d.ts +11 -0
  26. package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
  27. package/dist/components/forms/EntryDetailDrawer.js +145 -0
  28. package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
  29. package/dist/components/forms/NotificationsPanel.js +1 -1
  30. package/dist/components/forms/NotificationsPanel.js.map +1 -1
  31. package/dist/components/forms/primitives.d.ts +15 -0
  32. package/dist/components/forms/primitives.d.ts.map +1 -1
  33. package/dist/components/forms/primitives.js +36 -1
  34. package/dist/components/forms/primitives.js.map +1 -1
  35. package/dist/layout/Sidebar.d.ts.map +1 -1
  36. package/dist/layout/Sidebar.js +44 -21
  37. package/dist/layout/Sidebar.js.map +1 -1
  38. package/dist/lib/forms-service.d.ts +36 -2
  39. package/dist/lib/forms-service.d.ts.map +1 -1
  40. package/dist/lib/forms-service.js +66 -0
  41. package/dist/lib/forms-service.js.map +1 -1
  42. package/dist/views/FormEntries.d.ts +5 -0
  43. package/dist/views/FormEntries.d.ts.map +1 -0
  44. package/dist/views/FormEntries.js +211 -0
  45. package/dist/views/FormEntries.js.map +1 -0
  46. package/dist/views/FormSubmissions.d.ts.map +1 -1
  47. package/dist/views/FormSubmissions.js +204 -77
  48. package/dist/views/FormSubmissions.js.map +1 -1
  49. package/dist/views/Forms.d.ts.map +1 -1
  50. package/dist/views/Forms.js +26 -10
  51. package/dist/views/Forms.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/AdminRoot.tsx +4 -0
  54. package/src/__tests__/components/forms-pagination.render.test.tsx +49 -0
  55. package/src/__tests__/layout/sidebar-forms-submenu.render.test.tsx +38 -0
  56. package/src/__tests__/views/form-entries.render.test.tsx +133 -0
  57. package/src/__tests__/views/form-submissions.render.test.tsx +129 -0
  58. package/src/components/forms/EntriesTable.tsx +197 -0
  59. package/src/components/forms/EntryDetailDrawer.tsx +312 -0
  60. package/src/components/forms/NotificationsPanel.tsx +1 -1
  61. package/src/components/forms/primitives.tsx +101 -1
  62. package/src/layout/Sidebar.tsx +75 -17
  63. package/src/lib/forms-service.ts +99 -0
  64. package/src/views/FormEntries.tsx +449 -0
  65. package/src/views/FormSubmissions.tsx +391 -401
  66. package/src/views/Forms.tsx +145 -107
@@ -21,6 +21,9 @@ import type {
21
21
  FormStats,
22
22
  FormsSidebarCount,
23
23
  FetchFormsParams,
24
+ FormSubmission,
25
+ FetchFormEntriesParams,
26
+ EntryCounts,
24
27
  } from '@actuate-media/cms-core'
25
28
 
26
29
  export type {
@@ -35,6 +38,11 @@ export type {
35
38
  FormStatus,
36
39
  FormStats,
37
40
  FormsSidebarCount,
41
+ FormSubmission,
42
+ FormSubmissionFile,
43
+ FetchFormEntriesParams,
44
+ EntryCounts,
45
+ SpamStatus,
38
46
  } from '@actuate-media/cms-core'
39
47
 
40
48
  /**
@@ -242,3 +250,94 @@ export async function updateEmbedSettings(
242
250
  })
243
251
  return { data: res.data, error: res.error }
244
252
  }
253
+
254
+ // ─── Entries (submissions) ─────────────────────────────────────────────────
255
+
256
+ /** A page of submissions as returned by `GET /forms/entries`. */
257
+ export interface EntriesPage {
258
+ entries: FormSubmission[]
259
+ total: number
260
+ page: number
261
+ pageSize: number
262
+ }
263
+
264
+ /** Patch shape accepted by the entry mutation endpoints. */
265
+ export interface EntryPatch {
266
+ unread?: boolean
267
+ starred?: boolean
268
+ archived?: boolean
269
+ }
270
+
271
+ function buildEntriesQuery(params: FetchFormEntriesParams): string {
272
+ const qs = new URLSearchParams()
273
+ if (params.search) qs.set('search', params.search)
274
+ if (params.formId) qs.set('formId', params.formId)
275
+ if (typeof params.unread === 'boolean') qs.set('unread', String(params.unread))
276
+ if (typeof params.starred === 'boolean') qs.set('starred', String(params.starred))
277
+ if (typeof params.archived === 'boolean') qs.set('archived', String(params.archived))
278
+ if (params.spamStatus) qs.set('spamStatus', params.spamStatus)
279
+ if (params.dateFrom) qs.set('dateFrom', params.dateFrom)
280
+ if (params.dateTo) qs.set('dateTo', params.dateTo)
281
+ if (params.thisWeek) qs.set('thisWeek', 'true')
282
+ if (params.sortBy) qs.set('sortBy', params.sortBy)
283
+ if (params.sortDirection) qs.set('sortDirection', params.sortDirection)
284
+ if (params.page) qs.set('page', String(params.page))
285
+ if (params.pageSize) qs.set('pageSize', String(params.pageSize))
286
+ const str = qs.toString()
287
+ return str ? `?${str}` : ''
288
+ }
289
+
290
+ const EMPTY_PAGE: EntriesPage = { entries: [], total: 0, page: 1, pageSize: 25 }
291
+
292
+ export async function fetchEntries(params: FetchFormEntriesParams = {}): Promise<EntriesPage> {
293
+ const res = await cmsApi<EntriesPage>(`/forms/entries${buildEntriesQuery(params)}`)
294
+ throwIfError(res)
295
+ return res.data ?? EMPTY_PAGE
296
+ }
297
+
298
+ const EMPTY_COUNTS: EntryCounts = { total: 0, unread: 0, starred: 0, thisWeek: 0, byForm: {} }
299
+
300
+ export async function fetchEntryCounts(params: FetchFormEntriesParams = {}): Promise<EntryCounts> {
301
+ const res = await cmsApi<EntryCounts>(`/forms/entries/counts${buildEntriesQuery(params)}`)
302
+ throwIfError(res)
303
+ return res.data ?? EMPTY_COUNTS
304
+ }
305
+
306
+ /** A single entry rendered against the schema version it was captured with. */
307
+ export interface EntryDetail {
308
+ entry: FormSubmission
309
+ fields: FormField[]
310
+ }
311
+
312
+ export async function fetchEntry(id: string): Promise<EntryDetail | null> {
313
+ const res = await cmsApi<EntryDetail>(`/forms/entries/${encodeURIComponent(id)}`)
314
+ throwIfError(res)
315
+ return res.data ?? null
316
+ }
317
+
318
+ export async function updateEntry(
319
+ id: string,
320
+ patch: EntryPatch,
321
+ ): Promise<{ data?: FormSubmission; error?: string }> {
322
+ const res = await cmsApi<FormSubmission>(`/forms/entries/${encodeURIComponent(id)}`, {
323
+ method: 'PATCH',
324
+ body: JSON.stringify(patch),
325
+ })
326
+ return { data: res.data, error: res.error }
327
+ }
328
+
329
+ export async function deleteEntry(id: string): Promise<{ error?: string }> {
330
+ const res = await cmsApi(`/forms/entries/${encodeURIComponent(id)}`, { method: 'DELETE' })
331
+ return { error: res.error }
332
+ }
333
+
334
+ export async function bulkUpdateEntries(
335
+ ids: string[],
336
+ patch: EntryPatch,
337
+ ): Promise<{ data?: { updated: number }; error?: string }> {
338
+ const res = await cmsApi<{ updated: number }>('/forms/entries/bulk', {
339
+ method: 'POST',
340
+ body: JSON.stringify({ ids, patch }),
341
+ })
342
+ return { data: res.data, error: res.error }
343
+ }
@@ -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
+ }