@cosmicdrift/kumiko-document-designer 0.0.0-stage → 0.363.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/LICENSE +57 -0
  2. package/README.md +37 -2
  3. package/dist/constants.d.ts +24 -0
  4. package/dist/constants.js +33 -0
  5. package/dist/feature.d.ts +2 -0
  6. package/dist/feature.js +85 -0
  7. package/dist/i18n.d.ts +6 -0
  8. package/dist/i18n.js +670 -0
  9. package/dist/index.d.ts +2 -0
  10. package/dist/index.js +2 -0
  11. package/dist/list-definition.d.ts +6 -0
  12. package/dist/list-definition.js +109 -0
  13. package/dist/web/cells.d.ts +10 -0
  14. package/dist/web/cells.js +87 -0
  15. package/dist/web/client-plugin.d.ts +12 -0
  16. package/dist/web/client-plugin.js +43 -0
  17. package/dist/web/compose-layout.d.ts +6 -0
  18. package/dist/web/compose-layout.js +13 -0
  19. package/dist/web/errors.d.ts +6 -0
  20. package/dist/web/errors.js +24 -0
  21. package/dist/web/field-issue-text.d.ts +4 -0
  22. package/dist/web/field-issue-text.js +12 -0
  23. package/dist/web/file-name.d.ts +2 -0
  24. package/dist/web/file-name.js +17 -0
  25. package/dist/web/i18n.d.ts +2 -0
  26. package/dist/web/i18n.js +170 -0
  27. package/dist/web/index.d.ts +6 -0
  28. package/dist/web/index.js +7 -0
  29. package/dist/web/layout-editor-body.d.ts +14 -0
  30. package/dist/web/layout-editor-body.js +142 -0
  31. package/dist/web/layout-editor.d.ts +2 -0
  32. package/dist/web/layout-editor.js +41 -0
  33. package/dist/web/layout-html.d.ts +7 -0
  34. package/dist/web/layout-html.js +81 -0
  35. package/dist/web/layout-rails.d.ts +18 -0
  36. package/dist/web/layout-rails.js +107 -0
  37. package/dist/web/layout-types.d.ts +62 -0
  38. package/dist/web/layout-types.js +1 -0
  39. package/dist/web/layout-users.d.ts +3 -0
  40. package/dist/web/layout-users.js +8 -0
  41. package/dist/web/mail-dialogs.d.ts +29 -0
  42. package/dist/web/mail-dialogs.js +15 -0
  43. package/dist/web/mail-draft.d.ts +17 -0
  44. package/dist/web/mail-draft.js +132 -0
  45. package/dist/web/mail-edit-center.d.ts +17 -0
  46. package/dist/web/mail-edit-center.js +11 -0
  47. package/dist/web/mail-editor-body.d.ts +18 -0
  48. package/dist/web/mail-editor-body.js +193 -0
  49. package/dist/web/mail-editor.d.ts +2 -0
  50. package/dist/web/mail-editor.js +71 -0
  51. package/dist/web/mail-footer.d.ts +21 -0
  52. package/dist/web/mail-footer.js +44 -0
  53. package/dist/web/mail-header.d.ts +23 -0
  54. package/dist/web/mail-header.js +16 -0
  55. package/dist/web/mail-left-rail.d.ts +18 -0
  56. package/dist/web/mail-left-rail.js +53 -0
  57. package/dist/web/mail-origin-bar.d.ts +17 -0
  58. package/dist/web/mail-origin-bar.js +84 -0
  59. package/dist/web/mail-pdf-link-modal.d.ts +11 -0
  60. package/dist/web/mail-pdf-link-modal.js +54 -0
  61. package/dist/web/mail-preview-side.d.ts +12 -0
  62. package/dist/web/mail-preview-side.js +48 -0
  63. package/dist/web/mail-preview.d.ts +20 -0
  64. package/dist/web/mail-preview.js +43 -0
  65. package/dist/web/mail-right-rail.d.ts +19 -0
  66. package/dist/web/mail-right-rail.js +47 -0
  67. package/dist/web/mail-types.d.ts +112 -0
  68. package/dist/web/mail-types.js +1 -0
  69. package/dist/web/mail-upload-modal.d.ts +16 -0
  70. package/dist/web/mail-upload-modal.js +56 -0
  71. package/dist/web/mail-versions.d.ts +6 -0
  72. package/dist/web/mail-versions.js +43 -0
  73. package/dist/web/origin-bar.d.ts +13 -0
  74. package/dist/web/origin-bar.js +82 -0
  75. package/dist/web/overrides-drawer.d.ts +9 -0
  76. package/dist/web/overrides-drawer.js +19 -0
  77. package/dist/web/overview-row.d.ts +21 -0
  78. package/dist/web/overview-row.js +47 -0
  79. package/dist/web/page-canvas.d.ts +18 -0
  80. package/dist/web/page-canvas.js +38 -0
  81. package/dist/web/pdf-meta.d.ts +23 -0
  82. package/dist/web/pdf-meta.js +101 -0
  83. package/dist/web/pdf-preview.d.ts +4 -0
  84. package/dist/web/pdf-preview.js +99 -0
  85. package/dist/web/pdf-template-editor.d.ts +8 -0
  86. package/dist/web/pdf-template-editor.js +326 -0
  87. package/dist/web/placeholder-panel.d.ts +21 -0
  88. package/dist/web/placeholder-panel.js +35 -0
  89. package/dist/web/preview-record-picker.d.ts +12 -0
  90. package/dist/web/preview-record-picker.js +7 -0
  91. package/dist/web/rail.d.ts +18 -0
  92. package/dist/web/rail.js +14 -0
  93. package/dist/web/settings-panel.d.ts +17 -0
  94. package/dist/web/settings-panel.js +25 -0
  95. package/dist/web/status-label.d.ts +7 -0
  96. package/dist/web/status-label.js +18 -0
  97. package/dist/web/template-rows.d.ts +9 -0
  98. package/dist/web/template-rows.js +34 -0
  99. package/dist/web/types.d.ts +103 -0
  100. package/dist/web/types.js +3 -0
  101. package/dist/web/use-insert-targets.d.ts +9 -0
  102. package/dist/web/use-insert-targets.js +17 -0
  103. package/dist/web/use-mail-persistence.d.ts +15 -0
  104. package/dist/web/use-mail-persistence.js +49 -0
  105. package/dist/web/use-overview-rows.d.ts +7 -0
  106. package/dist/web/use-overview-rows.js +19 -0
  107. package/dist/web/use-settled-value.d.ts +2 -0
  108. package/dist/web/use-settled-value.js +18 -0
  109. package/dist/web/use-test-mail.d.ts +15 -0
  110. package/dist/web/use-test-mail.js +27 -0
  111. package/dist/web/used-by-label.d.ts +9 -0
  112. package/dist/web/used-by-label.js +32 -0
  113. package/package.json +63 -4
  114. package/src/changes.json +20 -0
