@bygghammar/cms-platform 0.3.1 → 0.5.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/package.json +51 -9
- package/src/admin/format-log.ts +217 -0
- package/src/backoffice/forms/ActivityLog.tsx +153 -0
- package/src/backoffice/forms/ConditionBuilder.tsx +170 -0
- package/src/backoffice/forms/RuleSimulator.tsx +163 -0
- package/src/backoffice/forms/SmartTags.tsx +181 -0
- package/src/backup/datasets.ts +249 -0
- package/src/backup/export-core.ts +350 -0
- package/src/backup/media-export.ts +24 -0
- package/src/backup/restore-core.ts +465 -0
- package/src/cms/shared-draft-store.ts +66 -0
- package/src/cms/version-diff.ts +89 -0
- package/src/forms/conditions.ts +350 -0
- package/src/forms/field-sets.ts +590 -0
- package/src/forms/form-audit.ts +202 -0
- package/src/forms/rich-text.tsx +76 -0
- package/src/http/cache-policy.ts +110 -0
- package/src/http/compression.ts +122 -0
- package/src/http/response-headers.ts +126 -0
- package/src/i18n/json.ts +7 -0
- package/src/i18n/resolve-locale.ts +14 -0
- package/src/i18n/translation-meta.ts +207 -0
- package/src/media/filename.ts +44 -0
- package/src/media/focus.ts +101 -0
- package/src/media/internal-paths.ts +30 -0
- package/src/media/media-meta.ts +50 -0
- package/src/media/optimize.ts +209 -0
- package/src/media/review-image.ts +107 -0
- package/src/media/srcset.ts +78 -0
- package/src/media/storage.ts +139 -0
- package/src/media/urls.ts +55 -0
- package/src/media/usage-types.ts +24 -0
- package/src/platform/index.ts +90 -0
- package/src/trips/index.ts +27 -0
package/package.json
CHANGED
|
@@ -1,13 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bygghammar/cms-platform",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Shared CMS platform: blocks, block types, i18n core, UI primitives and design-token contract used by every Bygghammar/Voyara site.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"sideEffects": false,
|
|
7
7
|
"license": "UNLICENSED",
|
|
8
8
|
"publishConfig": {
|
|
9
|
-
"access": "public"
|
|
10
|
-
"registry": "https://registry.npmjs.org/"
|
|
9
|
+
"access": "public"
|
|
11
10
|
},
|
|
12
11
|
"files": [
|
|
13
12
|
"src",
|
|
@@ -18,9 +17,6 @@
|
|
|
18
17
|
"url": "git+https://github.com/byggis79/devsba.git",
|
|
19
18
|
"directory": "packages/cms-platform"
|
|
20
19
|
},
|
|
21
|
-
"publishConfig": {
|
|
22
|
-
"access": "public"
|
|
23
|
-
},
|
|
24
20
|
"exports": {
|
|
25
21
|
".": "./src/index.ts",
|
|
26
22
|
"./i18n": "./src/i18n/index.ts",
|
|
@@ -44,7 +40,39 @@
|
|
|
44
40
|
"./blocks/registry": "./src/blocks/registry.tsx",
|
|
45
41
|
"./trips": "./src/trips/index.ts",
|
|
46
42
|
"./trips/TripSectionEditor": "./src/trips/TripSectionEditor.tsx",
|
|
47
|
-
"./navigation": "./src/navigation/index.ts"
|
|
43
|
+
"./navigation": "./src/navigation/index.ts",
|
|
44
|
+
"./forms/conditions": "./src/forms/conditions.ts",
|
|
45
|
+
"./forms/field-sets": "./src/forms/field-sets.ts",
|
|
46
|
+
"./forms/form-audit": "./src/forms/form-audit.ts",
|
|
47
|
+
"./forms/rich-text": "./src/forms/rich-text.tsx",
|
|
48
|
+
"./media/urls": "./src/media/urls.ts",
|
|
49
|
+
"./media/filename": "./src/media/filename.ts",
|
|
50
|
+
"./media/focus": "./src/media/focus.ts",
|
|
51
|
+
"./media/internal-paths": "./src/media/internal-paths.ts",
|
|
52
|
+
"./media/optimize": "./src/media/optimize.ts",
|
|
53
|
+
"./media/srcset": "./src/media/srcset.ts",
|
|
54
|
+
"./media/media-meta": "./src/media/media-meta.ts",
|
|
55
|
+
"./media/review-image": "./src/media/review-image.ts",
|
|
56
|
+
"./media/storage": "./src/media/storage.ts",
|
|
57
|
+
"./media/usage-types": "./src/media/usage-types.ts",
|
|
58
|
+
"./cms/version-diff": "./src/cms/version-diff.ts",
|
|
59
|
+
"./cms/shared-draft-store": "./src/cms/shared-draft-store.ts",
|
|
60
|
+
"./http/cache-policy": "./src/http/cache-policy.ts",
|
|
61
|
+
"./http/compression": "./src/http/compression.ts",
|
|
62
|
+
"./http/response-headers": "./src/http/response-headers.ts",
|
|
63
|
+
"./backup/datasets": "./src/backup/datasets.ts",
|
|
64
|
+
"./backup/export-core": "./src/backup/export-core.ts",
|
|
65
|
+
"./backup/restore-core": "./src/backup/restore-core.ts",
|
|
66
|
+
"./backup/media-export": "./src/backup/media-export.ts",
|
|
67
|
+
"./i18n/json": "./src/i18n/json.ts",
|
|
68
|
+
"./i18n/resolve-locale": "./src/i18n/resolve-locale.ts",
|
|
69
|
+
"./i18n/translation-meta": "./src/i18n/translation-meta.ts",
|
|
70
|
+
"./platform": "./src/platform/index.ts",
|
|
71
|
+
"./admin/format-log": "./src/admin/format-log.ts",
|
|
72
|
+
"./backoffice/forms/ConditionBuilder": "./src/backoffice/forms/ConditionBuilder.tsx",
|
|
73
|
+
"./backoffice/forms/RuleSimulator": "./src/backoffice/forms/RuleSimulator.tsx",
|
|
74
|
+
"./backoffice/forms/SmartTags": "./src/backoffice/forms/SmartTags.tsx",
|
|
75
|
+
"./backoffice/forms/ActivityLog": "./src/backoffice/forms/ActivityLog.tsx"
|
|
48
76
|
},
|
|
49
77
|
"dependencies": {
|
|
50
78
|
"fflate": "^0.8.3"
|
|
@@ -54,6 +82,20 @@
|
|
|
54
82
|
"lucide-react": ">=0.5.0",
|
|
55
83
|
"react": ">=19",
|
|
56
84
|
"react-dom": ">=19",
|
|
57
|
-
"sonner": ">=2"
|
|
85
|
+
"sonner": ">=2",
|
|
86
|
+
"@supabase/supabase-js": ">=2",
|
|
87
|
+
"@tanstack/react-start": ">=1",
|
|
88
|
+
"@tanstack/react-router": ">=1"
|
|
89
|
+
},
|
|
90
|
+
"peerDependenciesMeta": {
|
|
91
|
+
"@supabase/supabase-js": {
|
|
92
|
+
"optional": true
|
|
93
|
+
},
|
|
94
|
+
"@tanstack/react-start": {
|
|
95
|
+
"optional": true
|
|
96
|
+
},
|
|
97
|
+
"@tanstack/react-router": {
|
|
98
|
+
"optional": true
|
|
99
|
+
}
|
|
58
100
|
}
|
|
59
|
-
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Turn machine action codes and JSON meta into human-readable strings for the
|
|
2
|
+
// backoffice Audit and Email log views.
|
|
3
|
+
|
|
4
|
+
const ACTION_LABELS: Record<string, string> = {
|
|
5
|
+
"user.create": "Created user",
|
|
6
|
+
"user.delete": "Deleted user",
|
|
7
|
+
"user.password_reset": "Reset password",
|
|
8
|
+
"user.invite": "Invited user",
|
|
9
|
+
"role.grant": "Granted role",
|
|
10
|
+
"role.revoke": "Revoked role",
|
|
11
|
+
"role.update": "Updated role",
|
|
12
|
+
"email.settings.update": "Updated email settings",
|
|
13
|
+
"email.settings.test": "Sent test email",
|
|
14
|
+
"content.section.save": "Saved section",
|
|
15
|
+
"content.section.delete": "Deleted section",
|
|
16
|
+
"page.create": "Created page",
|
|
17
|
+
"page.update": "Updated page",
|
|
18
|
+
"page.delete": "Deleted page",
|
|
19
|
+
"page.publish": "Published page",
|
|
20
|
+
"page.unpublish": "Unpublished page",
|
|
21
|
+
"page.version.create": "Saved page version",
|
|
22
|
+
"page.version.restore": "Restored page version",
|
|
23
|
+
"template.create": "Created block template",
|
|
24
|
+
"template.update": "Updated block template",
|
|
25
|
+
"template.delete": "Deleted block template",
|
|
26
|
+
"global.section.save": "Saved global block",
|
|
27
|
+
"global.section.delete": "Deleted global block",
|
|
28
|
+
"domain.create": "Added locale domain",
|
|
29
|
+
"domain.update": "Updated locale domain",
|
|
30
|
+
"domain.delete": "Removed locale domain",
|
|
31
|
+
"seo.update": "Updated SEO",
|
|
32
|
+
"media.upload": "Uploaded media",
|
|
33
|
+
"media.delete": "Deleted media",
|
|
34
|
+
"media.import": "Imported external media",
|
|
35
|
+
"translate.run": "Ran auto-translation",
|
|
36
|
+
"form.create": "Created form",
|
|
37
|
+
"form.update": "Updated form",
|
|
38
|
+
"form.duplicate": "Duplicated form",
|
|
39
|
+
"form.soft_delete": "Moved form to bin",
|
|
40
|
+
"form.restore": "Restored form",
|
|
41
|
+
"form.purge": "Permanently deleted form",
|
|
42
|
+
"form.defaults.update": "Updated shared form defaults",
|
|
43
|
+
"auth.login": "Signed in",
|
|
44
|
+
"auth.logout": "Signed out",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const ENTITY_LABELS: Record<string, string> = {
|
|
48
|
+
user: "User",
|
|
49
|
+
user_role: "Role",
|
|
50
|
+
content_section: "Section",
|
|
51
|
+
page: "Page",
|
|
52
|
+
page_version: "Page version",
|
|
53
|
+
page_publication: "Publication",
|
|
54
|
+
block_template: "Template",
|
|
55
|
+
global_section: "Global block",
|
|
56
|
+
locale_domain: "Domain",
|
|
57
|
+
page_seo: "SEO",
|
|
58
|
+
email_settings: "Email settings",
|
|
59
|
+
media: "Media",
|
|
60
|
+
redirect: "Redirect",
|
|
61
|
+
forms: "Form",
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
export function humanizeAction(action: string | null | undefined): string {
|
|
65
|
+
if (!action) return "—";
|
|
66
|
+
if (ACTION_LABELS[action]) return ACTION_LABELS[action];
|
|
67
|
+
// Fallback: turn "some.snake_case.action" into "Some snake case action".
|
|
68
|
+
return action
|
|
69
|
+
.replace(/[._-]+/g, " ")
|
|
70
|
+
.replace(/\s+/g, " ")
|
|
71
|
+
.trim()
|
|
72
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function humanizeEntity(entity: string | null | undefined): string {
|
|
76
|
+
if (!entity) return "—";
|
|
77
|
+
return (
|
|
78
|
+
ENTITY_LABELS[entity] ?? entity.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase())
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function shortId(id: string): string {
|
|
83
|
+
if (id.length <= 12) return id;
|
|
84
|
+
// UUID-style: keep first + last block for context
|
|
85
|
+
if (/^[0-9a-f]{8}-/i.test(id)) return id.slice(0, 8) + "…";
|
|
86
|
+
return id.slice(0, 10) + "…";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function formatEntityRef(
|
|
90
|
+
entity: string | null | undefined,
|
|
91
|
+
entityId: string | null | undefined,
|
|
92
|
+
): string {
|
|
93
|
+
const label = humanizeEntity(entity);
|
|
94
|
+
if (!entityId) return label;
|
|
95
|
+
return `${label} · ${shortId(entityId)}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Convert an arbitrary `meta` payload into a short prose summary suitable for
|
|
100
|
+
* a log row. Falls back to compact key: value pairs, and finally to nothing.
|
|
101
|
+
*/
|
|
102
|
+
export function summarizeMeta(meta: unknown): string {
|
|
103
|
+
if (!meta || typeof meta !== "object") return "";
|
|
104
|
+
const m = meta as Record<string, unknown>;
|
|
105
|
+
if (!Object.keys(m).length) return "";
|
|
106
|
+
|
|
107
|
+
const parts: string[] = [];
|
|
108
|
+
const push = (label: string, value: unknown) => {
|
|
109
|
+
if (value == null || value === "") return;
|
|
110
|
+
if (typeof value === "object") {
|
|
111
|
+
try {
|
|
112
|
+
parts.push(`${label}: ${JSON.stringify(value)}`);
|
|
113
|
+
} catch {
|
|
114
|
+
/* noop */
|
|
115
|
+
}
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
parts.push(`${label}: ${String(value)}`);
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Common well-known fields, in a nice order
|
|
122
|
+
const known = [
|
|
123
|
+
["summary", "Summary"],
|
|
124
|
+
["email", "Email"],
|
|
125
|
+
["role", "Role"],
|
|
126
|
+
["name", "Name"],
|
|
127
|
+
["key", "Key"],
|
|
128
|
+
["slug", "Slug"],
|
|
129
|
+
["locale", "Locale"],
|
|
130
|
+
["from", "From"],
|
|
131
|
+
["to", "To"],
|
|
132
|
+
["reason", "Reason"],
|
|
133
|
+
["status", "Status"],
|
|
134
|
+
["version", "Version"],
|
|
135
|
+
["count", "Count"],
|
|
136
|
+
["path", "Path"],
|
|
137
|
+
["domain", "Domain"],
|
|
138
|
+
["title", "Title"],
|
|
139
|
+
] as const;
|
|
140
|
+
const used = new Set<string>();
|
|
141
|
+
for (const [k, label] of known) {
|
|
142
|
+
if (k in m) {
|
|
143
|
+
push(label, m[k]);
|
|
144
|
+
used.add(k);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
// Any remaining keys, up to 4 total extra fields
|
|
148
|
+
const extras = Object.keys(m)
|
|
149
|
+
.filter((k) => !used.has(k))
|
|
150
|
+
.slice(0, 4);
|
|
151
|
+
for (const k of extras) {
|
|
152
|
+
push(
|
|
153
|
+
k.replace(/[._-]+/g, " ").replace(/^./, (c) => c.toUpperCase()),
|
|
154
|
+
m[k],
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return parts.join(" · ");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function formatRelativeTime(iso: string): string {
|
|
161
|
+
const then = new Date(iso).getTime();
|
|
162
|
+
if (Number.isNaN(then)) return "—";
|
|
163
|
+
const diff = Date.now() - then;
|
|
164
|
+
const s = Math.round(diff / 1000);
|
|
165
|
+
if (s < 45) return "just now";
|
|
166
|
+
const m = Math.round(s / 60);
|
|
167
|
+
if (m < 60) return `${m} min ago`;
|
|
168
|
+
const h = Math.round(m / 60);
|
|
169
|
+
if (h < 24) return `${h} h ago`;
|
|
170
|
+
const d = Math.round(h / 24);
|
|
171
|
+
if (d < 30) return `${d} d ago`;
|
|
172
|
+
const mo = Math.round(d / 30);
|
|
173
|
+
if (mo < 12) return `${mo} mo ago`;
|
|
174
|
+
const y = Math.round(mo / 12);
|
|
175
|
+
return `${y} y ago`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function formatAbsoluteTime(iso: string): string {
|
|
179
|
+
const d = new Date(iso);
|
|
180
|
+
if (Number.isNaN(d.getTime())) return "—";
|
|
181
|
+
return d.toLocaleString(undefined, {
|
|
182
|
+
year: "numeric",
|
|
183
|
+
month: "short",
|
|
184
|
+
day: "2-digit",
|
|
185
|
+
hour: "2-digit",
|
|
186
|
+
minute: "2-digit",
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function emailStatusTone(status: string | null | undefined): {
|
|
191
|
+
label: string;
|
|
192
|
+
className: string;
|
|
193
|
+
} {
|
|
194
|
+
const s = (status ?? "").toLowerCase();
|
|
195
|
+
const map: Record<string, { label: string; className: string }> = {
|
|
196
|
+
sent: { label: "Sent", className: "border-green-600 text-green-700 bg-green-50" },
|
|
197
|
+
delivered: { label: "Delivered", className: "border-green-600 text-green-700 bg-green-50" },
|
|
198
|
+
queued: { label: "Queued", className: "border-ink/30 text-ink/70 bg-ink/5" },
|
|
199
|
+
pending: { label: "Pending", className: "border-ink/30 text-ink/70 bg-ink/5" },
|
|
200
|
+
failed: { label: "Failed", className: "border-red-600 text-red-700 bg-red-50" },
|
|
201
|
+
rejected: { label: "Rejected", className: "border-red-600 text-red-700 bg-red-50" },
|
|
202
|
+
bounced: { label: "Bounced", className: "border-amber-600 text-amber-700 bg-amber-50" },
|
|
203
|
+
complained: { label: "Complained", className: "border-amber-600 text-amber-700 bg-amber-50" },
|
|
204
|
+
suppressed: { label: "Suppressed", className: "border-amber-600 text-amber-700 bg-amber-50" },
|
|
205
|
+
unsubscribed: { label: "Unsubscribed", className: "border-ink/30 text-ink/70 bg-ink/5" },
|
|
206
|
+
rate_limited: {
|
|
207
|
+
label: "Rate limited",
|
|
208
|
+
className: "border-amber-600 text-amber-700 bg-amber-50",
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
return (
|
|
212
|
+
map[s] ?? {
|
|
213
|
+
label: status ?? "Unknown",
|
|
214
|
+
className: "border-ink/30 text-ink/60 bg-white",
|
|
215
|
+
}
|
|
216
|
+
);
|
|
217
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { useQuery } from "@tanstack/react-query";
|
|
3
|
+
/** Shape of one audit row the host site returns for form activity. */
|
|
4
|
+
export type FormActivityRow = {
|
|
5
|
+
id: string;
|
|
6
|
+
action: string;
|
|
7
|
+
actor_email: string | null;
|
|
8
|
+
actor_id: string | null;
|
|
9
|
+
entity_id: string | null;
|
|
10
|
+
meta: Record<string, unknown> | null;
|
|
11
|
+
created_at: string;
|
|
12
|
+
};
|
|
13
|
+
import type { FormAuditChange } from "../../forms/form-audit";
|
|
14
|
+
import { formatAbsoluteTime, formatRelativeTime } from "../../admin/format-log";
|
|
15
|
+
|
|
16
|
+
const ACTION_LABELS: Record<string, string> = {
|
|
17
|
+
"form.create": "Created form",
|
|
18
|
+
"form.update": "Updated form",
|
|
19
|
+
"form.duplicate": "Duplicated form",
|
|
20
|
+
"form.soft_delete": "Moved form to bin",
|
|
21
|
+
"form.restore": "Restored form",
|
|
22
|
+
"form.purge": "Permanently deleted form",
|
|
23
|
+
"form.defaults.update": "Updated shared form defaults",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
function label(action: string) {
|
|
27
|
+
return ACTION_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Who changed what and when, for one form or for the whole Forms module. */
|
|
31
|
+
export function ActivityLog({
|
|
32
|
+
formKey,
|
|
33
|
+
fetchActivity,
|
|
34
|
+
}: {
|
|
35
|
+
formKey?: string;
|
|
36
|
+
/** Host-provided reader for form audit rows. */
|
|
37
|
+
fetchActivity: (input: {
|
|
38
|
+
data: { formKey?: string; limit: number };
|
|
39
|
+
}) => Promise<FormActivityRow[]>;
|
|
40
|
+
}) {
|
|
41
|
+
const q = useQuery({
|
|
42
|
+
queryKey: ["form-activity", formKey ?? "all"],
|
|
43
|
+
queryFn: () => fetchActivity({ data: { ...(formKey ? { formKey } : {}), limit: 200 } }),
|
|
44
|
+
});
|
|
45
|
+
const [search, setSearch] = useState("");
|
|
46
|
+
const [open, setOpen] = useState<string | null>(null);
|
|
47
|
+
|
|
48
|
+
const rows = (q.data ?? []) as FormActivityRow[];
|
|
49
|
+
const filtered = useMemo(() => {
|
|
50
|
+
const s = search.trim().toLowerCase();
|
|
51
|
+
if (!s) return rows;
|
|
52
|
+
return rows.filter((r) =>
|
|
53
|
+
[r.actor_email, label(r.action), JSON.stringify(r.meta ?? {})]
|
|
54
|
+
.filter(Boolean)
|
|
55
|
+
.join(" ")
|
|
56
|
+
.toLowerCase()
|
|
57
|
+
.includes(s),
|
|
58
|
+
);
|
|
59
|
+
}, [rows, search]);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="border border-ink/10 bg-white">
|
|
63
|
+
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-ink/10 p-4">
|
|
64
|
+
<div>
|
|
65
|
+
<h3 className="font-serif text-base">Activity log</h3>
|
|
66
|
+
<p className="text-xs text-ink/60">
|
|
67
|
+
Every change to this form and its fields — who, what and when.
|
|
68
|
+
</p>
|
|
69
|
+
</div>
|
|
70
|
+
<input
|
|
71
|
+
value={search}
|
|
72
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
73
|
+
placeholder="Search user, field, change…"
|
|
74
|
+
className="w-64 border border-ink/15 px-3 py-2 text-sm focus:border-sea focus:outline-none"
|
|
75
|
+
/>
|
|
76
|
+
</div>
|
|
77
|
+
|
|
78
|
+
{q.isLoading ? (
|
|
79
|
+
<p className="p-6 text-sm text-ink/50">Loading activity…</p>
|
|
80
|
+
) : filtered.length === 0 ? (
|
|
81
|
+
<p className="p-6 text-sm text-ink/50">
|
|
82
|
+
{rows.length === 0 ? "No changes recorded yet." : "No entries match your search."}
|
|
83
|
+
</p>
|
|
84
|
+
) : (
|
|
85
|
+
<ul className="divide-y divide-ink/10">
|
|
86
|
+
{filtered.map((r) => {
|
|
87
|
+
const meta = (r.meta ?? {}) as {
|
|
88
|
+
summary?: string;
|
|
89
|
+
name?: string;
|
|
90
|
+
key?: string;
|
|
91
|
+
changes?: FormAuditChange[];
|
|
92
|
+
};
|
|
93
|
+
const changes = Array.isArray(meta.changes) ? meta.changes : [];
|
|
94
|
+
const isOpen = open === r.id;
|
|
95
|
+
return (
|
|
96
|
+
<li key={r.id}>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
onClick={() => setOpen(isOpen ? null : r.id)}
|
|
100
|
+
className="flex w-full items-start gap-4 p-4 text-left hover:bg-ink/[0.02]"
|
|
101
|
+
>
|
|
102
|
+
<div className="w-40 shrink-0">
|
|
103
|
+
<div className="text-sm text-ink">{formatRelativeTime(r.created_at)}</div>
|
|
104
|
+
<div className="text-[0.65rem] text-ink/50">
|
|
105
|
+
{formatAbsoluteTime(r.created_at)}
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
<div className="w-56 shrink-0 text-sm text-ink/80 break-all">
|
|
109
|
+
{r.actor_email ?? "System"}
|
|
110
|
+
</div>
|
|
111
|
+
<div className="min-w-0 flex-1">
|
|
112
|
+
<div className="text-sm text-ink">{label(r.action)}</div>
|
|
113
|
+
<div className="mt-1 text-xs text-ink/60 line-clamp-2">
|
|
114
|
+
{meta.summary ??
|
|
115
|
+
(changes.length ? `${changes.length} change(s)` : (meta.name ?? ""))}
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
<span className="pt-1 text-xs text-ink/40">{isOpen ? "▾" : "▸"}</span>
|
|
119
|
+
</button>
|
|
120
|
+
{isOpen ? (
|
|
121
|
+
<div className="border-t border-ink/10 bg-ink/[0.02] p-4">
|
|
122
|
+
{changes.length === 0 ? (
|
|
123
|
+
<p className="text-xs text-ink/60">No field-level detail recorded.</p>
|
|
124
|
+
) : (
|
|
125
|
+
<table className="w-full text-xs">
|
|
126
|
+
<thead className="text-[0.6rem] uppercase tracking-[0.2em] text-ink/50">
|
|
127
|
+
<tr>
|
|
128
|
+
<th className="p-2 text-left">What</th>
|
|
129
|
+
<th className="p-2 text-left">Before</th>
|
|
130
|
+
<th className="p-2 text-left">After</th>
|
|
131
|
+
</tr>
|
|
132
|
+
</thead>
|
|
133
|
+
<tbody>
|
|
134
|
+
{changes.map((c, i) => (
|
|
135
|
+
<tr key={i} className="border-t border-ink/10 align-top">
|
|
136
|
+
<td className="p-2 text-ink/80">{c.label}</td>
|
|
137
|
+
<td className="p-2 text-ink/60 break-all">{c.from ?? "—"}</td>
|
|
138
|
+
<td className="p-2 text-ink break-all">{c.to ?? "—"}</td>
|
|
139
|
+
</tr>
|
|
140
|
+
))}
|
|
141
|
+
</tbody>
|
|
142
|
+
</table>
|
|
143
|
+
)}
|
|
144
|
+
</div>
|
|
145
|
+
) : null}
|
|
146
|
+
</li>
|
|
147
|
+
);
|
|
148
|
+
})}
|
|
149
|
+
</ul>
|
|
150
|
+
)}
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
import { Plus, Trash2 } from "lucide-react";
|
|
3
|
+
import { CONDITION_OPERATORS, isUnaryOperator } from "../../forms/conditions";
|
|
4
|
+
import type { ConditionRule, FieldCondition, FormFieldDef } from "../../forms/field-sets";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Editor for one show/require rule: "all|any of these conditions match".
|
|
8
|
+
* Conditions can only reference other fields, never the field itself.
|
|
9
|
+
*/
|
|
10
|
+
export function ConditionBuilder({
|
|
11
|
+
title,
|
|
12
|
+
description,
|
|
13
|
+
rule,
|
|
14
|
+
fields,
|
|
15
|
+
selfKey,
|
|
16
|
+
onChange,
|
|
17
|
+
}: {
|
|
18
|
+
title: string;
|
|
19
|
+
description: string;
|
|
20
|
+
rule: ConditionRule | undefined;
|
|
21
|
+
fields: FormFieldDef[];
|
|
22
|
+
selfKey?: string;
|
|
23
|
+
onChange: (rule: ConditionRule | undefined) => void;
|
|
24
|
+
}) {
|
|
25
|
+
const conditions = rule?.conditions ?? [];
|
|
26
|
+
const mode = rule?.mode ?? "all";
|
|
27
|
+
const candidates = fields.filter((f) => f.key !== selfKey);
|
|
28
|
+
|
|
29
|
+
const set = (next: FieldCondition[], nextMode: "all" | "any" = mode) =>
|
|
30
|
+
onChange(next.length ? { mode: nextMode, conditions: next } : undefined);
|
|
31
|
+
|
|
32
|
+
const patch = (i: number, p: Partial<FieldCondition>) =>
|
|
33
|
+
set(conditions.map((c, idx) => (idx === i ? { ...c, ...p } : c)));
|
|
34
|
+
|
|
35
|
+
const optionsFor = (key: string) => fields.find((f) => f.key === key)?.options ?? [];
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className="space-y-2 border border-ink/10 bg-white p-3">
|
|
39
|
+
<div className="flex items-center justify-between gap-3">
|
|
40
|
+
<div>
|
|
41
|
+
<div className="text-[0.65rem] uppercase tracking-[0.2em] text-ink/55">{title}</div>
|
|
42
|
+
<p className="mt-0.5 text-xs text-ink/45">{description}</p>
|
|
43
|
+
</div>
|
|
44
|
+
{conditions.length > 1 ? (
|
|
45
|
+
<select
|
|
46
|
+
value={mode}
|
|
47
|
+
onChange={(e) => set(conditions, e.target.value as "all" | "any")}
|
|
48
|
+
className="border border-ink/15 px-2 py-1 text-xs"
|
|
49
|
+
>
|
|
50
|
+
<option value="all">Match all</option>
|
|
51
|
+
<option value="any">Match any</option>
|
|
52
|
+
</select>
|
|
53
|
+
) : null}
|
|
54
|
+
</div>
|
|
55
|
+
|
|
56
|
+
{conditions.length === 0 ? (
|
|
57
|
+
<p className="text-xs text-ink/40">No conditions — always applies.</p>
|
|
58
|
+
) : null}
|
|
59
|
+
|
|
60
|
+
{conditions.map((c, i) => {
|
|
61
|
+
const opts = optionsFor(c.field);
|
|
62
|
+
return (
|
|
63
|
+
<div key={i} className="flex flex-wrap items-center gap-2">
|
|
64
|
+
<select
|
|
65
|
+
value={c.field}
|
|
66
|
+
onChange={(e) => patch(i, { field: e.target.value })}
|
|
67
|
+
className="min-w-[130px] flex-1 border border-ink/15 px-2 py-1 text-xs"
|
|
68
|
+
>
|
|
69
|
+
<option value="">Choose a field…</option>
|
|
70
|
+
{candidates.map((f) => (
|
|
71
|
+
<option key={f.key} value={f.key}>
|
|
72
|
+
{f.label.sv || f.label.en || f.key}
|
|
73
|
+
</option>
|
|
74
|
+
))}
|
|
75
|
+
</select>
|
|
76
|
+
<select
|
|
77
|
+
value={c.op}
|
|
78
|
+
onChange={(e) => patch(i, { op: e.target.value as FieldCondition["op"] })}
|
|
79
|
+
className="min-w-[120px] border border-ink/15 px-2 py-1 text-xs"
|
|
80
|
+
>
|
|
81
|
+
{CONDITION_OPERATORS.map((o) => (
|
|
82
|
+
<option key={o.value} value={o.value}>
|
|
83
|
+
{o.label}
|
|
84
|
+
</option>
|
|
85
|
+
))}
|
|
86
|
+
</select>
|
|
87
|
+
{isUnaryOperator(c.op) ? null : opts.length ? (
|
|
88
|
+
<select
|
|
89
|
+
value={c.value ?? ""}
|
|
90
|
+
onChange={(e) => patch(i, { value: e.target.value })}
|
|
91
|
+
className="min-w-[120px] flex-1 border border-ink/15 px-2 py-1 text-xs"
|
|
92
|
+
>
|
|
93
|
+
<option value="">—</option>
|
|
94
|
+
{opts.map((o) => (
|
|
95
|
+
<option key={o.value} value={o.value}>
|
|
96
|
+
{o.label?.sv || o.label?.en || o.value}
|
|
97
|
+
</option>
|
|
98
|
+
))}
|
|
99
|
+
</select>
|
|
100
|
+
) : (
|
|
101
|
+
<input
|
|
102
|
+
value={c.value ?? ""}
|
|
103
|
+
onChange={(e) => patch(i, { value: e.target.value })}
|
|
104
|
+
placeholder="value"
|
|
105
|
+
className="min-w-[120px] flex-1 border border-ink/15 px-2 py-1 text-xs"
|
|
106
|
+
/>
|
|
107
|
+
)}
|
|
108
|
+
<button
|
|
109
|
+
type="button"
|
|
110
|
+
onClick={() => set(conditions.filter((_, idx) => idx !== i))}
|
|
111
|
+
className="text-ink/35 hover:text-red-500"
|
|
112
|
+
title="Remove condition"
|
|
113
|
+
>
|
|
114
|
+
<Trash2 className="size-3.5" />
|
|
115
|
+
</button>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
})}
|
|
119
|
+
|
|
120
|
+
<button
|
|
121
|
+
type="button"
|
|
122
|
+
disabled={candidates.length === 0}
|
|
123
|
+
onClick={() =>
|
|
124
|
+
set([...conditions, { field: candidates[0]?.key ?? "", op: "equals", value: "" }])
|
|
125
|
+
}
|
|
126
|
+
className="inline-flex items-center gap-1.5 border border-ink/15 px-3 py-1.5 text-[0.65rem] uppercase tracking-[0.2em] hover:border-sea disabled:opacity-40"
|
|
127
|
+
>
|
|
128
|
+
<Plus className="size-3.5" />
|
|
129
|
+
Add condition
|
|
130
|
+
</button>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Compact summary chip used in collapsed field rows. */
|
|
136
|
+
export function RuleSummary({
|
|
137
|
+
rule,
|
|
138
|
+
fields,
|
|
139
|
+
prefix,
|
|
140
|
+
}: {
|
|
141
|
+
rule: ConditionRule | undefined;
|
|
142
|
+
fields: FormFieldDef[];
|
|
143
|
+
prefix: string;
|
|
144
|
+
}) {
|
|
145
|
+
const [open, setOpen] = useState(false);
|
|
146
|
+
const list = rule?.conditions ?? [];
|
|
147
|
+
if (list.length === 0) return null;
|
|
148
|
+
const nameOf = (key: string) => {
|
|
149
|
+
const f = fields.find((x) => x.key === key);
|
|
150
|
+
return f?.label.sv || f?.label.en || key;
|
|
151
|
+
};
|
|
152
|
+
const opLabel = (op: FieldCondition["op"]) =>
|
|
153
|
+
CONDITION_OPERATORS.find((o) => o.value === op)?.label ?? op;
|
|
154
|
+
const text = list
|
|
155
|
+
.map(
|
|
156
|
+
(c) =>
|
|
157
|
+
`${nameOf(c.field)} ${opLabel(c.op)}${isUnaryOperator(c.op) ? "" : ` “${c.value ?? ""}”`}`,
|
|
158
|
+
)
|
|
159
|
+
.join(rule?.mode === "any" ? " or " : " and ");
|
|
160
|
+
return (
|
|
161
|
+
<button
|
|
162
|
+
type="button"
|
|
163
|
+
onClick={() => setOpen((v) => !v)}
|
|
164
|
+
title={text}
|
|
165
|
+
className="max-w-full truncate bg-sea/15 px-2 py-0.5 text-[0.6rem] uppercase tracking-[0.14em] text-ink/70"
|
|
166
|
+
>
|
|
167
|
+
{prefix}: {open ? text : `${list.length} condition${list.length === 1 ? "" : "s"}`}
|
|
168
|
+
</button>
|
|
169
|
+
);
|
|
170
|
+
}
|