@bygghammar/cms-platform 0.17.0 → 0.19.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.
@@ -0,0 +1,435 @@
1
+ import type React from "react";
2
+ import { useState } from "react";
3
+ import { toast } from "sonner";
4
+ import {
5
+ Activity,
6
+ Plus,
7
+ Settings2,
8
+ Inbox,
9
+ Trash2,
10
+ Copy,
11
+ RotateCcw,
12
+ ShieldAlert,
13
+ } from "lucide-react";
14
+ import { FORM_TYPES, type FormFieldDef } from "../forms/field-sets";
15
+
16
+ /** One row in the forms list. */
17
+ export type FormsListRow = {
18
+ id: string;
19
+ key: string;
20
+ name: string;
21
+ form_type: string;
22
+ is_active: boolean;
23
+ fields: FormFieldDef[];
24
+ submissions: number;
25
+ newSubmissions: number;
26
+ lastSubmissionAt?: string | null;
27
+ };
28
+
29
+ /** One soft-deleted form row. */
30
+ export type DeletedFormRow = {
31
+ id: string;
32
+ key: string;
33
+ name: string;
34
+ form_type: string;
35
+ deleted_at: string;
36
+ deleted_by_email?: string | null;
37
+ };
38
+
39
+ /** Host-provided data access and links for the forms index. */
40
+ export type FormsIndexDeps = {
41
+ forms: FormsListRow[];
42
+ isLoading: boolean;
43
+ bin: { rows: DeletedFormRow[]; canPurge: boolean } | undefined;
44
+ createForm: (input: {
45
+ data: { key: string; name: string; form_type: string };
46
+ }) => Promise<unknown>;
47
+ duplicateForm: (input: { data: { id: string; key: string; name: string } }) => Promise<unknown>;
48
+ deleteForm: (input: { data: { id: string } }) => Promise<unknown>;
49
+ restoreForm: (input: { data: { id: string } }) => Promise<unknown>;
50
+ purgeForm: (input: { data: { id: string } }) => Promise<unknown>;
51
+ getFormUsage: (input: {
52
+ data: { key: string };
53
+ }) => Promise<{ pages: string[]; globalSections: string[] }>;
54
+ refresh: () => Promise<unknown> | void;
55
+ /** Host router link; `params` carries the form key for the editor route. */
56
+ LinkComponent: (props: {
57
+ to: string;
58
+ params?: Record<string, string>;
59
+ search?: Record<string, unknown>;
60
+ className?: string;
61
+ children: React.ReactNode;
62
+ }) => React.ReactElement;
63
+ paths?: {
64
+ editor?: string;
65
+ submissions?: string;
66
+ deliveries?: string;
67
+ settings?: string;
68
+ };
69
+ };
70
+
71
+ function slugify(v: string) {
72
+ return v
73
+ .toLowerCase()
74
+ .replace(/[^a-z0-9]+/g, "-")
75
+ .replace(/^-+|-+$/g, "")
76
+ .slice(0, 64);
77
+ }
78
+
79
+ /** Host-agnostic forms index: create, list, duplicate, bin and restore forms. */
80
+ export function FormsIndexView({ deps }: { deps: FormsIndexDeps }) {
81
+ const {
82
+ forms,
83
+ isLoading,
84
+ bin,
85
+ createForm,
86
+ duplicateForm,
87
+ deleteForm,
88
+ restoreForm,
89
+ purgeForm,
90
+ getFormUsage,
91
+ refresh,
92
+ LinkComponent: Link,
93
+ } = deps;
94
+ const paths = {
95
+ editor: deps.paths?.editor ?? "/kontoret/forms/$key",
96
+ submissions: deps.paths?.submissions ?? "/kontoret/forms/submissions",
97
+ deliveries: deps.paths?.deliveries ?? "/kontoret/forms/deliveries",
98
+ settings: deps.paths?.settings ?? "/kontoret/forms/settings",
99
+ };
100
+
101
+ const [view, setView] = useState<"list" | "bin">("list");
102
+ const [name, setName] = useState("");
103
+ const [type, setType] = useState("contact");
104
+ const [busy, setBusy] = useState(false);
105
+
106
+ const onCreate = async () => {
107
+ if (!name.trim()) return;
108
+ setBusy(true);
109
+ try {
110
+ await createForm({ data: { key: slugify(name), name: name.trim(), form_type: type } });
111
+ await refresh();
112
+ setName("");
113
+ toast.success("Form created");
114
+ } catch (e) {
115
+ toast.error(e instanceof Error ? e.message : "Could not create the form");
116
+ } finally {
117
+ setBusy(false);
118
+ }
119
+ };
120
+
121
+ const onDuplicate = async (f: { id: string; name: string; key: string }) => {
122
+ const newName = window.prompt("Name for the copy", `${f.name} (copy)`);
123
+ if (!newName?.trim()) return;
124
+ let key = slugify(newName);
125
+ const taken = new Set(forms.map((x) => x.key));
126
+ if (!key || taken.has(key)) {
127
+ let i = 2;
128
+ const base = key || `${f.key}-copy`;
129
+ key = base;
130
+ while (taken.has(key)) key = `${base}-${i++}`;
131
+ }
132
+ try {
133
+ await duplicateForm({ data: { id: f.id, key, name: newName.trim() } });
134
+ await refresh();
135
+ toast.success(`Copied as "${newName.trim()}" (inactive)`);
136
+ } catch (e) {
137
+ toast.error(e instanceof Error ? e.message : "Could not copy the form");
138
+ }
139
+ };
140
+
141
+ const onDelete = async (f: { id: string; name: string; key: string }) => {
142
+ let warning = "";
143
+ try {
144
+ const u = await getFormUsage({ data: { key: f.key } });
145
+ const places = [...u.pages, ...u.globalSections];
146
+ if (places.length) {
147
+ warning = `\n\nWARNING: this form is still used on: ${places.join(", ")}. Those places will show nothing until you pick another form.`;
148
+ }
149
+ } catch {
150
+ /* usage check is advisory only */
151
+ }
152
+ if (
153
+ !window.confirm(
154
+ `Move the form "${f.name}" to the bin? Submissions are kept and stay visible in the inbox.${warning}`,
155
+ )
156
+ )
157
+ return;
158
+ try {
159
+ await deleteForm({ data: { id: f.id } });
160
+ await refresh();
161
+ toast.success("Form moved to the bin");
162
+ } catch (e) {
163
+ toast.error(e instanceof Error ? e.message : "Could not delete");
164
+ }
165
+ };
166
+
167
+ return (
168
+ <div className="p-8 md:p-12 w-full">
169
+ <div className="flex flex-wrap items-end justify-between gap-4">
170
+ <div>
171
+ <h1 className="font-serif text-2xl text-ink">Forms</h1>
172
+ <p className="mt-2 text-sm text-ink/60">
173
+ Every form on the site in one place — fields, notifications, submissions and Google
174
+ Sheets sync. All forms inherit the shared defaults.
175
+ </p>
176
+ </div>
177
+ <div className="flex gap-2">
178
+ <Link
179
+ to={paths.submissions}
180
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
181
+ >
182
+ <Inbox className="size-4" />
183
+ Submissions
184
+ </Link>
185
+ <Link
186
+ to={paths.deliveries}
187
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
188
+ >
189
+ <Activity className="size-4" />
190
+ Delivery monitor
191
+ </Link>
192
+ <Link
193
+ to={paths.settings}
194
+ className="inline-flex items-center gap-2 border border-ink/15 bg-white px-4 py-2.5 text-xs uppercase tracking-[0.2em] text-ink hover:border-sea"
195
+ >
196
+ <Settings2 className="size-4" />
197
+ Shared settings
198
+ </Link>
199
+ </div>
200
+ </div>
201
+
202
+ <div className="mt-8 border border-ink/10 bg-white p-6">
203
+ <div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">New form</div>
204
+ <div className="mt-4 flex flex-wrap items-end gap-3">
205
+ <label className="flex-1 min-w-[220px]">
206
+ <span className="text-xs text-ink/55">Name</span>
207
+ <input
208
+ value={name}
209
+ onChange={(e) => setName(e.target.value)}
210
+ placeholder="Contact form"
211
+ className="mt-1 w-full border border-ink/20 px-3 py-2 text-sm"
212
+ />
213
+ </label>
214
+ <label className="min-w-[220px]">
215
+ <span className="text-xs text-ink/55">Type</span>
216
+ <select
217
+ value={type}
218
+ onChange={(e) => setType(e.target.value)}
219
+ className="mt-1 w-full border border-ink/20 bg-white px-3 py-2 text-sm"
220
+ >
221
+ {FORM_TYPES.map((t) => (
222
+ <option key={t.value} value={t.value}>
223
+ {t.label} — {t.description}
224
+ </option>
225
+ ))}
226
+ </select>
227
+ </label>
228
+ <button
229
+ onClick={onCreate}
230
+ disabled={busy || !name.trim()}
231
+ className="inline-flex items-center gap-2 bg-ink px-5 py-2.5 text-xs uppercase tracking-[0.2em] text-paper hover:bg-sea hover:text-ink disabled:opacity-50"
232
+ >
233
+ <Plus className="size-4" />
234
+ Create
235
+ </button>
236
+ </div>
237
+ {name.trim() ? (
238
+ <p className="mt-3 text-xs text-ink/50">Key: {slugify(name) || "—"}</p>
239
+ ) : null}
240
+ </div>
241
+
242
+ <div className="mt-8 flex gap-2">
243
+ {(["list", "bin"] as const).map((v) => (
244
+ <button
245
+ key={v}
246
+ onClick={() => setView(v)}
247
+ className={
248
+ view === v
249
+ ? "border border-ink bg-ink px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em] text-paper"
250
+ : "border border-ink/15 bg-white px-4 py-2 text-[0.65rem] uppercase tracking-[0.2em] text-ink/60 hover:border-sea"
251
+ }
252
+ >
253
+ {v === "list" ? "Forms" : `Bin${bin?.rows.length ? ` (${bin.rows.length})` : ""}`}
254
+ </button>
255
+ ))}
256
+ </div>
257
+
258
+ {view === "list" ? (
259
+ <div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
260
+ <table className="w-full text-sm">
261
+ <thead>
262
+ <tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
263
+ <th className="px-6 py-3">Form</th>
264
+ <th className="px-6 py-3">Key</th>
265
+ <th className="px-6 py-3">Type</th>
266
+ <th className="px-6 py-3">Fields</th>
267
+ <th className="px-6 py-3">Status</th>
268
+ <th className="px-6 py-3">Submissions</th>
269
+ <th className="px-6 py-3">Last</th>
270
+ <th className="px-6 py-3"></th>
271
+ </tr>
272
+ </thead>
273
+ <tbody>
274
+ {isLoading ? (
275
+ <tr>
276
+ <td className="px-6 py-6 text-ink/50" colSpan={8}>
277
+ Loading…
278
+ </td>
279
+ </tr>
280
+ ) : forms.length === 0 ? (
281
+ <tr>
282
+ <td className="px-6 py-6 text-ink/50" colSpan={8}>
283
+ No forms yet.
284
+ </td>
285
+ </tr>
286
+ ) : (
287
+ forms.map((f) => (
288
+ <tr key={f.id} className="border-b border-ink/5 last:border-0">
289
+ <td className="px-6 py-3">
290
+ <Link
291
+ to={paths.editor}
292
+ params={{ key: f.key }}
293
+ className="text-ink hover:text-sea"
294
+ >
295
+ {f.name}
296
+ </Link>
297
+ </td>
298
+ <td className="px-6 py-3 text-ink/60">{f.key}</td>
299
+ <td className="px-6 py-3 text-ink/60">{f.form_type}</td>
300
+ <td className="px-6 py-3 text-ink/60">
301
+ {f.fields.filter((x) => x.enabled).length}/
302
+ {f.fields.filter((x) => !x.removed).length}
303
+ </td>
304
+ <td className="px-6 py-3">
305
+ <span
306
+ className={
307
+ f.is_active
308
+ ? "text-[0.65rem] uppercase tracking-[0.2em] text-sea"
309
+ : "text-[0.65rem] uppercase tracking-[0.2em] text-ink/40"
310
+ }
311
+ >
312
+ {f.is_active ? "Active" : "Inactive"}
313
+ </span>
314
+ </td>
315
+ <td className="px-6 py-3 text-ink/60">
316
+ <Link to={paths.submissions} search={{ form: f.key }} className="hover:text-sea">
317
+ {f.submissions}
318
+ {f.newSubmissions > 0 ? (
319
+ <span className="ml-2 bg-sea px-1.5 py-0.5 text-[0.6rem] uppercase tracking-[0.18em] text-ink">
320
+ {f.newSubmissions} new
321
+ </span>
322
+ ) : null}
323
+ </Link>
324
+ </td>
325
+ <td className="px-6 py-3 text-ink/50 whitespace-nowrap">
326
+ {f.lastSubmissionAt
327
+ ? new Date(f.lastSubmissionAt).toLocaleDateString("sv-SE")
328
+ : "—"}
329
+ </td>
330
+ <td className="px-6 py-3 text-right whitespace-nowrap">
331
+ <button
332
+ onClick={() => onDuplicate(f)}
333
+ className="mr-3 text-ink/40 hover:text-sea"
334
+ title="Duplicate form"
335
+ >
336
+ <Copy className="size-4" />
337
+ </button>
338
+ <button
339
+ onClick={() => onDelete(f)}
340
+ className="text-ink/40 hover:text-red-500"
341
+ title="Move form to bin"
342
+ >
343
+ <Trash2 className="size-4" />
344
+ </button>
345
+ </td>
346
+ </tr>
347
+ ))
348
+ )}
349
+ </tbody>
350
+ </table>
351
+ </div>
352
+ ) : (
353
+ <div className="mt-8 border border-ink/10 bg-white overflow-x-auto">
354
+ <table className="w-full text-sm">
355
+ <thead>
356
+ <tr className="border-b border-ink/10 text-left text-[0.65rem] uppercase tracking-[0.2em] text-ink/50">
357
+ <th className="px-6 py-3">Form</th>
358
+ <th className="px-6 py-3">Key</th>
359
+ <th className="px-6 py-3">Type</th>
360
+ <th className="px-6 py-3">Deleted</th>
361
+ <th className="px-6 py-3">By</th>
362
+ <th className="px-6 py-3"></th>
363
+ </tr>
364
+ </thead>
365
+ <tbody>
366
+ {(bin?.rows ?? []).length === 0 ? (
367
+ <tr>
368
+ <td className="px-6 py-6 text-ink/50" colSpan={6}>
369
+ The bin is empty.
370
+ </td>
371
+ </tr>
372
+ ) : (
373
+ (bin?.rows ?? []).map((f) => (
374
+ <tr key={f.id} className="border-b border-ink/5 last:border-0">
375
+ <td className="px-6 py-3 text-ink">{f.name}</td>
376
+ <td className="px-6 py-3 text-ink/60">{f.key}</td>
377
+ <td className="px-6 py-3 text-ink/60">{f.form_type}</td>
378
+ <td className="px-6 py-3 text-ink/50 whitespace-nowrap">
379
+ {new Date(f.deleted_at).toLocaleString("sv-SE")}
380
+ </td>
381
+ <td className="px-6 py-3 text-ink/60">{f.deleted_by_email ?? "—"}</td>
382
+ <td className="px-6 py-3 text-right whitespace-nowrap">
383
+ <button
384
+ onClick={async () => {
385
+ try {
386
+ await restoreForm({ data: { id: f.id } });
387
+ await refresh();
388
+ toast.success("Form restored (inactive)");
389
+ } catch (e) {
390
+ toast.error(e instanceof Error ? e.message : "Could not restore");
391
+ }
392
+ }}
393
+ className="mr-3 inline-flex items-center gap-1 text-xs uppercase tracking-[0.18em] text-ink/60 hover:text-sea"
394
+ >
395
+ <RotateCcw className="size-4" />
396
+ Restore
397
+ </button>
398
+ {bin?.canPurge ? (
399
+ <button
400
+ onClick={async () => {
401
+ if (
402
+ !window.confirm(
403
+ `Permanently delete "${f.name}"? This cannot be undone. Submissions are kept in the inbox.`,
404
+ )
405
+ )
406
+ return;
407
+ try {
408
+ await purgeForm({ data: { id: f.id } });
409
+ await refresh();
410
+ toast.success("Form permanently deleted");
411
+ } catch (e) {
412
+ toast.error(e instanceof Error ? e.message : "Could not delete");
413
+ }
414
+ }}
415
+ className="inline-flex items-center gap-1 text-xs uppercase tracking-[0.18em] text-ink/50 hover:text-red-500"
416
+ >
417
+ <ShieldAlert className="size-4" />
418
+ Delete permanently
419
+ </button>
420
+ ) : (
421
+ <span className="text-[0.65rem] uppercase tracking-[0.18em] text-ink/35">
422
+ Superadmin only
423
+ </span>
424
+ )}
425
+ </td>
426
+ </tr>
427
+ ))
428
+ )}
429
+ </tbody>
430
+ </table>
431
+ </div>
432
+ )}
433
+ </div>
434
+ );
435
+ }
@@ -0,0 +1,223 @@
1
+ import { useState } from "react";
2
+ import { toast } from "sonner";
3
+ import { Plus, Trash2, Save } from "lucide-react";
4
+
5
+ /** One stored redirect row. */
6
+ export type RedirectRow = {
7
+ id: string;
8
+ from_path: string;
9
+ to_path: string;
10
+ status: number;
11
+ created_at?: string | null;
12
+ };
13
+
14
+ /** Host-provided redirect data access. */
15
+ export type RedirectsDeps = {
16
+ rows: RedirectRow[];
17
+ saveRedirect: (input: {
18
+ data: { id?: string; from_path: string; to_path: string; status: number };
19
+ }) => Promise<unknown>;
20
+ deleteRedirect: (input: { data: { id: string } }) => Promise<unknown>;
21
+ refresh: () => Promise<unknown> | void;
22
+ };
23
+
24
+ /** Host-agnostic redirect manager: add, edit inline and delete redirects. */
25
+ export function RedirectsView({ deps }: { deps: RedirectsDeps }) {
26
+ const { rows, saveRedirect, deleteRedirect, refresh } = deps;
27
+ const [from, setFrom] = useState("");
28
+ const [to, setTo] = useState("");
29
+ const [status, setStatus] = useState(301);
30
+ const [busy, setBusy] = useState(false);
31
+
32
+ const onAdd = async () => {
33
+ if (!from || !to) return;
34
+ setBusy(true);
35
+ try {
36
+ await saveRedirect({ data: { from_path: from, to_path: to, status } });
37
+ toast.success("Redirect saved");
38
+ setFrom("");
39
+ setTo("");
40
+ await refresh();
41
+ } catch (e) {
42
+ toast.error(e instanceof Error ? e.message : "Failed");
43
+ } finally {
44
+ setBusy(false);
45
+ }
46
+ };
47
+
48
+ const onUpdate = async (row: {
49
+ id: string;
50
+ from_path: string;
51
+ to_path: string;
52
+ status: number;
53
+ }) => {
54
+ try {
55
+ await saveRedirect({ data: row });
56
+ toast.success("Updated");
57
+ await refresh();
58
+ } catch (e) {
59
+ toast.error(e instanceof Error ? e.message : "Failed");
60
+ }
61
+ };
62
+
63
+ const onDelete = async (id: string) => {
64
+ if (!window.confirm("Delete this redirect?")) return;
65
+ try {
66
+ await deleteRedirect({ data: { id } });
67
+ toast.success("Deleted");
68
+ await refresh();
69
+ } catch (e) {
70
+ toast.error(e instanceof Error ? e.message : "Failed");
71
+ }
72
+ };
73
+
74
+ return (
75
+ <div className="p-8 md:p-12 w-full">
76
+ <h1 className="font-serif text-3xl md:text-4xl text-ink">Redirects</h1>
77
+ <p className="mt-3 max-w-2xl text-sm text-ink/60">
78
+ Old URLs are redirected to new ones. A 301 is created automatically whenever you change a
79
+ page slug in Page SEO.
80
+ </p>
81
+
82
+ <div className="mt-8 border border-ink/10 bg-white p-6">
83
+ <div className="grid gap-3 md:grid-cols-[1fr_1fr_120px_auto] md:items-end">
84
+ <label className="block">
85
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">
86
+ From path
87
+ </span>
88
+ <input
89
+ value={from}
90
+ onChange={(e) => setFrom(e.target.value)}
91
+ className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
92
+ placeholder="/old-page"
93
+ />
94
+ </label>
95
+ <label className="block">
96
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">To path</span>
97
+ <input
98
+ value={to}
99
+ onChange={(e) => setTo(e.target.value)}
100
+ className="w-full border border-ink/20 px-3 py-2 text-sm font-mono"
101
+ placeholder="/new-page"
102
+ />
103
+ </label>
104
+ <label className="block">
105
+ <span className="block text-xs uppercase tracking-wider text-ink/60 mb-1">Status</span>
106
+ <select
107
+ value={status}
108
+ onChange={(e) => setStatus(Number(e.target.value))}
109
+ className="w-full border border-ink/20 px-3 py-2 text-sm"
110
+ >
111
+ <option value={301}>301</option>
112
+ <option value={302}>302</option>
113
+ </select>
114
+ </label>
115
+ <button
116
+ onClick={onAdd}
117
+ disabled={busy}
118
+ className="inline-flex items-center gap-2 bg-ink px-4 py-2 text-sm text-white disabled:opacity-50"
119
+ >
120
+ <Plus className="size-4" /> Add
121
+ </button>
122
+ </div>
123
+ </div>
124
+
125
+ <div className="mt-8 border border-ink/10 bg-white">
126
+ <table className="w-full text-sm">
127
+ <thead className="bg-ink/[0.03] text-left text-[0.7rem] uppercase tracking-[0.18em] text-ink/60">
128
+ <tr>
129
+ <th className="px-4 py-3">From</th>
130
+ <th className="px-4 py-3">To</th>
131
+ <th className="px-4 py-3 w-28">Status</th>
132
+ <th className="px-4 py-3 w-44">Created</th>
133
+ <th className="px-4 py-3 w-40"></th>
134
+ </tr>
135
+ </thead>
136
+ <tbody>
137
+ {rows.map((r) => (
138
+ <Row key={r.id} row={r} onSave={onUpdate} onDelete={onDelete} />
139
+ ))}
140
+ {rows.length === 0 && (
141
+ <tr>
142
+ <td colSpan={5} className="px-4 py-8 text-center text-ink/50">
143
+ No redirects yet.
144
+ </td>
145
+ </tr>
146
+ )}
147
+ </tbody>
148
+ </table>
149
+ </div>
150
+ </div>
151
+ );
152
+ }
153
+
154
+ function Row({
155
+ row,
156
+ onSave,
157
+ onDelete,
158
+ }: {
159
+ row: RedirectRow;
160
+ onSave: (r: { id: string; from_path: string; to_path: string; status: number }) => void;
161
+ onDelete: (id: string) => void;
162
+ }) {
163
+ const [from, setFrom] = useState(row.from_path);
164
+ const [to, setTo] = useState(row.to_path);
165
+ const [status, setStatus] = useState(row.status);
166
+ const dirty = from !== row.from_path || to !== row.to_path || status !== row.status;
167
+
168
+ return (
169
+ <tr className="border-t border-ink/10">
170
+ <td className="px-4 py-2">
171
+ <input
172
+ value={from}
173
+ onChange={(e) => setFrom(e.target.value)}
174
+ className="w-full border border-transparent hover:border-ink/20 px-2 py-1 font-mono text-sm"
175
+ />
176
+ </td>
177
+ <td className="px-4 py-2">
178
+ <input
179
+ value={to}
180
+ onChange={(e) => setTo(e.target.value)}
181
+ className="w-full border border-transparent hover:border-ink/20 px-2 py-1 font-mono text-sm"
182
+ />
183
+ </td>
184
+ <td className="px-4 py-2">
185
+ <select
186
+ value={status}
187
+ onChange={(e) => setStatus(Number(e.target.value))}
188
+ className="border border-ink/20 px-2 py-1 text-sm"
189
+ >
190
+ <option value={301}>301</option>
191
+ <option value={302}>302</option>
192
+ </select>
193
+ </td>
194
+ <td className="px-4 py-2 whitespace-nowrap text-xs text-ink/60">
195
+ {row.created_at
196
+ ? new Date(row.created_at).toLocaleString("sv-SE", {
197
+ year: "numeric",
198
+ month: "2-digit",
199
+ day: "2-digit",
200
+ hour: "2-digit",
201
+ minute: "2-digit",
202
+ })
203
+ : "—"}
204
+ </td>
205
+ <td className="px-4 py-2 text-right">
206
+ {dirty && (
207
+ <button
208
+ onClick={() => onSave({ id: row.id, from_path: from, to_path: to, status })}
209
+ className="inline-flex items-center gap-1 text-sea hover:underline mr-3"
210
+ >
211
+ <Save className="size-3.5" /> Save
212
+ </button>
213
+ )}
214
+ <button
215
+ onClick={() => onDelete(row.id)}
216
+ className="inline-flex items-center gap-1 text-red-600 hover:underline"
217
+ >
218
+ <Trash2 className="size-3.5" /> Delete
219
+ </button>
220
+ </td>
221
+ </tr>
222
+ );
223
+ }