@@ -0,0 +1,326 @@
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
+ import { useDispatcher, usePrimitives, useQuery, useTranslation, useUserRoles, } from "@cosmicdrift/kumiko-renderer";
5
+ import { RendererPuppeteerClientQueries } from "@cosmicdrift/kumiko-renderer-puppeteer-client/qualified-names";
6
+ import { ErrorState, LoadingState, ModeSwitch, StatusBadge, uploadFileRef, } from "@cosmicdrift/kumiko-renderer-web";
7
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
8
+ import { describeError, isAlreadyActive } from "./errors.js";
9
+ import { OverridesDrawer } from "./overrides-drawer.js";
10
+ import { PageCanvas } from "./page-canvas.js";
11
+ import { EMPTY_PDF_META, parsePdfMeta, serializePdfMeta } from "./pdf-meta.js";
12
+ import { PdfPreview } from "./pdf-preview.js";
13
+ import { PlaceholderPanel, visibleSystemPlaceholders, } from "./placeholder-panel.js";
14
+ import { SettingsPanel } from "./settings-panel.js";
15
+ import { localesOf, parseJsonObject, pickRow, resolveLocale } from "./template-rows.js";
16
+ import { DEFAULT_LAYOUT_SLUG, DOCUMENT_PDF_KIND, PDF_LAYOUT_KIND, } from "./types.js";
17
+ const SETTLE_MS = 500;
18
+ const NO_VARIABLES = {};
19
+ const SYSTEM_LABEL_NAMES = ["tenantName", "imprint", "signature", "page", "pages"];
20
+ // Page numbers share one row in the panel; "pages" is inserted with "page".
21
+ const MERGED_SYSTEM_PLACEHOLDERS = new Set(["pages"]);
22
+ const draftFromDetail = (detail) => ({
23
+ content: detail.content,
24
+ meta: parsePdfMeta(detail.meta),
25
+ });
26
+ const snapshotOf = (draft) => JSON.stringify({ content: draft.content, meta: serializePdfMeta(draft.meta) });
27
+ function formatSize(bytes) {
28
+ return bytes < 1024 * 1024
29
+ ? `${Math.max(1, Math.round(bytes / 1024))} KB`
30
+ : `${(bytes / 1024 / 1024).toFixed(1)} MB`;
31
+ }
32
+ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChange, }) {
33
+ const t = useTranslation();
34
+ const dispatcher = useDispatcher();
35
+ const { Button, Banner, Dialog } = usePrimitives();
36
+ const roles = useUserRoles();
37
+ const scope = roles?.includes("SystemAdmin") === true ? "system" : "tenant";
38
+ const rowsQuery = useQuery(TemplateResolverQueries.list, {
39
+ kind: DOCUMENT_PDF_KIND,
40
+ });
41
+ const rows = rowsQuery.data ?? [];
42
+ const locales = localesOf(rows, slug);
43
+ const locale = resolveLocale(requestedLocale, locales);
44
+ const row = pickRow(rows, slug, locale, scope);
45
+ const detailQuery = useQuery(TemplateResolverQueries.findById, { id: row?.id ?? "" }, { enabled: row !== undefined });
46
+ const detail = detailQuery.data !== null &&
47
+ detailQuery.data?.slug === slug &&
48
+ detailQuery.data.locale === locale
49
+ ? detailQuery.data
50
+ : null;
51
+ const [state, setState] = useState(null);
52
+ if (detail !== null && (state === null || state.key !== detail.id)) {
53
+ const draft = draftFromDetail(detail);
54
+ setState({ key: detail.id, draft, settled: draft, snapshot: snapshotOf(draft) });
55
+ }
56
+ const current = state !== null && detail !== null && state.key === detail.id ? state : null;
57
+ const settleTimer = useRef(null);
58
+ // A pending settle must not fire into an unmounted editor.
59
+ useEffect(() => () => {
60
+ if (settleTimer.current !== null)
61
+ clearTimeout(settleTimer.current);
62
+ }, []);
63
+ const [overridesOpen, setOverridesOpen] = useState(false);
64
+ const editorHandle = useRef(null);
65
+ const [mode, setMode] = useState("edit");
66
+ const [busy, setBusy] = useState(null);
67
+ const [actionError, setActionError] = useState(null);
68
+ const [generated, setGenerated] = useState(null);
69
+ const [pendingLocale, setPendingLocale] = useState(null);
70
+ const variableSchema = useMemo(() => parseJsonObject(detail?.variableSchema ?? null), [detail?.variableSchema]);
71
+ const settledMeta = current === null ? null : serializePdfMeta(current.settled.meta);
72
+ const validationQuery = useQuery(TemplateResolverQueries.validate, {
73
+ slug,
74
+ kind: DOCUMENT_PDF_KIND,
75
+ contentFormat: "html",
76
+ content: current?.settled.content ?? "",
77
+ meta: settledMeta ?? {},
78
+ variableSchema,
79
+ locale,
80
+ scope,
81
+ }, { enabled: current !== null });
82
+ const validation = validationQuery.data;
83
+ const layoutSlug = current?.settled.meta.layoutSlug ?? "";
84
+ const previewQuery = useQuery(TemplateResolverQueries.preview, {
85
+ slug,
86
+ kind: DOCUMENT_PDF_KIND,
87
+ locale,
88
+ content: detail?.content ?? "",
89
+ meta: layoutSlug === "" ? {} : { layoutSlug },
90
+ variableSchema,
91
+ }, { enabled: detail !== null });
92
+ const layout = previewQuery.error === null ? (previewQuery.data?.layout ?? null) : null;
93
+ const layoutsQuery = useQuery(TemplateResolverQueries.list, {
94
+ kind: PDF_LAYOUT_KIND,
95
+ locale,
96
+ });
97
+ const layoutSlugs = [
98
+ ...new Set((layoutsQuery.data ?? []).map((layoutRow) => layoutRow.slug)),
99
+ ].sort();
100
+ const statusQuery = useQuery(RendererPuppeteerClientQueries.status, {});
101
+ const pdfAvailable = statusQuery.error === null && statusQuery.data?.available === true;
102
+ const unavailableMessage = statusQuery.data?.available === false ? (statusQuery.data.message ?? "") : "";
103
+ const showOrigin = scope === "system";
104
+ const overridesQuery = useQuery(TemplateResolverQueries.tenantOverrides, { slug, kind: DOCUMENT_PDF_KIND, locale }, { enabled: showOrigin && detail !== null });
105
+ const variables = validation?.variables ?? NO_VARIABLES;
106
+ const systemLabel = useCallback((name) => {
107
+ const label = t(`documentDesigner.system.${name}`);
108
+ return label === `documentDesigner.system.${name}` ? name : label;
109
+ }, [t]);
110
+ const variableLabels = useMemo(() => Object.fromEntries(Object.entries(variables).flatMap(([name, info]) => info.label === undefined ? [] : [[name, info.label]])), [variables]);
111
+ const systemLabels = useMemo(() => Object.fromEntries(SYSTEM_LABEL_NAMES.map((name) => [name, systemLabel(name)])), [systemLabel]);
112
+ const systemVariables = useMemo(() => visibleSystemPlaceholders(validation?.systemPlaceholders ?? []), [validation?.systemPlaceholders]);
113
+ const variableNames = useMemo(() => Object.keys(variables), [variables]);
114
+ const linkedResources = detail?.linkedResources ?? null;
115
+ // The editor shows resource images from the stored copies; no extra request is made here.
116
+ const resourcePreviews = useMemo(() => Object.fromEntries(Object.entries(parseJsonObject(linkedResources)).flatMap(([key, fileRefId]) => typeof fileRefId === "string"
117
+ ? [[key, { src: `/api/files/${encodeURIComponent(fileRefId)}`, caption: key }]]
118
+ : [])), [linkedResources]);
119
+ const onEditorReady = useCallback((handle) => {
120
+ editorHandle.current = handle;
121
+ }, []);
122
+ if (rowsQuery.loading && rowsQuery.data === null)
123
+ return _jsx(LoadingState, {});
124
+ if (rowsQuery.error !== null)
125
+ return _jsx(ErrorState, { error: rowsQuery.error });
126
+ if (row === undefined)
127
+ return _jsx(Banner, { variant: "info", children: t("documentDesigner.notFound", { slug }) });
128
+ if (current === null || detail === null) {
129
+ return detailQuery.error !== null ? _jsx(ErrorState, { error: detailQuery.error }) : _jsx(LoadingState, {});
130
+ }
131
+ const dirty = snapshotOf(current.draft) !== current.snapshot;
132
+ const generatedFresh = generated !== null && generated.forSnapshot === snapshotOf(current.draft);
133
+ function updateDraft(next) {
134
+ setState((previous) => (previous === null ? previous : { ...previous, draft: next }));
135
+ if (settleTimer.current !== null)
136
+ clearTimeout(settleTimer.current);
137
+ settleTimer.current = setTimeout(() => {
138
+ setState((previous) => previous === null ? previous : { ...previous, settled: previous.draft });
139
+ }, SETTLE_MS);
140
+ }
141
+ function resetDraft(source) {
142
+ const draft = draftFromDetail(source);
143
+ if (settleTimer.current !== null)
144
+ clearTimeout(settleTimer.current);
145
+ setState({ key: source.id, draft, settled: draft, snapshot: snapshotOf(draft) });
146
+ setActionError(null);
147
+ }
148
+ async function reload() {
149
+ await Promise.all([rowsQuery.refetch(), detailQuery.refetch(), validationQuery.refetch()]);
150
+ }
151
+ async function persist() {
152
+ const draft = current?.draft ?? { content: "", meta: EMPTY_PDF_META };
153
+ const parentTemplateId = detail?.scope === "system" ? detail.id : detail?.parentTemplateId;
154
+ const result = await dispatcher.write(scope === "system"
155
+ ? TemplateResolverHandlers.upsertSystem
156
+ : TemplateResolverHandlers.upsertTenant, {
157
+ slug,
158
+ kind: DOCUMENT_PDF_KIND,
159
+ locale,
160
+ content: draft.content,
161
+ contentFormat: "html",
162
+ variableSchema,
163
+ meta: serializePdfMeta(draft.meta),
164
+ ...(scope === "tenant" && parentTemplateId != null && { parentTemplateId }),
165
+ });
166
+ if (!result.isSuccess) {
167
+ setActionError(describeError(t, result.error));
168
+ return null;
169
+ }
170
+ setState((previous) => previous === null ? previous : { ...previous, snapshot: snapshotOf(draft) });
171
+ return result.data;
172
+ }
173
+ async function save() {
174
+ setBusy("save");
175
+ setActionError(null);
176
+ const saved = await persist();
177
+ if (saved !== null)
178
+ await reload();
179
+ setBusy(null);
180
+ return saved !== null;
181
+ }
182
+ async function publish() {
183
+ setBusy("publish");
184
+ setActionError(null);
185
+ // A system save activates immediately; a tenant row needs a save first when it
186
+ // does not exist yet or carries unsaved edits, then an explicit publish.
187
+ const needsSave = dirty || detail?.scope === "system";
188
+ const saved = needsSave ? await persist() : null;
189
+ const saveFailed = needsSave && saved === null;
190
+ // upsertSystem already activates, so only a tenant row needs the explicit publish.
191
+ const alreadyActivatedBySave = scope === "system" && saved !== null;
192
+ const templateId = saved?.id ?? detail?.id ?? null;
193
+ if (templateId !== null && !saveFailed && !alreadyActivatedBySave) {
194
+ const result = await dispatcher.write(scope === "system"
195
+ ? TemplateResolverHandlers.publishSystem
196
+ : TemplateResolverHandlers.publish, { id: templateId });
197
+ if (!result.isSuccess && !isAlreadyActive(result.error))
198
+ setActionError(describeError(t, result.error));
199
+ }
200
+ await reload();
201
+ setBusy(null);
202
+ }
203
+ async function generatePdf() {
204
+ if (current === null)
205
+ return;
206
+ setMode("preview");
207
+ setBusy("pdf");
208
+ setActionError(null);
209
+ const forSnapshot = snapshotOf(current.draft);
210
+ const result = await dispatcher.query(RendererPuppeteerClientQueries.previewPdf, {
211
+ slug,
212
+ locale,
213
+ content: current.draft.content,
214
+ meta: serializePdfMeta(current.draft.meta),
215
+ variableSchema,
216
+ });
217
+ if (result.isSuccess)
218
+ setGenerated({ forSnapshot, result: result.data });
219
+ else
220
+ setActionError(describeError(t, result.error));
221
+ setBusy(null);
222
+ }
223
+ function switchMode(next) {
224
+ setMode(next);
225
+ if (next === "preview" && pdfAvailable && !generatedFresh && busy === null)
226
+ void generatePdf();
227
+ }
228
+ async function uploadResource(file) {
229
+ const sourceFileRefId = await uploadFileRef(file);
230
+ const result = await dispatcher.write(TemplateResolverHandlers.resourceUpload, {
231
+ templateId: detail?.id,
232
+ sourceFileRefId,
233
+ });
234
+ if (!result.isSuccess)
235
+ throw new Error(describeError(t, result.error));
236
+ await detailQuery.refetch();
237
+ }
238
+ function requestLocale(next) {
239
+ if (next === locale)
240
+ return;
241
+ if (dirty)
242
+ setPendingLocale(next);
243
+ else
244
+ onLocaleChange?.(next);
245
+ }
246
+ const used = validation?.used ?? [];
247
+ const missingRequired = validation?.missingRequired ?? [];
248
+ const layoutName = layout?.slug ?? DEFAULT_LAYOUT_SLUG;
249
+ const systemNames = visibleSystemPlaceholders(validation?.systemPlaceholders ?? []).filter((name) => !MERGED_SYSTEM_PLACEHOLDERS.has(name));
250
+ const systemRows = systemNames.map((name) => ({
251
+ name,
252
+ label: systemLabel(name),
253
+ status: used.includes(name)
254
+ ? "used"
255
+ : layout !== null &&
256
+ (layout.html.includes(`data-variable="${name}"`) || layout.html.includes(`{{${name}}}`))
257
+ ? "layout"
258
+ : "unused",
259
+ }));
260
+ const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
261
+ const canPublish = validation?.canPublish === true;
262
+ const issueTexts = [...(validation?.issues ?? []), ...(validation?.publishIssues ?? [])].map((issue) => t(issue.i18nKey, issue.params));
263
+ const resourceKeys = Object.keys(parseJsonObject(detail.linkedResources));
264
+ const actions = (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "ghost", disabled: !dirty || busy !== null, onClick: () => resetDraft(detail), children: t("documentDesigner.actions.discard") }), _jsx(Button, { variant: "secondary", disabled: busy !== null || validation?.canSave === false, loading: busy === "save", onClick: () => void save(), children: t("documentDesigner.actions.save") }), _jsx(Button, { variant: "primary", disabled: busy !== null || !canPublish, loading: busy === "publish", onClick: () => void publish(), children: t("documentDesigner.actions.publish") })] }));
265
+ return (_jsxs("div", { className: "flex flex-col gap-3", "data-testid": "pdf-template-editor", children: [_jsxs("header", { className: "flex flex-wrap items-center gap-3", children: [_jsxs("nav", { className: "text-sm text-muted-foreground", children: [scope === "system" ? `${t("documentDesigner.breadcrumb.platform")} › ` : "", t("documentDesigner.breadcrumb.templates"), " \u203A", " ", _jsx("span", { className: "text-foreground", children: slug })] }), _jsx(StatusBadge, { tone: detail.status === "active" ? "ok" : "warn", children: t(detail.status === "active"
266
+ ? "documentDesigner.status.activeVersion"
267
+ : "documentDesigner.status.draftVersion", { version: detail.version }) }), _jsx(StatusBadge, { tone: "muted", children: t(detail.scope === "system"
268
+ ? "documentDesigner.origin.system"
269
+ : "documentDesigner.origin.tenant") }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.locale"), value: locale, onChange: requestLocale, options: (locales.length > 0 ? locales : [locale]).map((value) => ({
270
+ value,
271
+ label: value.toUpperCase(),
272
+ })) }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.mode.label"), variant: "pill", value: mode, onChange: switchMode, options: [
273
+ { value: "edit", label: t("documentDesigner.mode.edit") },
274
+ { value: "preview", label: t("documentDesigner.mode.preview") },
275
+ ] }), _jsxs("div", { className: "ml-auto flex items-center gap-2", children: [_jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, loading: busy === "pdf", onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") }), actions] })] }), showOrigin && detail.scope === "system" && (_jsx(Banner, { variant: "info", actions: _jsxs("div", { className: "flex flex-wrap items-center gap-4", children: [_jsx(Button, { variant: "link", onClick: () => void exportBundle(), children: t("documentDesigner.originBar.export") }), _jsx(Button, { variant: "link", onClick: () => setOverridesOpen(true), children: t("documentDesigner.originBar.showOverrides") })] }), children: overridesQuery.data?.totalTenants == null
276
+ ? t("documentDesigner.originBar.noTotal", { count: overrideCount })
277
+ : t("documentDesigner.originBar.withTotal", {
278
+ count: overrideCount,
279
+ total: overridesQuery.data.totalTenants,
280
+ }) })), _jsx(OverridesDrawer, { open: overridesOpen, onOpenChange: setOverridesOpen, slug: slug, overrides: overridesQuery.data }), !pdfAvailable && statusQuery.loading === false && (_jsx(Banner, { variant: "warning", children: t("documentDesigner.pdfUnavailable", { message: unavailableMessage }) })), actionError !== null && _jsx(Banner, { variant: "error", children: actionError }), _jsxs("div", { className: "grid grid-cols-[260px_minmax(0,1fr)_300px] items-start gap-4", children: [_jsx(PlaceholderPanel, { variables: variables, used: used, missingRequired: missingRequired, systemRows: systemRows, resourceKeys: resourceKeys, canUpload: detail.scope === scope, layoutName: layoutName, onInsert: (name) => editorHandle.current?.insertPlaceholder(name), onInsertResource: (key) => editorHandle.current?.insertResource(key), onUpload: uploadResource }), mode === "edit" ? (_jsx(PageCanvas, { meta: current.draft.meta, content: current.draft.content, onContentChange: (content) => updateDraft({ ...current.draft, content }), regions: layout?.regions ?? null, layoutName: layoutName, variableLabels: variableLabels, systemVariables: systemVariables, systemLabels: systemLabels, variableNames: variableNames, resourcePreviews: resourcePreviews, onEditorReady: onEditorReady })) : generated !== null && generatedFresh ? (_jsx(PdfPreview, { pdfBase64: generated.result.pdfBase64 })) : (_jsxs("div", { className: "flex flex-col items-start gap-3 bg-muted p-6", "data-testid": "document-designer-preview-stale", children: [_jsx("p", { className: "text-sm text-muted-foreground", children: busy === "pdf"
281
+ ? t("documentDesigner.preview.loading")
282
+ : t("documentDesigner.preview.stale") }), _jsx(Button, { variant: "secondary", disabled: !pdfAvailable || busy !== null, onClick: () => void generatePdf(), children: t("documentDesigner.actions.generatePdf") })] })), _jsx(SettingsPanel, { meta: current.draft.meta, onMetaChange: (meta) => updateDraft({ ...current.draft, meta }), layoutSlugs: layoutSlugs, locale: locale, version: {
283
+ version: detail.version,
284
+ status: detail.status,
285
+ modifiedAt: detail.modifiedAt,
286
+ } })] }), _jsxs("footer", { className: "flex flex-wrap items-start justify-between gap-3 border-t border-border pt-3", children: [_jsxs("ul", { className: "m-0 flex list-none flex-col gap-1 p-0 text-sm", "data-testid": "document-designer-validation", children: [missingRequired.length === 0 && validation !== undefined && validation !== null && (_jsx("li", { className: "text-status-ok", children: t("documentDesigner.validation.ok") })), missingRequired.map((name) => (_jsx("li", { className: "text-status-bad", children: t("documentDesigner.validation.missingRequired", {
287
+ name: variables[name]?.label ?? name,
288
+ }) }, name))), issueTexts.map((text) => (_jsx("li", { className: "text-status-bad", children: text }, text))), (validation?.warnings ?? []).map((warning) => (_jsx("li", { className: "text-status-warn", children: t(warning.i18nKey, { detail: warning.detail }) }, `${warning.code}:${warning.detail}`))), generated !== null && (_jsx("li", { className: "text-muted-foreground", children: t("documentDesigner.validation.lastProbe", {
289
+ pages: generated.result.pageCount,
290
+ size: formatSize(generated.result.sizeBytes),
291
+ seconds: (generated.result.durationMs / 1000).toFixed(1),
292
+ }) }))] }), _jsx("div", { className: "flex items-center gap-2", children: actions })] }), _jsx(Dialog, { open: pendingLocale !== null, onOpenChange: (open) => {
293
+ if (!open)
294
+ setPendingLocale(null);
295
+ }, title: t("documentDesigner.localeDialog.title"), description: t("documentDesigner.localeDialog.description"), confirmLabel: t("documentDesigner.localeDialog.save"), cancelLabel: t("documentDesigner.localeDialog.cancel"), onConfirm: async () => {
296
+ const target = pendingLocale;
297
+ if (target !== null && (await save()))
298
+ onLocaleChange?.(target);
299
+ setPendingLocale(null);
300
+ }, children: _jsx(Button, { variant: "danger-ghost", onClick: () => {
301
+ const target = pendingLocale;
302
+ setPendingLocale(null);
303
+ resetDraft(detail);
304
+ if (target !== null)
305
+ onLocaleChange?.(target);
306
+ }, children: t("documentDesigner.localeDialog.discard") }) })] }));
307
+ async function exportBundle() {
308
+ const result = await dispatcher.query(TemplateResolverQueries.exportBundle, {
309
+ slug,
310
+ kind: DOCUMENT_PDF_KIND,
311
+ scope: "system",
312
+ });
313
+ if (!result.isSuccess) {
314
+ setActionError(describeError(t, result.error));
315
+ return;
316
+ }
317
+ const url = URL.createObjectURL(new Blob([JSON.stringify(result.data, null, 2)], { type: "application/json" }));
318
+ const anchor = document.createElement("a");
319
+ anchor.href = url;
320
+ // Slugs are namespaced with ":", which browsers rewrite in download names
321
+ // on their own; replacing it here keeps the file name stable everywhere.
322
+ anchor.download = `${slug.replace(/[\\/:*?"<>|]/g, "_")}.bundle.json`;
323
+ anchor.click();
324
+ URL.revokeObjectURL(url);
325
+ }
326
+ }
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from "react";
2
+ import type { VariableInfo } from "./types.js";
3
+ export type SystemPlaceholderRow = {
4
+ readonly name: string;
5
+ readonly label: string;
6
+ readonly status: "used" | "layout" | "unused";
7
+ };
8
+ export type PlaceholderPanelProps = {
9
+ readonly variables: Readonly<Record<string, VariableInfo>>;
10
+ readonly used: readonly string[];
11
+ readonly missingRequired: readonly string[];
12
+ readonly systemRows: readonly SystemPlaceholderRow[];
13
+ readonly resourceKeys: readonly string[];
14
+ readonly canUpload: boolean;
15
+ readonly layoutName: string;
16
+ readonly onInsert: (name: string) => void;
17
+ readonly onInsertResource: (key: string) => void;
18
+ readonly onUpload: (file: File) => Promise<void>;
19
+ };
20
+ export declare function visibleSystemPlaceholders(names: readonly string[]): readonly string[];
21
+ export declare function PlaceholderPanel(props: PlaceholderPanelProps): ReactNode;
@@ -0,0 +1,35 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import { cn, SectionCard, UploadZone } from "@cosmicdrift/kumiko-renderer-web";
5
+ const TYPE_GLYPHS = {
6
+ text: "T",
7
+ money: "€",
8
+ date: "D",
9
+ link: "↗",
10
+ list: "≡",
11
+ };
12
+ // unsubscribeUrl belongs to mail; the PDF designer never offers it.
13
+ const HIDDEN_SYSTEM_PLACEHOLDERS = new Set(["unsubscribeUrl"]);
14
+ export function visibleSystemPlaceholders(names) {
15
+ return names.filter((name) => !HIDDEN_SYSTEM_PLACEHOLDERS.has(name));
16
+ }
17
+ export function PlaceholderPanel(props) {
18
+ const t = useTranslation();
19
+ const { Button } = usePrimitives();
20
+ const names = Object.keys(props.variables);
21
+ return (_jsxs("div", { className: "flex flex-col gap-4", "data-testid": "document-designer-placeholders", children: [_jsx(SectionCard, { title: t("documentDesigner.placeholders.title"), children: _jsx("ul", { className: "m-0 flex list-none flex-col gap-1 p-0", children: names.map((name) => {
22
+ const info = props.variables[name];
23
+ if (info === undefined)
24
+ return null;
25
+ const missing = props.missingRequired.includes(name);
26
+ const isUsed = props.used.includes(name);
27
+ return (_jsx("li", { children: _jsxs("button", { type: "button", "data-testid": `placeholder-${name}`, onClick: () => props.onInsert(name), className: cn("flex w-full items-center gap-2 rounded-md border px-2 py-1 text-left text-sm", missing
28
+ ? "border-status-bad bg-status-bad-surface"
29
+ : "border-border bg-background hover:bg-muted"), children: [_jsx("span", { className: "w-4 text-center text-xs text-muted-foreground", children: TYPE_GLYPHS[info.type] }), _jsxs("span", { className: "min-w-0 flex-1 truncate", children: [info.label ?? name, info.required === true ? " *" : ""] }), _jsx("span", { className: cn("text-xs", missing ? "font-medium text-status-bad" : "text-muted-foreground"), children: missing ? t("documentDesigner.placeholders.missing") : isUsed ? "✓" : "–" })] }) }, name));
30
+ }) }) }), _jsx(SectionCard, { title: t("documentDesigner.placeholders.system"), children: _jsx("ul", { className: "m-0 flex list-none flex-col gap-1 p-0", children: props.systemRows.map((row) => (_jsx("li", { children: _jsxs("button", { type: "button", "data-testid": `system-placeholder-${row.name}`, onClick: () => props.onInsert(row.name), className: "flex w-full items-center gap-2 rounded-md border border-primary/30 bg-primary/10 px-2 py-1 text-left text-sm text-primary", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: row.label }), _jsx("span", { className: "text-xs", children: row.status === "used"
31
+ ? "✓"
32
+ : row.status === "layout"
33
+ ? t("documentDesigner.placeholders.fromLayout")
34
+ : "–" })] }) }, row.name))) }) }), _jsxs(SectionCard, { title: t("documentDesigner.resources.title"), children: [props.resourceKeys.length === 0 ? (_jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.resources.empty") })) : (_jsx("ul", { className: "m-0 flex list-none flex-col gap-1 p-0 font-mono text-xs", children: props.resourceKeys.map((key) => (_jsxs("li", { className: "flex items-center justify-between gap-2", children: [_jsx("span", { className: "truncate", children: key }), _jsx(Button, { variant: "link", size: "sm", onClick: () => props.onInsertResource(key), ariaLabel: t("documentDesigner.resources.insertNamed", { key }), children: t("documentDesigner.resources.insert") })] }, key))) })), props.canUpload ? (_jsx(UploadZone, { multiple: false, accept: ["image/png", "image/jpeg", "image/gif", "image/webp"], title: t("documentDesigner.resources.upload"), onUpload: props.onUpload, testId: "document-designer-upload" })) : (_jsx(Button, { variant: "secondary", size: "sm", disabled: true, children: t("documentDesigner.resources.upload") })), !props.canUpload && (_jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.resources.saveFirst") }))] }), _jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.placeholders.layoutHint", { layout: props.layoutName }) })] }));
35
+ }
@@ -0,0 +1,12 @@
1
+ import { type ReactNode } from "react";
2
+ export type PreviewRecordPickerProps = {
3
+ readonly slug: string;
4
+ readonly locale: string;
5
+ readonly onPick: (values: Readonly<Record<string, unknown>>) => void;
6
+ };
7
+ export type PreviewRecordPicker = {
8
+ readonly label: string;
9
+ readonly renderPicker: (props: PreviewRecordPickerProps) => ReactNode;
10
+ };
11
+ export declare const PreviewRecordPickerProvider: import("react").Provider<PreviewRecordPicker | null>;
12
+ export declare function usePreviewRecordPicker(): PreviewRecordPicker | null;
@@ -0,0 +1,7 @@
1
+ // @runtime client
2
+ import { createContext, useContext } from "react";
3
+ const PreviewRecordPickerContext = createContext(null);
4
+ export const PreviewRecordPickerProvider = PreviewRecordPickerContext.Provider;
5
+ export function usePreviewRecordPicker() {
6
+ return useContext(PreviewRecordPickerContext);
7
+ }
@@ -0,0 +1,18 @@
1
+ import type { ReactNode } from "react";
2
+ export declare function RailSection({ title, action, testId, children, }: {
3
+ readonly title: string;
4
+ readonly action?: ReactNode;
5
+ readonly testId?: string;
6
+ readonly children: ReactNode;
7
+ }): ReactNode;
8
+ export declare function RailRow({ symbol, label, trailing, bad, testId, onPress, }: {
9
+ readonly symbol?: string;
10
+ readonly label: string;
11
+ readonly trailing?: ReactNode;
12
+ readonly bad?: boolean;
13
+ readonly testId?: string;
14
+ readonly onPress?: () => void;
15
+ }): ReactNode;
16
+ export declare function RailHint({ children }: {
17
+ readonly children: ReactNode;
18
+ }): ReactNode;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { usePrimitives } from "@cosmicdrift/kumiko-renderer";
4
+ import { cn } from "@cosmicdrift/kumiko-renderer-web";
5
+ export function RailSection({ title, action, testId, children, }) {
6
+ return (_jsxs("section", { className: "flex flex-col gap-1", "data-testid": testId, children: [_jsxs("div", { className: "flex items-center gap-2 pt-3 pb-1", children: [_jsx("span", { className: "text-xs font-semibold uppercase tracking-wide text-muted-foreground", children: title }), _jsx("div", { className: "grow" }), action] }), children] }));
7
+ }
8
+ export function RailRow({ symbol, label, trailing, bad, testId, onPress, }) {
9
+ const { Button } = usePrimitives();
10
+ return (_jsxs("div", { className: cn("flex items-center gap-2 rounded px-2 py-1", bad === true && "bg-status-bad-surface text-status-bad"), "data-testid": testId, "data-bad": bad === true ? "true" : undefined, children: [symbol !== undefined && (_jsx("span", { className: "w-4 shrink-0 text-center text-xs text-muted-foreground", children: symbol })), _jsx("span", { className: "min-w-0 grow truncate text-sm", children: onPress === undefined ? (label) : (_jsx(Button, { variant: "link", onClick: onPress, ...(testId !== undefined && { testId: `${testId}-insert` }), children: label })) }), trailing !== undefined && (_jsx("span", { className: "min-w-0 max-w-[60%] truncate text-right text-xs text-muted-foreground", title: typeof trailing === "string" ? trailing : undefined, children: trailing }))] }));
11
+ }
12
+ export function RailHint({ children }) {
13
+ return _jsx("p", { className: "px-2 pt-1 text-xs text-muted-foreground", children: children });
14
+ }
@@ -0,0 +1,17 @@
1
+ import type { ReactNode } from "react";
2
+ import { type PdfMetaDraft } from "./pdf-meta.js";
3
+ import type { TemplateStatus } from "./types.js";
4
+ export type VersionInfo = {
5
+ readonly version: number;
6
+ readonly status: TemplateStatus;
7
+ readonly modifiedAt: string | null;
8
+ };
9
+ export type SettingsPanelProps = {
10
+ readonly meta: PdfMetaDraft;
11
+ readonly onMetaChange: (next: PdfMetaDraft) => void;
12
+ /** Layout slugs available for this locale; the default layout is always offered. */
13
+ readonly layoutSlugs: readonly string[];
14
+ readonly locale: string;
15
+ readonly version: VersionInfo;
16
+ };
17
+ export declare function SettingsPanel({ meta, onMetaChange, layoutSlugs, locale, version, }: SettingsPanelProps): ReactNode;
@@ -0,0 +1,25 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // @runtime client
3
+ import { usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
4
+ import { ModeSwitch, SectionCard, StatusBadge } from "@cosmicdrift/kumiko-renderer-web";
5
+ import { MARGIN_SIDES, ORIENTATIONS, PAGE_FORMATS, } from "./pdf-meta.js";
6
+ const SIDE_LABEL_KEYS = {
7
+ top: "documentDesigner.settings.marginTop",
8
+ right: "documentDesigner.settings.marginRight",
9
+ bottom: "documentDesigner.settings.marginBottom",
10
+ left: "documentDesigner.settings.marginLeft",
11
+ };
12
+ export function SettingsPanel({ meta, onMetaChange, layoutSlugs, locale, version, }) {
13
+ const t = useTranslation();
14
+ const { Field, Input } = usePrimitives();
15
+ const patch = (changes) => onMetaChange({ ...meta, ...changes });
16
+ return (_jsxs("div", { className: "flex flex-col gap-4", "data-testid": "document-designer-settings", children: [_jsxs(SectionCard, { title: t("documentDesigner.settings.page"), children: [_jsx(Field, { id: "dd-layout", label: t("documentDesigner.settings.layout"), children: _jsx(Input, { kind: "select", id: "dd-layout", name: "layoutSlug", value: meta.layoutSlug, onChange: (layoutSlug) => patch({ layoutSlug }), display: "dropdown", options: [
17
+ { value: "", label: t("documentDesigner.settings.layoutDefault") },
18
+ ...layoutSlugs.map((slug) => ({ value: slug, label: slug })),
19
+ ] }) }), _jsx(Field, { id: "dd-file-name", label: t("documentDesigner.settings.fileName"), children: _jsx(Input, { kind: "text", id: "dd-file-name", name: "fileName", value: meta.fileName, onChange: (fileName) => patch({ fileName }), placeholder: "rechnung-{{number}}.pdf" }) }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.settings.pageFormat"), value: meta.pageFormat, onChange: (pageFormat) => patch({ pageFormat }), options: PAGE_FORMATS.map((value) => ({ value, label: value })) }), _jsx(ModeSwitch, { ariaLabel: t("documentDesigner.settings.orientation"), value: meta.orientation, onChange: (orientation) => patch({ orientation }), options: ORIENTATIONS.map((value) => ({
20
+ value,
21
+ label: t(`documentDesigner.settings.${value}`),
22
+ })) }), _jsx("div", { className: "grid grid-cols-2 gap-2", children: MARGIN_SIDES.map((side) => (_jsx(Field, { id: `dd-margin-${side}`, label: t(SIDE_LABEL_KEYS[side]), children: _jsx(Input, { kind: "number", id: `dd-margin-${side}`, name: `margin-${side}`, value: meta.margins[side], onChange: (value) => patch({ margins: { ...meta.margins, [side]: value ?? "" } }), placeholder: "15" }) }, side))) }), _jsx(Field, { id: "dd-print-background", label: t("documentDesigner.settings.printBackground"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "dd-print-background", name: "printBackground", value: meta.printBackground, onChange: (printBackground) => patch({ printBackground }) }) }), _jsx(Field, { id: "dd-layout-header-footer", label: t("documentDesigner.settings.layoutHeaderFooter"), layout: "inline", children: _jsx(Input, { kind: "boolean", id: "dd-layout-header-footer", name: "layoutHeaderFooter", value: meta.layoutHeaderFooter, onChange: (layoutHeaderFooter) => patch({ layoutHeaderFooter }) }) })] }), _jsxs(SectionCard, { title: t("documentDesigner.settings.metadata"), children: [_jsx(Field, { id: "dd-meta-title", label: t("documentDesigner.settings.metaTitle"), children: _jsx(Input, { kind: "text", id: "dd-meta-title", name: "metaTitle", value: meta.title, onChange: (title) => patch({ title }) }) }), _jsx(Field, { id: "dd-meta-author", label: t("documentDesigner.settings.metaAuthor"), children: _jsx(Input, { kind: "text", id: "dd-meta-author", name: "metaAuthor", value: meta.author, onChange: (author) => patch({ author }) }) }), _jsx(Field, { id: "dd-meta-language", label: t("documentDesigner.settings.metaLanguage"), children: _jsx(Input, { kind: "text", id: "dd-meta-language", name: "metaLanguage", value: locale, onChange: () => undefined, disabled: true }) })] }), _jsx(SectionCard, { title: t("documentDesigner.settings.storage"), children: _jsx("p", { className: "text-xs text-muted-foreground", children: t("documentDesigner.settings.storageHint") }) }), _jsx(SectionCard, { title: t("documentDesigner.settings.versions"), children: _jsxs("div", { className: "flex items-center justify-between gap-2 text-sm", children: [_jsx("span", { children: `v${version.version}` }), _jsx("span", { className: "text-xs text-muted-foreground", children: version.modifiedAt?.slice(0, 10) ?? "" }), _jsx(StatusBadge, { tone: version.status === "active" ? "ok" : "warn", children: t(version.status === "active"
23
+ ? "documentDesigner.status.active"
24
+ : "documentDesigner.status.draft") })] }) })] }));
25
+ }
@@ -0,0 +1,7 @@
1
+ import type { StatusBadgeTone } from "@cosmicdrift/kumiko-renderer-web";
2
+ import type { OverviewRowView } from "./overview-row.js";
3
+ type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
4
+ export declare function statusTone(status: OverviewRowView["status"]): StatusBadgeTone;
5
+ export declare function statusLabel(t: Translate, status: OverviewRowView["status"], version: number): string;
6
+ export declare function asRowStatus(status: string): OverviewRowView["status"];
7
+ export {};
@@ -0,0 +1,18 @@
1
+ const STATUS_TONE = {
2
+ active: "ok",
3
+ draft: "muted",
4
+ archived: "muted",
5
+ };
6
+ export function statusTone(status) {
7
+ return STATUS_TONE[status];
8
+ }
9
+ export function statusLabel(t, status, version) {
10
+ if (status === "archived")
11
+ return t("documentDesigner.status.archived");
12
+ return t(status === "active"
13
+ ? "documentDesigner.status.activeVersion"
14
+ : "documentDesigner.status.draftVersion", { version });
15
+ }
16
+ export function asRowStatus(status) {
17
+ return status === "active" || status === "archived" ? status : "draft";
18
+ }
@@ -0,0 +1,9 @@
1
+ import type { TemplateListRow, TemplateScope } from "./types.js";
2
+ /**
3
+ * The row the editor works on. A tenant sees its own customisation, or the
4
+ * system row as the starting point for one; a SystemAdmin edits the system row.
5
+ */
6
+ export declare function pickRow(rows: readonly TemplateListRow[], slug: string, locale: string, scope: TemplateScope): TemplateListRow | undefined;
7
+ export declare function localesOf(rows: readonly TemplateListRow[], slug: string): readonly string[];
8
+ export declare function resolveLocale(requested: string | undefined, available: readonly string[]): string;
9
+ export declare function parseJsonObject(raw: string | null): Record<string, unknown>;
@@ -0,0 +1,34 @@
1
+ // @runtime client
2
+ import { isPlainObject } from "@cosmicdrift/kumiko-types/type-guards";
3
+ /**
4
+ * The row the editor works on. A tenant sees its own customisation, or the
5
+ * system row as the starting point for one; a SystemAdmin edits the system row.
6
+ */
7
+ export function pickRow(rows, slug, locale, scope) {
8
+ const candidates = rows.filter((row) => row.slug === slug && row.locale === locale);
9
+ if (scope === "system")
10
+ return candidates.find((row) => row.scope === "system");
11
+ return (candidates.find((row) => row.scope === "tenant") ??
12
+ candidates.find((row) => row.scope === "system"));
13
+ }
14
+ export function localesOf(rows, slug) {
15
+ return [...new Set(rows.filter((row) => row.slug === slug).map((row) => row.locale))].sort();
16
+ }
17
+ export function resolveLocale(requested, available) {
18
+ if (requested !== undefined && requested !== "")
19
+ return requested;
20
+ if (available.includes("de") || available.length === 0)
21
+ return "de";
22
+ return available[0] ?? "de";
23
+ }
24
+ export function parseJsonObject(raw) {
25
+ if (raw === null)
26
+ return {};
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ return isPlainObject(parsed) ? parsed : {};
30
+ }
31
+ catch {
32
+ return {};
33
+ }
34
+ }