@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.
- package/dist/AdminRoot.d.ts.map +1 -1
- package/dist/AdminRoot.js +4 -0
- package/dist/AdminRoot.js.map +1 -1
- package/dist/__tests__/components/forms-pagination.render.test.d.ts +2 -0
- package/dist/__tests__/components/forms-pagination.render.test.d.ts.map +1 -0
- package/dist/__tests__/components/forms-pagination.render.test.js +33 -0
- package/dist/__tests__/components/forms-pagination.render.test.js.map +1 -0
- 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/__tests__/views/form-submissions.render.test.d.ts +2 -0
- package/dist/__tests__/views/form-submissions.render.test.d.ts.map +1 -0
- package/dist/__tests__/views/form-submissions.render.test.js +110 -0
- package/dist/__tests__/views/form-submissions.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/components/forms/EntryDetailDrawer.d.ts +11 -0
- package/dist/components/forms/EntryDetailDrawer.d.ts.map +1 -0
- package/dist/components/forms/EntryDetailDrawer.js +145 -0
- package/dist/components/forms/EntryDetailDrawer.js.map +1 -0
- package/dist/components/forms/NotificationsPanel.js +1 -1
- package/dist/components/forms/NotificationsPanel.js.map +1 -1
- package/dist/components/forms/primitives.d.ts +15 -0
- package/dist/components/forms/primitives.d.ts.map +1 -1
- package/dist/components/forms/primitives.js +36 -1
- package/dist/components/forms/primitives.js.map +1 -1
- 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/lib/forms-service.d.ts +36 -2
- package/dist/lib/forms-service.d.ts.map +1 -1
- package/dist/lib/forms-service.js +66 -0
- package/dist/lib/forms-service.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 +204 -77
- package/dist/views/FormSubmissions.js.map +1 -1
- package/dist/views/Forms.d.ts.map +1 -1
- package/dist/views/Forms.js +26 -10
- package/dist/views/Forms.js.map +1 -1
- package/package.json +1 -1
- package/src/AdminRoot.tsx +4 -0
- package/src/__tests__/components/forms-pagination.render.test.tsx +49 -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/__tests__/views/form-submissions.render.test.tsx +129 -0
- package/src/components/forms/EntriesTable.tsx +197 -0
- package/src/components/forms/EntryDetailDrawer.tsx +312 -0
- package/src/components/forms/NotificationsPanel.tsx +1 -1
- package/src/components/forms/primitives.tsx +101 -1
- package/src/layout/Sidebar.tsx +75 -17
- package/src/lib/forms-service.ts +99 -0
- package/src/views/FormEntries.tsx +449 -0
- package/src/views/FormSubmissions.tsx +391 -401
- package/src/views/Forms.tsx +145 -107
package/src/lib/forms-service.ts
CHANGED
|
@@ -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
|
+
}
|