@bygghammar/cms-platform 0.27.0 → 0.29.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/CHANGELOG.md +22 -0
- package/package.json +9 -3
- package/src/backoffice/FormDeliveriesView.tsx +344 -0
- package/src/backoffice/FormSettingsView.tsx +436 -0
- package/src/backoffice/FormSubmissionsView.tsx +646 -0
- package/src/backoffice/GlobalSectionBuilderView.tsx +225 -0
- package/src/backoffice/GlobalSectionsListView.tsx +193 -0
- package/src/backoffice/ReviewsView.tsx +231 -0
|
@@ -0,0 +1,646 @@
|
|
|
1
|
+
import type React from "react";
|
|
2
|
+
import { Fragment, useMemo, useState } from "react";
|
|
3
|
+
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
4
|
+
import { toast } from "sonner";
|
|
5
|
+
import {
|
|
6
|
+
ArrowLeft,
|
|
7
|
+
Download,
|
|
8
|
+
Mail,
|
|
9
|
+
RefreshCw,
|
|
10
|
+
Search,
|
|
11
|
+
Send,
|
|
12
|
+
Sheet as SheetIcon,
|
|
13
|
+
Trash2,
|
|
14
|
+
} from "lucide-react";
|
|
15
|
+
import type { FormFieldDef } from "../forms/field-sets";
|
|
16
|
+
|
|
17
|
+
/** Workflow states a submission can be in. */
|
|
18
|
+
export const SUBMISSION_STATUSES = ["new", "read", "handled", "spam"] as const;
|
|
19
|
+
export type SubmissionStatus = (typeof SUBMISSION_STATUSES)[number];
|
|
20
|
+
|
|
21
|
+
/** Page size used by the inbox; the host must query with the same value. */
|
|
22
|
+
export const SUBMISSIONS_PAGE_SIZE = 50;
|
|
23
|
+
|
|
24
|
+
/** One stored submission. */
|
|
25
|
+
export type SubmissionsViewRow = {
|
|
26
|
+
id: string;
|
|
27
|
+
form_key: string;
|
|
28
|
+
locale: string;
|
|
29
|
+
status: string;
|
|
30
|
+
created_at: string;
|
|
31
|
+
page_path?: string | null;
|
|
32
|
+
payload?: Record<string, string> | null;
|
|
33
|
+
notified_at?: string | null;
|
|
34
|
+
autoreply_at?: string | null;
|
|
35
|
+
sheet_synced_at?: string | null;
|
|
36
|
+
sheet_error?: string | null;
|
|
37
|
+
delivery_error?: string | null;
|
|
38
|
+
consent_at?: string | null;
|
|
39
|
+
ip?: string | null;
|
|
40
|
+
user_agent?: string | null;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
/** One part-filled form that was never submitted. */
|
|
44
|
+
export type PartialsViewRow = {
|
|
45
|
+
id: string;
|
|
46
|
+
form_key: string;
|
|
47
|
+
updated_at: string;
|
|
48
|
+
payload?: Record<string, string> | null;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Filter state the host keeps in the URL. */
|
|
52
|
+
export type SubmissionsFilters = {
|
|
53
|
+
form?: string;
|
|
54
|
+
status?: string;
|
|
55
|
+
locale?: string;
|
|
56
|
+
q?: string;
|
|
57
|
+
from?: string;
|
|
58
|
+
to?: string;
|
|
59
|
+
page?: number;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Host-provided data access and navigation for the submissions inbox. */
|
|
63
|
+
export type FormSubmissionsDeps = {
|
|
64
|
+
filters: SubmissionsFilters;
|
|
65
|
+
setFilters: (patch: SubmissionsFilters) => void;
|
|
66
|
+
clearFilters: () => void;
|
|
67
|
+
goToPage: (page: number) => void;
|
|
68
|
+
data:
|
|
69
|
+
| {
|
|
70
|
+
rows: SubmissionsViewRow[];
|
|
71
|
+
total: number;
|
|
72
|
+
counts?: Record<string, number>;
|
|
73
|
+
}
|
|
74
|
+
| undefined;
|
|
75
|
+
isLoading: boolean;
|
|
76
|
+
isFetching: boolean;
|
|
77
|
+
forms: { key: string; name: string; fields: FormFieldDef[] }[];
|
|
78
|
+
/** Active locales, used for the language filter. */
|
|
79
|
+
locales?: string[];
|
|
80
|
+
refresh: () => Promise<unknown> | void;
|
|
81
|
+
setStatus: (input: { data: { ids: string[]; status: SubmissionStatus } }) => Promise<unknown>;
|
|
82
|
+
pushToSheet: (input: { data: { ids: string[] } }) => Promise<{ error?: string | null }>;
|
|
83
|
+
resendDelivery: (input: {
|
|
84
|
+
data: { id: string; what: "notification" | "autoreply" };
|
|
85
|
+
}) => Promise<unknown>;
|
|
86
|
+
listPartials: (input: {
|
|
87
|
+
data: { formKey?: string; includeCompleted: boolean; limit: number };
|
|
88
|
+
}) => Promise<PartialsViewRow[]>;
|
|
89
|
+
deletePartials: (input: { data: { ids: string[] } }) => Promise<unknown>;
|
|
90
|
+
/** Host router link back to the forms index. */
|
|
91
|
+
BackLink: (props: { className?: string; children: React.ReactNode }) => React.ReactElement;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
function toCsv(rows: Record<string, string>[], columns: { key: string; label: string }[]) {
|
|
95
|
+
const esc = (v: string) => `"${(v ?? "").replace(/"/g, '""')}"`;
|
|
96
|
+
return [
|
|
97
|
+
columns.map((c) => esc(c.label)).join(","),
|
|
98
|
+
...rows.map((r) => columns.map((c) => esc(r[c.key] ?? "")).join(",")),
|
|
99
|
+
].join("\n");
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The submissions inbox: filtering, bulk actions, CSV export and drafts. */
|
|
103
|
+
export function FormSubmissionsView({ deps }: { deps: FormSubmissionsDeps }) {
|
|
104
|
+
const { filters, setFilters, data, isLoading, isFetching, forms } = deps;
|
|
105
|
+
const page = filters.page ?? 0;
|
|
106
|
+
const [term, setTerm] = useState(filters.q ?? "");
|
|
107
|
+
const [selected, setSelected] = useState<Set<string>>(new Set());
|
|
108
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
109
|
+
const [busy, setBusy] = useState(false);
|
|
110
|
+
|
|
111
|
+
const rows = data?.rows ?? [];
|
|
112
|
+
const total = data?.total ?? 0;
|
|
113
|
+
const counts = data?.counts ?? {};
|
|
114
|
+
const locales = deps.locales ?? ["sv", "en"];
|
|
115
|
+
|
|
116
|
+
/** Readable labels per field key, from the form definitions. */
|
|
117
|
+
const labels = useMemo(() => {
|
|
118
|
+
const m = new Map<string, string>();
|
|
119
|
+
for (const f of forms) {
|
|
120
|
+
for (const fl of f.fields ?? []) {
|
|
121
|
+
m.set(fl.key, fl.label?.sv ?? fl.label?.en ?? fl.key);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
return m;
|
|
125
|
+
}, [forms]);
|
|
126
|
+
|
|
127
|
+
const columns = useMemo(() => {
|
|
128
|
+
const keys = new Set<string>();
|
|
129
|
+
for (const r of rows) for (const k of Object.keys(r.payload ?? {})) keys.add(k);
|
|
130
|
+
return Array.from(keys).map((k) => ({ key: k, label: labels.get(k) ?? k }));
|
|
131
|
+
}, [rows, labels]);
|
|
132
|
+
|
|
133
|
+
const download = () => {
|
|
134
|
+
const flat = rows.map((r) => ({
|
|
135
|
+
created_at: new Date(r.created_at).toLocaleString("sv-SE"),
|
|
136
|
+
form: r.form_key,
|
|
137
|
+
locale: r.locale,
|
|
138
|
+
status: r.status,
|
|
139
|
+
page: r.page_path ?? "",
|
|
140
|
+
...(r.payload ?? {}),
|
|
141
|
+
}));
|
|
142
|
+
const csv = toCsv(flat, [
|
|
143
|
+
{ key: "created_at", label: "Received" },
|
|
144
|
+
{ key: "form", label: "Form" },
|
|
145
|
+
{ key: "locale", label: "Language" },
|
|
146
|
+
{ key: "status", label: "Status" },
|
|
147
|
+
{ key: "page", label: "Page" },
|
|
148
|
+
...columns,
|
|
149
|
+
]);
|
|
150
|
+
const url = URL.createObjectURL(new Blob([csv], { type: "text/csv;charset=utf-8" }));
|
|
151
|
+
const a = document.createElement("a");
|
|
152
|
+
a.href = url;
|
|
153
|
+
a.download = `submissions-${filters.form ?? "all"}.csv`;
|
|
154
|
+
a.click();
|
|
155
|
+
URL.revokeObjectURL(url);
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const runBulk = async (fn: () => Promise<unknown>, okMsg: string) => {
|
|
159
|
+
setBusy(true);
|
|
160
|
+
try {
|
|
161
|
+
await fn();
|
|
162
|
+
await deps.refresh();
|
|
163
|
+
setSelected(new Set());
|
|
164
|
+
toast.success(okMsg);
|
|
165
|
+
} catch (e) {
|
|
166
|
+
toast.error(e instanceof Error ? e.message : "Action failed");
|
|
167
|
+
} finally {
|
|
168
|
+
setBusy(false);
|
|
169
|
+
}
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const ids = Array.from(selected);
|
|
173
|
+
const allOnPage = rows.length > 0 && rows.every((r) => selected.has(r.id));
|
|
174
|
+
|
|
175
|
+
return (
|
|
176
|
+
<div className="p-8 md:p-12 w-full">
|
|
177
|
+
<div className="flex flex-wrap items-end justify-between gap-4">
|
|
178
|
+
<div>
|
|
179
|
+
<deps.BackLink className="inline-flex items-center gap-2 text-[0.7rem] uppercase tracking-[0.22em] text-ink/55 hover:text-sea">
|
|
180
|
+
<ArrowLeft className="size-3.5" />
|
|
181
|
+
Back to forms
|
|
182
|
+
</deps.BackLink>
|
|
183
|
+
<h1 className="mt-3 font-serif text-2xl text-ink">Submissions</h1>
|
|
184
|
+
<p className="mt-1 text-xs text-ink/50">
|
|
185
|
+
{total} match{total === 1 ? "" : "es"} · New {counts.new ?? 0} · Read {counts.read ?? 0}{" "}
|
|
186
|
+
· Handled {counts.handled ?? 0} · Spam {counts.spam ?? 0}
|
|
187
|
+
</p>
|
|
188
|
+
</div>
|
|
189
|
+
<div className="flex items-center gap-2">
|
|
190
|
+
<button
|
|
191
|
+
onClick={() => deps.refresh()}
|
|
192
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
193
|
+
>
|
|
194
|
+
<RefreshCw className={`size-4 ${isFetching ? "animate-spin" : ""}`} />
|
|
195
|
+
Refresh
|
|
196
|
+
</button>
|
|
197
|
+
<button
|
|
198
|
+
onClick={download}
|
|
199
|
+
className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] hover:border-sea"
|
|
200
|
+
>
|
|
201
|
+
<Download className="size-4" />
|
|
202
|
+
Export CSV
|
|
203
|
+
</button>
|
|
204
|
+
</div>
|
|
205
|
+
</div>
|
|
206
|
+
|
|
207
|
+
{/* Filters */}
|
|
208
|
+
<div className="mt-6 flex flex-wrap items-end gap-3 border border-ink/10 bg-white p-4">
|
|
209
|
+
<label className="min-w-[180px]">
|
|
210
|
+
<span className="text-xs text-ink/55">Form</span>
|
|
211
|
+
<select
|
|
212
|
+
value={filters.form ?? ""}
|
|
213
|
+
onChange={(e) => setFilters({ form: e.target.value || undefined })}
|
|
214
|
+
className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
215
|
+
>
|
|
216
|
+
<option value="">All forms</option>
|
|
217
|
+
{forms.map((f) => (
|
|
218
|
+
<option key={f.key} value={f.key}>
|
|
219
|
+
{f.name}
|
|
220
|
+
</option>
|
|
221
|
+
))}
|
|
222
|
+
</select>
|
|
223
|
+
</label>
|
|
224
|
+
<label className="min-w-[140px]">
|
|
225
|
+
<span className="text-xs text-ink/55">Status</span>
|
|
226
|
+
<select
|
|
227
|
+
value={filters.status ?? ""}
|
|
228
|
+
onChange={(e) => setFilters({ status: e.target.value || undefined })}
|
|
229
|
+
className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
230
|
+
>
|
|
231
|
+
<option value="">Any status</option>
|
|
232
|
+
{SUBMISSION_STATUSES.map((s) => (
|
|
233
|
+
<option key={s} value={s}>
|
|
234
|
+
{s[0].toUpperCase() + s.slice(1)}
|
|
235
|
+
</option>
|
|
236
|
+
))}
|
|
237
|
+
</select>
|
|
238
|
+
</label>
|
|
239
|
+
<label className="min-w-[120px]">
|
|
240
|
+
<span className="text-xs text-ink/55">Language</span>
|
|
241
|
+
<select
|
|
242
|
+
value={filters.locale ?? ""}
|
|
243
|
+
onChange={(e) => setFilters({ locale: e.target.value || undefined })}
|
|
244
|
+
className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
|
|
245
|
+
>
|
|
246
|
+
<option value="">Any</option>
|
|
247
|
+
{locales.map((l) => (
|
|
248
|
+
<option key={l} value={l}>
|
|
249
|
+
{l}
|
|
250
|
+
</option>
|
|
251
|
+
))}
|
|
252
|
+
</select>
|
|
253
|
+
</label>
|
|
254
|
+
<label>
|
|
255
|
+
<span className="text-xs text-ink/55">From</span>
|
|
256
|
+
<input
|
|
257
|
+
type="date"
|
|
258
|
+
value={filters.from ?? ""}
|
|
259
|
+
onChange={(e) => setFilters({ from: e.target.value || undefined })}
|
|
260
|
+
className="mt-1 border border-ink/20 px-3 py-2 text-sm"
|
|
261
|
+
/>
|
|
262
|
+
</label>
|
|
263
|
+
<label>
|
|
264
|
+
<span className="text-xs text-ink/55">To</span>
|
|
265
|
+
<input
|
|
266
|
+
type="date"
|
|
267
|
+
value={filters.to ?? ""}
|
|
268
|
+
onChange={(e) => setFilters({ to: e.target.value || undefined })}
|
|
269
|
+
className="mt-1 border border-ink/20 px-3 py-2 text-sm"
|
|
270
|
+
/>
|
|
271
|
+
</label>
|
|
272
|
+
<label className="flex-1 min-w-[200px]">
|
|
273
|
+
<span className="text-xs text-ink/55">Search answers</span>
|
|
274
|
+
<div className="mt-1 flex">
|
|
275
|
+
<input
|
|
276
|
+
value={term}
|
|
277
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
278
|
+
onKeyDown={(e) => {
|
|
279
|
+
if (e.key === "Enter") setFilters({ q: term.trim() || undefined });
|
|
280
|
+
}}
|
|
281
|
+
placeholder="name, email, keyword…"
|
|
282
|
+
className="w-full border border-ink/20 px-3 py-2 text-sm"
|
|
283
|
+
/>
|
|
284
|
+
<button
|
|
285
|
+
onClick={() => setFilters({ q: term.trim() || undefined })}
|
|
286
|
+
className="border border-l-0 border-ink/20 px-3 hover:border-sea"
|
|
287
|
+
title="Search"
|
|
288
|
+
>
|
|
289
|
+
<Search className="size-4" />
|
|
290
|
+
</button>
|
|
291
|
+
</div>
|
|
292
|
+
</label>
|
|
293
|
+
<button
|
|
294
|
+
onClick={() => {
|
|
295
|
+
setTerm("");
|
|
296
|
+
deps.clearFilters();
|
|
297
|
+
}}
|
|
298
|
+
className="px-3 py-2 text-xs uppercase tracking-[0.2em] text-ink/50 hover:text-sea"
|
|
299
|
+
>
|
|
300
|
+
Clear
|
|
301
|
+
</button>
|
|
302
|
+
</div>
|
|
303
|
+
|
|
304
|
+
{/* Bulk bar */}
|
|
305
|
+
{ids.length > 0 ? (
|
|
306
|
+
<div className="mt-4 flex flex-wrap items-center gap-3 border border-sea/40 bg-sea/5 px-4 py-3 text-xs">
|
|
307
|
+
<span className="uppercase tracking-[0.2em] text-ink/60">{ids.length} selected</span>
|
|
308
|
+
{SUBMISSION_STATUSES.map((s) => (
|
|
309
|
+
<button
|
|
310
|
+
key={s}
|
|
311
|
+
disabled={busy}
|
|
312
|
+
onClick={() =>
|
|
313
|
+
runBulk(() => deps.setStatus({ data: { ids, status: s } }), `Marked as ${s}`)
|
|
314
|
+
}
|
|
315
|
+
className="border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
|
|
316
|
+
>
|
|
317
|
+
Mark {s}
|
|
318
|
+
</button>
|
|
319
|
+
))}
|
|
320
|
+
<button
|
|
321
|
+
disabled={busy}
|
|
322
|
+
onClick={() =>
|
|
323
|
+
runBulk(async () => {
|
|
324
|
+
const res = await deps.pushToSheet({ data: { ids } });
|
|
325
|
+
if (res.error) toast.warning(res.error);
|
|
326
|
+
}, "Pushed to Google Sheets")
|
|
327
|
+
}
|
|
328
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
|
|
329
|
+
>
|
|
330
|
+
<SheetIcon className="size-3.5" />
|
|
331
|
+
Push to sheet
|
|
332
|
+
</button>
|
|
333
|
+
<button
|
|
334
|
+
onClick={() => setSelected(new Set())}
|
|
335
|
+
className="ml-auto text-ink/50 hover:text-sea"
|
|
336
|
+
>
|
|
337
|
+
Clear selection
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
) : null}
|
|
341
|
+
|
|
342
|
+
<div className="mt-6 border border-ink/10 bg-white overflow-x-auto">
|
|
343
|
+
<table className="w-full text-sm">
|
|
344
|
+
<thead>
|
|
345
|
+
<tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
|
|
346
|
+
<th className="px-4 py-3">
|
|
347
|
+
<input
|
|
348
|
+
type="checkbox"
|
|
349
|
+
checked={allOnPage}
|
|
350
|
+
onChange={(e) =>
|
|
351
|
+
setSelected(e.target.checked ? new Set(rows.map((r) => r.id)) : new Set())
|
|
352
|
+
}
|
|
353
|
+
className="size-4 accent-sea"
|
|
354
|
+
/>
|
|
355
|
+
</th>
|
|
356
|
+
<th className="px-4 py-3">Received</th>
|
|
357
|
+
<th className="px-4 py-3">Form</th>
|
|
358
|
+
<th className="px-4 py-3">Summary</th>
|
|
359
|
+
<th className="px-4 py-3">Status</th>
|
|
360
|
+
<th className="px-4 py-3">Delivery</th>
|
|
361
|
+
</tr>
|
|
362
|
+
</thead>
|
|
363
|
+
<tbody>
|
|
364
|
+
{isLoading ? (
|
|
365
|
+
<tr>
|
|
366
|
+
<td className="px-6 py-6 text-ink/50" colSpan={6}>
|
|
367
|
+
Loading…
|
|
368
|
+
</td>
|
|
369
|
+
</tr>
|
|
370
|
+
) : rows.length === 0 ? (
|
|
371
|
+
<tr>
|
|
372
|
+
<td className="px-6 py-6 text-ink/50" colSpan={6}>
|
|
373
|
+
No submissions match these filters.
|
|
374
|
+
</td>
|
|
375
|
+
</tr>
|
|
376
|
+
) : (
|
|
377
|
+
rows.map((r) => {
|
|
378
|
+
const p = r.payload ?? {};
|
|
379
|
+
const summary =
|
|
380
|
+
[p.email, p.name, [p.firstName, p.lastName].filter(Boolean).join(" "), p.phone]
|
|
381
|
+
.filter(Boolean)
|
|
382
|
+
.join(" · ") || "—";
|
|
383
|
+
const isOpen = open === r.id;
|
|
384
|
+
return (
|
|
385
|
+
<Fragment key={r.id}>
|
|
386
|
+
<tr
|
|
387
|
+
className="border-b border-ink/5 last:border-0 cursor-pointer hover:bg-ink/[0.02]"
|
|
388
|
+
onClick={() => setOpen(isOpen ? null : r.id)}
|
|
389
|
+
>
|
|
390
|
+
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
391
|
+
<input
|
|
392
|
+
type="checkbox"
|
|
393
|
+
checked={selected.has(r.id)}
|
|
394
|
+
onChange={(e) => {
|
|
395
|
+
const next = new Set(selected);
|
|
396
|
+
if (e.target.checked) next.add(r.id);
|
|
397
|
+
else next.delete(r.id);
|
|
398
|
+
setSelected(next);
|
|
399
|
+
}}
|
|
400
|
+
className="size-4 accent-sea"
|
|
401
|
+
/>
|
|
402
|
+
</td>
|
|
403
|
+
<td className="px-4 py-3 text-ink/60 whitespace-nowrap">
|
|
404
|
+
{new Date(r.created_at).toLocaleString("sv-SE")}
|
|
405
|
+
</td>
|
|
406
|
+
<td className="px-4 py-3 text-ink/60">{r.form_key}</td>
|
|
407
|
+
<td className="px-4 py-3 text-ink">
|
|
408
|
+
{r.status === "new" ? (
|
|
409
|
+
<span className="mr-2 inline-block size-1.5 rounded-full bg-sea align-middle" />
|
|
410
|
+
) : null}
|
|
411
|
+
{summary}
|
|
412
|
+
</td>
|
|
413
|
+
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
414
|
+
<select
|
|
415
|
+
value={r.status}
|
|
416
|
+
onChange={async (e) => {
|
|
417
|
+
try {
|
|
418
|
+
await deps.setStatus({
|
|
419
|
+
data: {
|
|
420
|
+
ids: [r.id],
|
|
421
|
+
status: e.target.value as SubmissionStatus,
|
|
422
|
+
},
|
|
423
|
+
});
|
|
424
|
+
await deps.refresh();
|
|
425
|
+
} catch (err) {
|
|
426
|
+
toast.error(
|
|
427
|
+
err instanceof Error ? err.message : "Could not update status",
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
}}
|
|
431
|
+
className="border border-ink/15 bg-white px-2 py-1 text-xs"
|
|
432
|
+
>
|
|
433
|
+
{SUBMISSION_STATUSES.map((s) => (
|
|
434
|
+
<option key={s} value={s}>
|
|
435
|
+
{s[0].toUpperCase() + s.slice(1)}
|
|
436
|
+
</option>
|
|
437
|
+
))}
|
|
438
|
+
</select>
|
|
439
|
+
</td>
|
|
440
|
+
<td className="px-4 py-3 text-[0.7rem] text-ink/50 whitespace-nowrap">
|
|
441
|
+
{r.notified_at ? "Notified" : "—"} · {r.autoreply_at ? "Replied" : "—"} ·{" "}
|
|
442
|
+
{r.sheet_synced_at ? "Synced" : r.sheet_error ? "Sheet error" : "—"}
|
|
443
|
+
</td>
|
|
444
|
+
</tr>
|
|
445
|
+
{isOpen ? (
|
|
446
|
+
<tr className="border-b border-ink/5 bg-ink/[0.02]">
|
|
447
|
+
<td colSpan={6} className="px-6 py-4">
|
|
448
|
+
<dl className="grid gap-3 sm:grid-cols-2">
|
|
449
|
+
{Object.entries(p).map(([k, v]) => (
|
|
450
|
+
<div key={k}>
|
|
451
|
+
<dt className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/45">
|
|
452
|
+
{labels.get(k) ?? k}
|
|
453
|
+
</dt>
|
|
454
|
+
<dd className="whitespace-pre-wrap text-ink">{String(v) || "—"}</dd>
|
|
455
|
+
</div>
|
|
456
|
+
))}
|
|
457
|
+
</dl>
|
|
458
|
+
<p className="mt-4 text-xs text-ink/45">
|
|
459
|
+
{r.locale} · {r.page_path ?? "—"} ·{" "}
|
|
460
|
+
{r.consent_at
|
|
461
|
+
? `consent ${new Date(r.consent_at).toLocaleString("sv-SE")}`
|
|
462
|
+
: "no consent"}
|
|
463
|
+
{r.ip ? ` · ${r.ip}` : ""}
|
|
464
|
+
</p>
|
|
465
|
+
{r.user_agent ? (
|
|
466
|
+
<p className="mt-1 text-[0.7rem] text-ink/35">{r.user_agent}</p>
|
|
467
|
+
) : null}
|
|
468
|
+
{r.delivery_error || r.sheet_error ? (
|
|
469
|
+
<p className="mt-2 text-xs text-red-500">
|
|
470
|
+
{[r.delivery_error, r.sheet_error].filter(Boolean).join(" · ")}
|
|
471
|
+
</p>
|
|
472
|
+
) : null}
|
|
473
|
+
<div className="mt-4 flex flex-wrap gap-2">
|
|
474
|
+
<button
|
|
475
|
+
disabled={busy}
|
|
476
|
+
onClick={() =>
|
|
477
|
+
runBulk(
|
|
478
|
+
() => deps.resendDelivery({ data: { id: r.id, what: "notification" } }),
|
|
479
|
+
"Notification sent",
|
|
480
|
+
)
|
|
481
|
+
}
|
|
482
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
|
|
483
|
+
>
|
|
484
|
+
<Send className="size-3.5" />
|
|
485
|
+
Resend notification
|
|
486
|
+
</button>
|
|
487
|
+
<button
|
|
488
|
+
disabled={busy}
|
|
489
|
+
onClick={() =>
|
|
490
|
+
runBulk(
|
|
491
|
+
() => deps.resendDelivery({ data: { id: r.id, what: "autoreply" } }),
|
|
492
|
+
"Autoreply sent",
|
|
493
|
+
)
|
|
494
|
+
}
|
|
495
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
|
|
496
|
+
>
|
|
497
|
+
<Mail className="size-3.5" />
|
|
498
|
+
Send autoreply
|
|
499
|
+
</button>
|
|
500
|
+
<button
|
|
501
|
+
disabled={busy}
|
|
502
|
+
onClick={() =>
|
|
503
|
+
runBulk(async () => {
|
|
504
|
+
const res = await deps.pushToSheet({ data: { ids: [r.id] } });
|
|
505
|
+
if (res.error) toast.warning(res.error);
|
|
506
|
+
}, "Pushed to sheet")
|
|
507
|
+
}
|
|
508
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 bg-white px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-50"
|
|
509
|
+
>
|
|
510
|
+
<SheetIcon className="size-3.5" />
|
|
511
|
+
Retry sheet sync
|
|
512
|
+
</button>
|
|
513
|
+
</div>
|
|
514
|
+
</td>
|
|
515
|
+
</tr>
|
|
516
|
+
) : null}
|
|
517
|
+
</Fragment>
|
|
518
|
+
);
|
|
519
|
+
})
|
|
520
|
+
)}
|
|
521
|
+
</tbody>
|
|
522
|
+
</table>
|
|
523
|
+
</div>
|
|
524
|
+
|
|
525
|
+
<div className="mt-4 flex items-center justify-between text-xs text-ink/55">
|
|
526
|
+
<span>
|
|
527
|
+
Page {page + 1} of {Math.max(1, Math.ceil(total / SUBMISSIONS_PAGE_SIZE))}
|
|
528
|
+
</span>
|
|
529
|
+
<div className="flex gap-2">
|
|
530
|
+
<button
|
|
531
|
+
disabled={page === 0}
|
|
532
|
+
onClick={() => deps.goToPage(page - 1)}
|
|
533
|
+
className="border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
|
|
534
|
+
>
|
|
535
|
+
Previous
|
|
536
|
+
</button>
|
|
537
|
+
<button
|
|
538
|
+
disabled={(page + 1) * SUBMISSIONS_PAGE_SIZE >= total}
|
|
539
|
+
onClick={() => deps.goToPage(page + 1)}
|
|
540
|
+
className="border border-ink/15 bg-white px-3 py-1.5 uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
|
|
541
|
+
>
|
|
542
|
+
Next
|
|
543
|
+
</button>
|
|
544
|
+
</div>
|
|
545
|
+
</div>
|
|
546
|
+
|
|
547
|
+
<PartialsPanel deps={deps} labels={labels} />
|
|
548
|
+
</div>
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Part-filled forms that were never submitted, kept apart from real entries. */
|
|
553
|
+
function PartialsPanel({
|
|
554
|
+
deps,
|
|
555
|
+
labels,
|
|
556
|
+
}: {
|
|
557
|
+
deps: FormSubmissionsDeps;
|
|
558
|
+
labels: Map<string, string>;
|
|
559
|
+
}) {
|
|
560
|
+
const qc = useQueryClient();
|
|
561
|
+
const [open, setOpen] = useState(false);
|
|
562
|
+
const formKey = deps.filters.form;
|
|
563
|
+
|
|
564
|
+
const { data, isFetching } = useQuery({
|
|
565
|
+
queryKey: ["form-partials", formKey],
|
|
566
|
+
queryFn: () => deps.listPartials({ data: { formKey, includeCompleted: false, limit: 100 } }),
|
|
567
|
+
enabled: open,
|
|
568
|
+
});
|
|
569
|
+
const rows = data ?? [];
|
|
570
|
+
|
|
571
|
+
const remove = async (id: string) => {
|
|
572
|
+
try {
|
|
573
|
+
await deps.deletePartials({ data: { ids: [id] } });
|
|
574
|
+
await qc.invalidateQueries({ queryKey: ["form-partials"] });
|
|
575
|
+
toast.success("Draft deleted");
|
|
576
|
+
} catch (e) {
|
|
577
|
+
toast.error(e instanceof Error ? e.message : "Delete failed");
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
return (
|
|
582
|
+
<div className="mt-10 border border-ink/10 bg-white">
|
|
583
|
+
<button
|
|
584
|
+
onClick={() => setOpen((v) => !v)}
|
|
585
|
+
className="flex w-full items-center justify-between px-5 py-4 text-left"
|
|
586
|
+
>
|
|
587
|
+
<span>
|
|
588
|
+
<span className="text-[0.7rem] uppercase tracking-[0.22em] text-ink/55">
|
|
589
|
+
Incomplete forms (drafts)
|
|
590
|
+
</span>
|
|
591
|
+
<span className="mt-1 block text-xs text-ink/45">
|
|
592
|
+
What visitors typed but never sent. Never merged into submissions.
|
|
593
|
+
</span>
|
|
594
|
+
</span>
|
|
595
|
+
<span className="text-xs uppercase tracking-[0.2em] text-ink/45">
|
|
596
|
+
{open ? "Hide" : "Show"}
|
|
597
|
+
</span>
|
|
598
|
+
</button>
|
|
599
|
+
|
|
600
|
+
{open ? (
|
|
601
|
+
<div className="border-t border-ink/10">
|
|
602
|
+
{isFetching && !rows.length ? (
|
|
603
|
+
<p className="p-5 text-xs text-ink/45">Loading…</p>
|
|
604
|
+
) : !rows.length ? (
|
|
605
|
+
<p className="p-5 text-xs text-ink/45">No drafts right now.</p>
|
|
606
|
+
) : (
|
|
607
|
+
<table className="w-full text-sm">
|
|
608
|
+
<thead className="bg-paper/60 text-[0.65rem] uppercase tracking-[0.18em] text-ink/50">
|
|
609
|
+
<tr>
|
|
610
|
+
<th className="px-4 py-2 text-left">Last typed</th>
|
|
611
|
+
<th className="px-4 py-2 text-left">Form</th>
|
|
612
|
+
<th className="px-4 py-2 text-left">Answers so far</th>
|
|
613
|
+
<th className="px-4 py-2" />
|
|
614
|
+
</tr>
|
|
615
|
+
</thead>
|
|
616
|
+
<tbody>
|
|
617
|
+
{rows.map((r) => (
|
|
618
|
+
<tr key={r.id} className="border-t border-ink/5 align-top">
|
|
619
|
+
<td className="whitespace-nowrap px-4 py-3 text-xs text-ink/60">
|
|
620
|
+
{new Date(r.updated_at).toLocaleString("sv-SE")}
|
|
621
|
+
</td>
|
|
622
|
+
<td className="px-4 py-3 text-xs text-ink/60">{r.form_key}</td>
|
|
623
|
+
<td className="px-4 py-3 text-xs text-ink/70">
|
|
624
|
+
{Object.entries(r.payload ?? {})
|
|
625
|
+
.map(([k, v]) => `${labels.get(k) ?? k}: ${v}`)
|
|
626
|
+
.join(" · ")}
|
|
627
|
+
</td>
|
|
628
|
+
<td className="px-4 py-3 text-right">
|
|
629
|
+
<button
|
|
630
|
+
onClick={() => remove(r.id)}
|
|
631
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-2.5 py-1.5 text-[0.6rem] uppercase tracking-[0.2em] hover:border-red-500 hover:text-red-600"
|
|
632
|
+
>
|
|
633
|
+
<Trash2 className="size-3.5" />
|
|
634
|
+
Delete
|
|
635
|
+
</button>
|
|
636
|
+
</td>
|
|
637
|
+
</tr>
|
|
638
|
+
))}
|
|
639
|
+
</tbody>
|
|
640
|
+
</table>
|
|
641
|
+
)}
|
|
642
|
+
</div>
|
|
643
|
+
) : null}
|
|
644
|
+
</div>
|
|
645
|
+
);
|
|
646
|
+
}
|