@cosmicdrift/kumiko-samples 0.377.0 → 0.378.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 +1 -1
- package/packages/bundled-features/package.json +1 -1
- package/packages/bundled-features/src/cap-counter/changes.json +12 -0
- package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
- package/packages/bundled-features/src/config/web/i18n.ts +1 -0
- package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
- package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
- package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
- package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
- package/packages/bundled-features/src/template-resolver/changes.json +20 -0
- package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
- package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
- package/packages/bundled-features/src/template-resolver/document-blocks.ts +3 -1
- package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
- package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +35 -0
- package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
- package/packages/bundled-features/src/template-resolver/feature.ts +77 -36
- package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
- package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
- package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
- package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
- package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
- package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
- package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
- package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +105 -96
- package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
- package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
- package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
- package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
- package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
- package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -111
- package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
- package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
- package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
- package/packages/bundled-features/src/template-resolver/index.ts +8 -1
- package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
- package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
- package/packages/bundled-features/src/template-resolver/template-preview.ts +8 -2
- package/packages/bundled-features/src/template-resolver/template-validation.ts +31 -10
- package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
- package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +42 -4
- package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +2 -0
- package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
- package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
- package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
- package/samples/apps/ui-walkthrough/src/app/shell.tsx +6 -0
- package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
- package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
- package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
- package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
- package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
- package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
- package/samples/recipes/delivery-notifications/README.md +1 -1
- package/samples/recipes/embedded/README.md +8 -0
- package/samples/recipes/embedded/src/feature.ts +8 -0
- package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
- package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
|
@@ -535,6 +535,26 @@ export const SYSTEM_TENANCY_CERTIFICATE: SeedTemplate = {
|
|
|
535
535
|
},
|
|
536
536
|
};
|
|
537
537
|
|
|
538
|
+
export const SYSTEM_PROPERTY_EXPOSE: SeedTemplate = {
|
|
539
|
+
slug: "rental:property-expose",
|
|
540
|
+
kind: "document-pdf",
|
|
541
|
+
locale: "de",
|
|
542
|
+
title: "Exposé",
|
|
543
|
+
content: [
|
|
544
|
+
`<h1>Exposé ${v("propertyAddress")}</h1>`,
|
|
545
|
+
`<p>${v("propertySummary")}</p>`,
|
|
546
|
+
"<h2>Objektfotos</h2>",
|
|
547
|
+
'<div data-images="photos"></div>',
|
|
548
|
+
].join(""),
|
|
549
|
+
meta: { layoutSlug: "default", pageFormat: "A4", fileName: "Expose.pdf" },
|
|
550
|
+
variableSchema: {
|
|
551
|
+
propertyAddress: { type: "text", required: true },
|
|
552
|
+
propertySummary: { type: "text", required: true },
|
|
553
|
+
// The app passes the FileRef ids of the property photos when it renders the PDF.
|
|
554
|
+
photos: { type: "images", label: "Objektfotos" },
|
|
555
|
+
},
|
|
556
|
+
};
|
|
557
|
+
|
|
538
558
|
// Two other tenants customise the invoice for the "Anpassungen der Mandanten" drawer: one
|
|
539
559
|
// published copy with a second save, one draft.
|
|
540
560
|
export type TenantInvoiceOverride = {
|
|
@@ -1,12 +1,18 @@
|
|
|
1
1
|
import type { EventStoreExecutor } from "@cosmicdrift/kumiko-framework/db";
|
|
2
|
-
import { defineFeature, i18nKey } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { defineFeature, defineWriteHandler, i18nKey } from "@cosmicdrift/kumiko-framework/engine";
|
|
3
3
|
import { failNotFound } from "@cosmicdrift/kumiko-framework/errors";
|
|
4
|
+
import {
|
|
5
|
+
renderPdfBeforeTransaction,
|
|
6
|
+
storeRenderedPdf,
|
|
7
|
+
} from "@cosmicdrift/kumiko-renderer-puppeteer-client";
|
|
4
8
|
import { z } from "zod";
|
|
5
9
|
import { openToAllSignedIn, toKeyFirst } from "../translations";
|
|
6
10
|
import { leaseEntity, leasePartyEntity, leasePositionEntity } from "./entities";
|
|
7
11
|
import { rentalTranslations } from "./i18n";
|
|
12
|
+
import { receiveInvoicePayloadSchema } from "./invoice-receive";
|
|
8
13
|
import {
|
|
9
14
|
dayBefore,
|
|
15
|
+
fileRefIdsPayloadSchema,
|
|
10
16
|
formatGermanDate,
|
|
11
17
|
formatGermanMoney,
|
|
12
18
|
germanStatusLabel,
|
|
@@ -18,6 +24,7 @@ import {
|
|
|
18
24
|
positionExecutor,
|
|
19
25
|
positionListRowSchema,
|
|
20
26
|
positionRowSchema,
|
|
27
|
+
propertyExposePayloadSchema,
|
|
21
28
|
type RecordedPositionItem,
|
|
22
29
|
recordPositionsPayloadSchema,
|
|
23
30
|
rentAdjustPayloadSchema,
|
|
@@ -32,8 +39,10 @@ import {
|
|
|
32
39
|
leaseListScreen,
|
|
33
40
|
leaseListShortScreen,
|
|
34
41
|
leaseSuccessionListScreen,
|
|
42
|
+
leaseUnterlagenScreen,
|
|
35
43
|
partyEditScreen,
|
|
36
44
|
positionEditScreen,
|
|
45
|
+
receiveInvoiceScreen,
|
|
37
46
|
recordPositionsScreen,
|
|
38
47
|
rentalDashboardScreen,
|
|
39
48
|
} from "./screens";
|
|
@@ -95,6 +104,17 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
95
104
|
});
|
|
96
105
|
r.crud("leaseParty", leasePartyEntity, { write: open, read: open });
|
|
97
106
|
|
|
107
|
+
r.queryHandler({
|
|
108
|
+
name: "lease:unterlagen",
|
|
109
|
+
schema: fileRefIdsPayloadSchema,
|
|
110
|
+
handler: async (query) => ({
|
|
111
|
+
id: "unterlagen",
|
|
112
|
+
titel: "Unterlagen",
|
|
113
|
+
unterlagen: query.payload.id,
|
|
114
|
+
}),
|
|
115
|
+
...open,
|
|
116
|
+
});
|
|
117
|
+
|
|
98
118
|
r.queryHandler({
|
|
99
119
|
name: "lease:akte",
|
|
100
120
|
schema: idPayloadSchema,
|
|
@@ -217,6 +237,28 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
217
237
|
...open,
|
|
218
238
|
});
|
|
219
239
|
|
|
240
|
+
r.writeHandler({
|
|
241
|
+
name: "invoice:receive",
|
|
242
|
+
schema: receiveInvoicePayloadSchema,
|
|
243
|
+
handler: async (event) => ({ isSuccess: true, data: event.payload }),
|
|
244
|
+
...open,
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
r.writeHandler(
|
|
248
|
+
defineWriteHandler({
|
|
249
|
+
name: "generate-property-expose",
|
|
250
|
+
schema: propertyExposePayloadSchema,
|
|
251
|
+
access: { roles: ["TenantAdmin"] },
|
|
252
|
+
beforeTransaction: async (event, ctx) =>
|
|
253
|
+
renderPdfBeforeTransaction(ctx, {
|
|
254
|
+
slug: "rental:property-expose",
|
|
255
|
+
locale: "de",
|
|
256
|
+
variables: event.payload,
|
|
257
|
+
}),
|
|
258
|
+
handler: async (event, ctx) => storeRenderedPdf(ctx, event.prepared),
|
|
259
|
+
}),
|
|
260
|
+
);
|
|
261
|
+
|
|
220
262
|
r.writeHandler({
|
|
221
263
|
name: "rent:adjust",
|
|
222
264
|
schema: rentAdjustPayloadSchema,
|
|
@@ -270,6 +312,14 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
270
312
|
r.screen(rentalDashboardScreen);
|
|
271
313
|
r.screen(adjustRentScreen);
|
|
272
314
|
r.screen(recordPositionsScreen);
|
|
315
|
+
r.screen(receiveInvoiceScreen);
|
|
316
|
+
r.screen(leaseUnterlagenScreen);
|
|
317
|
+
r.screen({
|
|
318
|
+
id: "upload-standard-layout",
|
|
319
|
+
type: "custom",
|
|
320
|
+
renderer: { react: { __component: "upload-standard-layout" } },
|
|
321
|
+
description: "Receipt upload on the standard form screen layout.",
|
|
322
|
+
});
|
|
273
323
|
|
|
274
324
|
r.nav({
|
|
275
325
|
id: "contracts",
|
|
@@ -290,4 +340,18 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
290
340
|
screen: "rental:screen:rental-dashboard",
|
|
291
341
|
order: 20,
|
|
292
342
|
});
|
|
343
|
+
r.nav({
|
|
344
|
+
id: "receive-invoice",
|
|
345
|
+
label: i18nKey("rental.nav.receiveInvoice"),
|
|
346
|
+
parent: "rental:nav:contracts",
|
|
347
|
+
screen: "rental:screen:receive-invoice",
|
|
348
|
+
order: 40,
|
|
349
|
+
});
|
|
350
|
+
r.nav({
|
|
351
|
+
id: "upload-standard-layout",
|
|
352
|
+
label: i18nKey("rental.nav.upload"),
|
|
353
|
+
parent: "rental:nav:contracts",
|
|
354
|
+
screen: "rental:screen:upload-standard-layout",
|
|
355
|
+
order: 30,
|
|
356
|
+
});
|
|
293
357
|
});
|
|
@@ -4,6 +4,23 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
4
4
|
de: {
|
|
5
5
|
"rental.nav.contracts": "Verträge",
|
|
6
6
|
"rental.nav.leases": "Mietverträge",
|
|
7
|
+
"screen:lease-unterlagen.title": "Unterlagen",
|
|
8
|
+
"rental.unterlagen.label": "Dateien",
|
|
9
|
+
"rental.unterlagen.section": "Mietvertragsunterlagen",
|
|
10
|
+
"rental.unterlagen.sectionDescription":
|
|
11
|
+
"Hochgeladene Rechnungen und Belege zum Download und zur Ansicht.",
|
|
12
|
+
"rental.nav.upload": "Belege hochladen",
|
|
13
|
+
"screen:upload-standard-layout.title": "Belege hochladen",
|
|
14
|
+
"rental.upload.description":
|
|
15
|
+
"Laden Sie Rechnungen und Nebenkostenbelege zum Mietvertrag hoch. Danach gehen sie zur Prüfung.",
|
|
16
|
+
"rental.upload.zoneTitle": "Belege auswählen",
|
|
17
|
+
"rental.upload.zoneDescription": "PDF oder Foto, mehrere Dateien auf einmal sind möglich.",
|
|
18
|
+
"rental.upload.dropTitle": "Rechnungen und Belege hier ablegen",
|
|
19
|
+
"rental.upload.dropHint": "PDF, JPG oder PNG",
|
|
20
|
+
"rental.upload.resultTitle": "Hochgeladene Belege",
|
|
21
|
+
"rental.upload.resultDescription": "Diese Dateien gehen mit der Prüfung an die Buchhaltung.",
|
|
22
|
+
"rental.upload.empty": "Noch keine Belege hochgeladen.",
|
|
23
|
+
"rental.upload.toReview": "Zur Prüfung",
|
|
7
24
|
|
|
8
25
|
"screen:lease-list.title": "Mietverträge",
|
|
9
26
|
"screen:lease-list-short.title": "Mietverträge (gekündigt)",
|
|
@@ -96,6 +113,24 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
96
113
|
"screen:record-positions.title": "Positionen erfassen",
|
|
97
114
|
"rental.section.items": "Positionen",
|
|
98
115
|
"rental.action.recordPositions": "Positionen speichern",
|
|
116
|
+
"rental.nav.receiveInvoice": "Eingangsrechnung",
|
|
117
|
+
"screen:receive-invoice.title": "Eingangsrechnung erfassen",
|
|
118
|
+
"rental.section.invoiceLines": "Rechnungspositionen",
|
|
119
|
+
"rental.section.invoiceTotals": "Summen",
|
|
120
|
+
"rental.section.invoiceTotalsHint":
|
|
121
|
+
"Mit Positionen sind die Summen berechnet. Ohne Positionen tragen Sie Netto und USt.-Satz ein.",
|
|
122
|
+
"rental.action.receiveInvoice": "Rechnung erfassen",
|
|
123
|
+
"rental:entity:__action-form__:field:supplier": "Lieferant",
|
|
124
|
+
"rental:entity:__action-form__:field:invoiceNumber": "Rechnungsnummer",
|
|
125
|
+
"rental:entity:__action-form__:field:lines": "Positionen",
|
|
126
|
+
"rental:entity:__action-form__:field:lines:cell:description": "Leistung",
|
|
127
|
+
"rental:entity:__action-form__:field:lines:cell:amount": "Netto",
|
|
128
|
+
"rental:entity:__action-form__:field:lines:cell:vatRate": "USt. in %",
|
|
129
|
+
"rental:entity:__action-form__:field:lines:cell:vatAmount": "USt.",
|
|
130
|
+
"rental:entity:__action-form__:field:netTotal": "Netto gesamt",
|
|
131
|
+
"rental:entity:__action-form__:field:vatRate": "USt.-Satz in %",
|
|
132
|
+
"rental:entity:__action-form__:field:vatTotal": "USt. gesamt",
|
|
133
|
+
"rental:entity:__action-form__:field:grossTotal": "Brutto gesamt",
|
|
99
134
|
"rental:entity:__action-form__:field:lease": "Mietvertrag",
|
|
100
135
|
"rental:entity:__action-form__:field:items": "Positionen",
|
|
101
136
|
"rental:entity:__action-form__:field:zahlungsweise": "Zahlungsweise",
|
|
@@ -121,6 +156,23 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
121
156
|
en: {
|
|
122
157
|
"rental.nav.contracts": "Contracts",
|
|
123
158
|
"rental.nav.leases": "Leases",
|
|
159
|
+
"screen:lease-unterlagen.title": "Documents",
|
|
160
|
+
"rental.unterlagen.label": "Files",
|
|
161
|
+
"rental.unterlagen.section": "Lease documents",
|
|
162
|
+
"rental.unterlagen.sectionDescription":
|
|
163
|
+
"Uploaded invoices and receipts to download or preview.",
|
|
164
|
+
"rental.nav.upload": "Upload receipts",
|
|
165
|
+
"screen:upload-standard-layout.title": "Upload receipts",
|
|
166
|
+
"rental.upload.description":
|
|
167
|
+
"Upload invoices and service charge receipts for the lease. They then go to review.",
|
|
168
|
+
"rental.upload.zoneTitle": "Choose receipts",
|
|
169
|
+
"rental.upload.zoneDescription": "PDF or photo, several files at once are fine.",
|
|
170
|
+
"rental.upload.dropTitle": "Drop invoices and receipts here",
|
|
171
|
+
"rental.upload.dropHint": "PDF, JPG or PNG",
|
|
172
|
+
"rental.upload.resultTitle": "Uploaded receipts",
|
|
173
|
+
"rental.upload.resultDescription": "These files go to accounting with the review.",
|
|
174
|
+
"rental.upload.empty": "No receipts uploaded yet.",
|
|
175
|
+
"rental.upload.toReview": "To review",
|
|
124
176
|
|
|
125
177
|
"screen:lease-list.title": "Leases",
|
|
126
178
|
"screen:lease-list-short.title": "Leases (terminated)",
|
|
@@ -210,6 +262,24 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
210
262
|
"screen:record-positions.title": "Record positions",
|
|
211
263
|
"rental.section.items": "Positions",
|
|
212
264
|
"rental.action.recordPositions": "Save positions",
|
|
265
|
+
"rental.nav.receiveInvoice": "Incoming invoice",
|
|
266
|
+
"screen:receive-invoice.title": "Record incoming invoice",
|
|
267
|
+
"rental.section.invoiceLines": "Invoice lines",
|
|
268
|
+
"rental.section.invoiceTotals": "Totals",
|
|
269
|
+
"rental.section.invoiceTotalsHint":
|
|
270
|
+
"With lines the totals are computed. Without lines, enter the net amount and the VAT rate.",
|
|
271
|
+
"rental.action.receiveInvoice": "Record invoice",
|
|
272
|
+
"rental:entity:__action-form__:field:supplier": "Supplier",
|
|
273
|
+
"rental:entity:__action-form__:field:invoiceNumber": "Invoice number",
|
|
274
|
+
"rental:entity:__action-form__:field:lines": "Lines",
|
|
275
|
+
"rental:entity:__action-form__:field:lines:cell:description": "Service",
|
|
276
|
+
"rental:entity:__action-form__:field:lines:cell:amount": "Net",
|
|
277
|
+
"rental:entity:__action-form__:field:lines:cell:vatRate": "VAT in %",
|
|
278
|
+
"rental:entity:__action-form__:field:lines:cell:vatAmount": "VAT",
|
|
279
|
+
"rental:entity:__action-form__:field:netTotal": "Net total",
|
|
280
|
+
"rental:entity:__action-form__:field:vatRate": "VAT rate in %",
|
|
281
|
+
"rental:entity:__action-form__:field:vatTotal": "VAT total",
|
|
282
|
+
"rental:entity:__action-form__:field:grossTotal": "Gross total",
|
|
213
283
|
"rental:entity:__action-form__:field:lease": "Lease",
|
|
214
284
|
"rental:entity:__action-form__:field:items": "Positions",
|
|
215
285
|
"rental:entity:__action-form__:field:zahlungsweise": "Payment method",
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { fieldToZod, withFormDerivations } from "@cosmicdrift/kumiko-framework/engine";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { receiveInvoiceLines, receiveInvoiceScreen } from "./screens";
|
|
4
|
+
|
|
5
|
+
const moneyPayload = z.object({ amount: z.number(), currency: z.string().min(1) });
|
|
6
|
+
|
|
7
|
+
// Totals are recomputed from the lines before validation, so a client-sent
|
|
8
|
+
// total never reaches the handler.
|
|
9
|
+
export const receiveInvoicePayloadSchema = withFormDerivations(
|
|
10
|
+
z.object({
|
|
11
|
+
supplier: z.string().min(1),
|
|
12
|
+
invoiceNumber: z.string().min(1),
|
|
13
|
+
lines: fieldToZod(receiveInvoiceLines, ["EUR"]),
|
|
14
|
+
netTotal: moneyPayload,
|
|
15
|
+
vatRate: z.number().optional(),
|
|
16
|
+
vatTotal: moneyPayload,
|
|
17
|
+
grossTotal: moneyPayload,
|
|
18
|
+
}),
|
|
19
|
+
receiveInvoiceScreen,
|
|
20
|
+
);
|
|
@@ -12,6 +12,21 @@ export const { executor: partyExecutor } = createEntityExecutor("leaseParty", le
|
|
|
12
12
|
|
|
13
13
|
export const idPayloadSchema = z.object({ id: z.uuid() });
|
|
14
14
|
|
|
15
|
+
// Walkthrough shortcut, not the pattern: a real handler reads the photo ids from the stored record, never from the client.
|
|
16
|
+
export const propertyExposePayloadSchema = z.object({
|
|
17
|
+
propertyAddress: z.string().min(1),
|
|
18
|
+
propertySummary: z.string().min(1),
|
|
19
|
+
photos: z.array(z.uuid()).max(10),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// Walkthrough shortcut, not the pattern: real screens resolve FileRefs from the record, never from route-supplied ids.
|
|
23
|
+
export const fileRefIdsPayloadSchema = z.object({
|
|
24
|
+
id: z
|
|
25
|
+
.string()
|
|
26
|
+
.transform((raw) => raw.split(","))
|
|
27
|
+
.pipe(z.array(z.uuid()).min(1).max(10)),
|
|
28
|
+
});
|
|
29
|
+
|
|
15
30
|
export const rentAdjustPayloadSchema = z.object({
|
|
16
31
|
positionId: z.uuid(),
|
|
17
32
|
wirksamAb: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
export const RENTAL_QUERIES = {
|
|
2
2
|
leaseAkte: "rental:query:lease:akte",
|
|
3
|
+
leaseUnterlagen: "rental:query:lease:unterlagen",
|
|
3
4
|
partyList: "rental:query:lease-party:list",
|
|
4
5
|
positionList: "rental:query:lease:positions",
|
|
5
6
|
kennzahlen: "rental:query:lease:kennzahlen",
|
|
@@ -9,4 +10,5 @@ export const RENTAL_WRITES = {
|
|
|
9
10
|
leaseTerminate: "rental:write:lease:terminate",
|
|
10
11
|
rentAdjust: "rental:write:rent:adjust",
|
|
11
12
|
positionsRecord: "rental:write:lease:record-positions",
|
|
13
|
+
invoiceReceive: "rental:write:invoice:receive",
|
|
12
14
|
} as const;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import {
|
|
2
2
|
createDateField,
|
|
3
|
+
createDecimalField,
|
|
3
4
|
createEmbeddedListField,
|
|
4
5
|
createSelectField,
|
|
5
6
|
createTextField,
|
|
@@ -462,6 +463,64 @@ export const recordPositionsScreen: ActionFormScreenDefinition = {
|
|
|
462
463
|
access: OPEN_ACCESS,
|
|
463
464
|
};
|
|
464
465
|
|
|
466
|
+
const EUR = { kind: "literal", code: "EUR" } as const;
|
|
467
|
+
|
|
468
|
+
export const receiveInvoiceLines = createEmbeddedListField(
|
|
469
|
+
{
|
|
470
|
+
description: { type: "text", required: true },
|
|
471
|
+
amount: { type: "money", required: true },
|
|
472
|
+
vatRate: { type: "decimal", scale: 2, required: true },
|
|
473
|
+
vatAmount: { type: "money" },
|
|
474
|
+
},
|
|
475
|
+
{
|
|
476
|
+
derived: { vatAmount: { op: "percent", from: ["amount", "vatRate"] } },
|
|
477
|
+
totals: ["amount", "vatAmount"],
|
|
478
|
+
},
|
|
479
|
+
);
|
|
480
|
+
|
|
481
|
+
export const receiveInvoiceScreen: ActionFormScreenDefinition = {
|
|
482
|
+
id: "receive-invoice",
|
|
483
|
+
type: "actionForm",
|
|
484
|
+
handler: RENTAL_WRITES.invoiceReceive,
|
|
485
|
+
fields: {
|
|
486
|
+
supplier: createTextField({ personal: "tenant", find: "none", required: true }),
|
|
487
|
+
invoiceNumber: createTextField({
|
|
488
|
+
personal: false,
|
|
489
|
+
reason: "is_business_data",
|
|
490
|
+
required: true,
|
|
491
|
+
}),
|
|
492
|
+
lines: receiveInvoiceLines,
|
|
493
|
+
netTotal: { type: "money", currency: EUR },
|
|
494
|
+
vatRate: createDecimalField({ precision: 5, scale: 2 }),
|
|
495
|
+
vatTotal: { type: "money", currency: EUR },
|
|
496
|
+
grossTotal: { type: "money", currency: EUR },
|
|
497
|
+
},
|
|
498
|
+
derive: {
|
|
499
|
+
netTotal: { op: "listSum", list: "lines", cell: "amount" },
|
|
500
|
+
vatTotal: {
|
|
501
|
+
op: "listSum",
|
|
502
|
+
list: "lines",
|
|
503
|
+
cell: "vatAmount",
|
|
504
|
+
whenEmpty: { op: "percent", from: ["netTotal", "vatRate"] },
|
|
505
|
+
},
|
|
506
|
+
grossTotal: { op: "sum", from: ["netTotal", "vatTotal"] },
|
|
507
|
+
},
|
|
508
|
+
layout: {
|
|
509
|
+
width: "full",
|
|
510
|
+
sections: [
|
|
511
|
+
{ fields: ["supplier", "invoiceNumber"] },
|
|
512
|
+
{ title: "rental.section.invoiceLines", columns: 1, fields: ["lines"] },
|
|
513
|
+
{
|
|
514
|
+
title: "rental.section.invoiceTotals",
|
|
515
|
+
description: "rental.section.invoiceTotalsHint",
|
|
516
|
+
fields: ["netTotal", "vatRate", "vatTotal", "grossTotal"],
|
|
517
|
+
},
|
|
518
|
+
],
|
|
519
|
+
},
|
|
520
|
+
submitLabel: "rental.action.receiveInvoice",
|
|
521
|
+
access: OPEN_ACCESS,
|
|
522
|
+
};
|
|
523
|
+
|
|
465
524
|
// Fixtures for the UI-gap screenshots: a confirm-gated primary action that is
|
|
466
525
|
// visible only while the lease is active (a terminated lease falls back to edit
|
|
467
526
|
// as the main button), and a list that is empty because of its default filter.
|
|
@@ -518,3 +577,23 @@ export const leaseSuccessionListScreen: EntityListScreenDefinition = {
|
|
|
518
577
|
rowActions: [openLeaseDetail],
|
|
519
578
|
access: OPEN_ACCESS,
|
|
520
579
|
};
|
|
580
|
+
|
|
581
|
+
export const leaseUnterlagenScreen: ProjectionDetailScreenDefinition = {
|
|
582
|
+
id: "lease-unterlagen",
|
|
583
|
+
type: "projectionDetail",
|
|
584
|
+
dormant: true,
|
|
585
|
+
query: RENTAL_QUERIES.leaseUnterlagen,
|
|
586
|
+
recordTitleField: "titel",
|
|
587
|
+
fieldLabels: { unterlagen: "rental.unterlagen.label" },
|
|
588
|
+
layout: {
|
|
589
|
+
sections: [
|
|
590
|
+
{
|
|
591
|
+
title: "rental.unterlagen.section",
|
|
592
|
+
description: "rental.unterlagen.sectionDescription",
|
|
593
|
+
icon: "file",
|
|
594
|
+
fields: [{ field: "unterlagen", renderer: { format: "file" } }],
|
|
595
|
+
},
|
|
596
|
+
],
|
|
597
|
+
},
|
|
598
|
+
access: OPEN_ACCESS,
|
|
599
|
+
};
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import type { ClientFeatureDefinition } from "@cosmicdrift/kumiko-renderer/web";
|
|
2
2
|
import { rentalTranslations } from "../i18n";
|
|
3
3
|
import { LeaseHubHeader, LeaseHubHistory } from "./lease-hub-components";
|
|
4
|
+
import { UploadStandardLayout } from "./upload-standard-layout";
|
|
4
5
|
|
|
5
6
|
export const rentalClient: ClientFeatureDefinition = {
|
|
6
7
|
name: "rental",
|
|
7
8
|
translations: rentalTranslations,
|
|
9
|
+
components: { "upload-standard-layout": UploadStandardLayout },
|
|
8
10
|
extensionSectionComponents: {
|
|
9
11
|
LeaseHubHeader,
|
|
10
12
|
LeaseHubHistory,
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { FormScreenLayout, usePrimitives, useTranslation } from "@cosmicdrift/kumiko-renderer";
|
|
2
|
+
import { UploadZone, uploadFileRef } from "@cosmicdrift/kumiko-renderer/web";
|
|
3
|
+
import { type ReactNode, useState } from "react";
|
|
4
|
+
|
|
5
|
+
type UploadedReceipt = { readonly id: string; readonly name: string };
|
|
6
|
+
|
|
7
|
+
export function UploadStandardLayout(): ReactNode {
|
|
8
|
+
const t = useTranslation();
|
|
9
|
+
const { Section, Button, Text } = usePrimitives();
|
|
10
|
+
const [uploadedReceipts, setUploadedNames] = useState<readonly UploadedReceipt[]>([]);
|
|
11
|
+
|
|
12
|
+
async function uploadReceipt(file: File): Promise<void> {
|
|
13
|
+
await uploadFileRef(file);
|
|
14
|
+
setUploadedNames((receipts) => [...receipts, { id: crypto.randomUUID(), name: file.name }]);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
return (
|
|
18
|
+
<FormScreenLayout
|
|
19
|
+
testId="upload-standard-layout"
|
|
20
|
+
description={t("rental.upload.description")}
|
|
21
|
+
actions={
|
|
22
|
+
uploadedReceipts.length > 0 ? (
|
|
23
|
+
<Button testId="upload-to-review">{t("rental.upload.toReview")}</Button>
|
|
24
|
+
) : undefined
|
|
25
|
+
}
|
|
26
|
+
>
|
|
27
|
+
<Section
|
|
28
|
+
title={t("rental.upload.zoneTitle")}
|
|
29
|
+
icon="upload"
|
|
30
|
+
subtitle={t("rental.upload.zoneDescription")}
|
|
31
|
+
testId="upload-zone-section"
|
|
32
|
+
>
|
|
33
|
+
<UploadZone
|
|
34
|
+
testId="upload-zone"
|
|
35
|
+
title={t("rental.upload.dropTitle")}
|
|
36
|
+
hint={t("rental.upload.dropHint")}
|
|
37
|
+
accept={["application/pdf", "image/*"]}
|
|
38
|
+
onUpload={uploadReceipt}
|
|
39
|
+
/>
|
|
40
|
+
</Section>
|
|
41
|
+
<Section
|
|
42
|
+
title={t("rental.upload.resultTitle")}
|
|
43
|
+
icon="file"
|
|
44
|
+
subtitle={t("rental.upload.resultDescription")}
|
|
45
|
+
testId="upload-result-section"
|
|
46
|
+
>
|
|
47
|
+
{uploadedReceipts.length === 0 ? (
|
|
48
|
+
<Text variant="muted">{t("rental.upload.empty")}</Text>
|
|
49
|
+
) : (
|
|
50
|
+
<ul data-testid="upload-result-list" className="space-y-2">
|
|
51
|
+
{uploadedReceipts.map(({ id, name }) => (
|
|
52
|
+
<li key={id} className="text-sm">
|
|
53
|
+
{name}
|
|
54
|
+
</li>
|
|
55
|
+
))}
|
|
56
|
+
</ul>
|
|
57
|
+
)}
|
|
58
|
+
</Section>
|
|
59
|
+
</FormScreenLayout>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -9,6 +9,10 @@ export const tasksTranslations: TranslationsByLocale = {
|
|
|
9
9
|
"tasks.assistant.label": "Assistent",
|
|
10
10
|
"tasks.assistant.description": "Der Assistent ist im Walkthrough nicht angebunden.",
|
|
11
11
|
"tasks.assistant.close": "Schließen",
|
|
12
|
+
"tasks.help.label": "Hilfe",
|
|
13
|
+
"tasks.help.description": "Kurzhilfe zur aktuellen Seite.",
|
|
14
|
+
"tasks.help.body":
|
|
15
|
+
"Die Seite bleibt bedienbar: Sie rückt neben das Hilfefenster, statt darunter zu verschwinden.",
|
|
12
16
|
"tasks.nav.group": "Allgemein",
|
|
13
17
|
"tasks.nav.list": "Aufgaben",
|
|
14
18
|
"tasks.nav.new": "Neue Aufgabe",
|
|
@@ -29,6 +33,10 @@ export const tasksTranslations: TranslationsByLocale = {
|
|
|
29
33
|
"tasks.assistant.label": "Assistant",
|
|
30
34
|
"tasks.assistant.description": "The assistant is not connected in the walkthrough.",
|
|
31
35
|
"tasks.assistant.close": "Close",
|
|
36
|
+
"tasks.help.label": "Help",
|
|
37
|
+
"tasks.help.description": "Quick help for the current page.",
|
|
38
|
+
"tasks.help.body":
|
|
39
|
+
"The page stays usable: it moves next to the help panel instead of disappearing under it.",
|
|
32
40
|
"tasks.nav.group": "General",
|
|
33
41
|
"tasks.nav.list": "Tasks",
|
|
34
42
|
"tasks.nav.new": "New task",
|
|
@@ -4,14 +4,20 @@
|
|
|
4
4
|
// from here — migration and runtime cannot drift.
|
|
5
5
|
|
|
6
6
|
import { createAuditFeature } from "@cosmicdrift/kumiko-bundled-features/audit";
|
|
7
|
+
import { AuthMailNotificationTypes } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
|
|
7
8
|
import {
|
|
8
9
|
createChannelEmailFeature,
|
|
9
10
|
createInMemoryTransport,
|
|
10
11
|
} from "@cosmicdrift/kumiko-bundled-features/channel-email";
|
|
11
12
|
import { createDeliveryFeature } from "@cosmicdrift/kumiko-bundled-features/delivery";
|
|
13
|
+
import { createFilesFeature } from "@cosmicdrift/kumiko-bundled-features/files";
|
|
12
14
|
import { createManagedPagesFeature } from "@cosmicdrift/kumiko-bundled-features/managed-pages";
|
|
13
15
|
import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
14
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
createTemplateResolverFeature,
|
|
18
|
+
type TenantEditableTemplate,
|
|
19
|
+
templateEditAccess,
|
|
20
|
+
} from "@cosmicdrift/kumiko-bundled-features/template-resolver";
|
|
15
21
|
import { createDocumentDesignerFeature } from "@cosmicdrift/kumiko-document-designer";
|
|
16
22
|
import { localeDe } from "@cosmicdrift/kumiko-locale-de";
|
|
17
23
|
import { localeEs } from "@cosmicdrift/kumiko-locale-es";
|
|
@@ -23,6 +29,27 @@ import { rentalFeature } from "./features/rental";
|
|
|
23
29
|
import { taskFeature } from "./features/tasks";
|
|
24
30
|
import { vehiclesFeature } from "./features/vehicles";
|
|
25
31
|
|
|
32
|
+
// The walkthrough shows the tenant admin editing these templates; every other template stays with the platform.
|
|
33
|
+
const TENANT_EDITABLE_TEMPLATES: readonly TenantEditableTemplate[] = [
|
|
34
|
+
...[
|
|
35
|
+
"billing:notify:invoice-issued",
|
|
36
|
+
"billing:notify:invoice-corrected",
|
|
37
|
+
"billing:notify:payment-reminder",
|
|
38
|
+
"billing:notify:terms-changed",
|
|
39
|
+
AuthMailNotificationTypes.passwordReset,
|
|
40
|
+
].map((slug) => ({ kind: "mail" as const, slug, access: { roles: ["TenantAdmin"] } })),
|
|
41
|
+
...["billing:invoice", "rental:utility-statement"].map((slug) => ({
|
|
42
|
+
kind: "document-pdf" as const,
|
|
43
|
+
slug,
|
|
44
|
+
access: { roles: ["TenantAdmin"] },
|
|
45
|
+
})),
|
|
46
|
+
...(["mail-layout", "pdf-layout"] as const).map((kind) => ({
|
|
47
|
+
kind,
|
|
48
|
+
slug: "default",
|
|
49
|
+
access: { roles: ["TenantAdmin"] },
|
|
50
|
+
})),
|
|
51
|
+
];
|
|
52
|
+
|
|
26
53
|
export const APP_FEATURES = [
|
|
27
54
|
localeDe(),
|
|
28
55
|
// Spanish mail copy: subscribers whose mails go out in Spanish land on Spanish unsubscribe pages.
|
|
@@ -31,11 +58,13 @@ export const APP_FEATURES = [
|
|
|
31
58
|
rentalFeature,
|
|
32
59
|
vehiclesFeature,
|
|
33
60
|
billingFeature,
|
|
34
|
-
createTemplateResolverFeature(),
|
|
61
|
+
createTemplateResolverFeature({ tenantEditable: TENANT_EDITABLE_TEMPLATES }),
|
|
35
62
|
// Only the branding rail of the layout editor needs managed-pages; the sample
|
|
36
63
|
// serves no public pages, so no host resolves to a tenant.
|
|
37
64
|
createManagedPagesFeature({ resolveApexTenant: () => null }),
|
|
38
|
-
|
|
65
|
+
// The mail editor offers uploads only when the files feature is mounted.
|
|
66
|
+
createFilesFeature(),
|
|
67
|
+
createDocumentDesignerFeature({ access: templateEditAccess(TENANT_EDITABLE_TEMPLATES) }),
|
|
39
68
|
// Mounted individually: composeRendererStack() would add a second template-resolver.
|
|
40
69
|
createRendererFoundationFeature(),
|
|
41
70
|
createRendererMailHtmlFeature({
|
|
@@ -795,7 +795,7 @@
|
|
|
795
795
|
},
|
|
796
796
|
{
|
|
797
797
|
"name": "document-designer",
|
|
798
|
-
"description": "Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`.",
|
|
798
|
+
"description": "Template overview and layout editor: lists the mail, PDF and layout templates of a tenant (platform admins see the system templates and how many tenants customised each) and edits the mail layout and the PDF letterhead in the browser. Mount after template-resolver and, for the branding rail, managed-pages. Client: `documentDesignerClient()`; admin-shell lists the navs with `includeDocumentDesigner`. The tenant overview and the editors are for SystemAdmin only; the `access` option adds roles (normally `templateEditAccess(tenantEditable)` of the template-resolver mount), and the server still enforces per template which of them may save.",
|
|
799
799
|
"toggleableDefault": null,
|
|
800
800
|
"requires": [
|
|
801
801
|
"template-resolver"
|
|
@@ -2602,7 +2602,7 @@
|
|
|
2602
2602
|
},
|
|
2603
2603
|
{
|
|
2604
2604
|
"name": "template-resolver",
|
|
2605
|
-
"description": "Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).\n\nReading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away.\n\nText an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.\n\nHow a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).\n\nA collection is tenant-wide by default. With `ownership: \"user\"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.\n\n`validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only.\n\n`preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted.\n\nImages used by a template (`<img data-resource=\"logo\">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: \"attachment\"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10.\n\nReplaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
|
|
2605
|
+
"description": "Every piece of editable text lives here, in one entity: mail bodies, notification texts, PDF document templates, AI prompts and plain text blocks. What a record is used for is the `kind` (`notification`, `mail`, `mail-layout`, `document-pdf`, `pdf-layout`, `ai-prompt`, `text-block`, `snippet`, `image-snapshot`).\n\nReading is one call — `ctx.templateResolver.resolveTemplate({ tenantId, slug, kind, locale })`. It walks four levels: tenant+locale, system+locale, tenant+fallback-locale, system+fallback-locale. A tenant overrides a system default by simply having its own record; no application code changes. Writing programmatically goes through the `upsertSystem`, `upsertTenant`, `publish` and `archive` write handlers; a SystemAdmin retires or restores a system default with `publishSystem` and `archiveSystem`, and a tenant drops its override with `resetToSystem`. Saving a draft over an active template keeps the active version served and stores the edit beside it (column `draft`); `publish` makes it the served version, `discardDraft` / `discardDraftSystem` throws it away.\n\nEditing templates is for SystemAdmin only. A TenantAdmin edits only the templates the app unlocks at the mount site, `createTemplateResolverFeature({ tenantEditable: [{ kind: \"mail\", slug: \"status-update\", access: { roles: [\"TenantAdmin\"] } }] })`, and every write handler enforces that on the server (override, draft, publish, archive, reset, copy-locale, import, resources, `set`). Each entry carries the `access` roles allowed to edit it, because a bundled feature cannot know the host's roles. Reading and delivery do not depend on it: a tenant override that exists is served. A collection mounted without `access` is SystemAdmin only.\n\nText an editor should be able to change belongs in a collection, declared at mount: `createTemplateResolverFeature({ collections: [{ id, kind, access: { roles }, nav }] })`. It appears in the navigation, and `access` is part of the mount because a bundled feature cannot know the host's roles. Each collection gets its own `<id>-list` / `<id>-item` / `<id>-set` handlers, so the dispatcher enforces the separation.\n\nHow a collection is edited follows from `contentFormat`: `plain` gives a text area, `rich` a small WYSIWYG (bold, italic, headings, lists, links), `markdown` a text area that stores markdown text (same insertable chips as `plain`). All three offer the collection's `variableSchema` as insertable chips and a preview rendered with sample data — an editor sees what `{{firstName}}` becomes without sending a mail. An app can register its own editor for a format; the last `clientFeature` that registers a format wins (a conflict logs a warning).\n\nA collection is tenant-wide by default. With `ownership: \"user\"` every user keeps their own entries — mail signatures being the obvious case. Those rows live in the separate `user-content-entry` entity and count as user data, so mounting one also requires the `template-resolver-user-data` feature and a migration on the app side.\n\n`validate` dry-runs the write pipeline for a draft (sanitizer, layout rules, placeholders) and reports what the template uses and misses. `export-bundle` / `import-bundle` (and `import-bundle-system`) move one template with all locales and resource files between tenants or environments; `copy-locale` starts a translation from an existing locale. Layouts are structured: a mail or PDF layout is `data-header` / `data-content` / `data-signature` / `data-footer` regions, and mjml layout source is system-only.\n\n`preview` renders a draft (or the stored template) with sample values taken from the `example` of each declared variable, through the same gates and the same mail renderer as a real send, and returns subject, html, `displayHtml` (the html with cid images as data URIs for an editor), text, attachments (inline images and files) and `checks` (images resolved, plain text present, size under 10 MiB, required placeholders used); nothing is saved or sent. `send-test` mails that rendering to the calling user's own address only, subject prefixed with `[TEST]`, at most 10 per user and hour; it needs `delivery`, `channel-email` and `renderer-mail-html` to be mounted. `send-test-status` tells an editor beforehand whether that send can work for the caller (`canSend`, else `reason` `no_channel` or `no_address`), never returning the address.\n\nImages used by a template (`<img data-resource=\"logo\">`) are uploaded with `resourceUpload`, which re-encodes them, stores a file and links it under the key; `resourceRemove` unlinks it. `linkedResources` is written only through these two handlers. With `purpose: \"attachment\"` and a `fileName`, `resourceUpload` stores a PDF, PNG or JPEG as a file attachment of a mail template (original bytes, at most 10) that the mail renderer attaches after the inline images. `resourceLinkPdf` instead links a `document-pdf` template under a key: every mail sent from the template then carries that PDF, rendered at send time with the mail's own variables and locale, under a file name pattern such as `invoice-{{number}}.pdf`; files and linked PDFs share the limit of 10.\n\nReplaces the former `text-content` feature; its blocks now live here as kind `text-block`.",
|
|
2606
2606
|
"toggleableDefault": null,
|
|
2607
2607
|
"requires": [],
|
|
2608
2608
|
"optionalRequires": [],
|
|
@@ -8,7 +8,7 @@ Shows how a feature sends notifications to multiple channels (InApp + Email + Pu
|
|
|
8
8
|
|
|
9
9
|
**Mail text is stored data:** The boot seed (`seedNotificationTemplates`) writes the declared `template` and `meta` into the template store as system templates. The email channel renders them with `renderer-mail-html`. A notification routed to email without `template` and `meta` fails its delivery.
|
|
10
10
|
|
|
11
|
-
**Tenant override:**
|
|
11
|
+
**Tenant override:** Only a SystemAdmin edits templates unless the app unlocks them. The app lists the mail in `createTemplateResolverFeature({ tenantEditable: [{ kind: "mail", slug, access: { roles: ["TenantAdmin"] } }] })`; then a tenant admin overrides the text through the template-resolver handlers (`upsertTenant`, then `publish`) and every other template is refused with 403. The next mail for that tenant uses the override; the system text stays for everyone else.
|
|
12
12
|
|
|
13
13
|
**Sending:** The write handler calls `ctx.notify(type, { to, data })`. The `data` keys are the template variables; sending fails loudly when a required variable is missing.
|
|
14
14
|
|
|
@@ -19,6 +19,14 @@ object and an embedded list rendered by a declarative `entityEdit` screen.
|
|
|
19
19
|
`createEmbeddedListField`; its renderer receives `embeddedListCells`, so no
|
|
20
20
|
custom screen component is required.
|
|
21
21
|
|
|
22
|
+
- **Derived head totals** (`src/screens/invoice-receive.screen.ts`): an
|
|
23
|
+
`actionForm` declares `derive` so net, VAT, and gross follow the lines
|
|
24
|
+
(`listSum`), or follow a typed net and VAT rate while the list is empty
|
|
25
|
+
(`whenEmpty`, `percent`). The renderer shows them read-only and live; the
|
|
26
|
+
handler wraps its schema with `withFormDerivations(schema, screen)`
|
|
27
|
+
(`src/handlers/invoice-receive.write.ts`), so totals sent by the client are
|
|
28
|
+
replaced on the server.
|
|
29
|
+
|
|
22
30
|
## When to reach for it
|
|
23
31
|
|
|
24
32
|
Use an embedded field or list when the nested data is created, changed, and
|
|
@@ -15,6 +15,9 @@
|
|
|
15
15
|
// on its `lines` sub-fields (Issue #1835), plus a declarative
|
|
16
16
|
// entityEdit screen that renders the list end-to-end via
|
|
17
17
|
// EmbeddedListField, no custom-screen code required
|
|
18
|
+
// invoice-receive — actionForm whose net/VAT/gross head fields are derived
|
|
19
|
+
// from the lines (`derive`); the handler recomputes them with
|
|
20
|
+
// `withFormDerivations`, so client-sent totals are never trusted
|
|
18
21
|
|
|
19
22
|
import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
|
|
20
23
|
import { contactEntity } from "./entities/contact";
|
|
@@ -22,6 +25,8 @@ import { invoiceEntity } from "./entities/invoice";
|
|
|
22
25
|
import { productEntity } from "./entities/product";
|
|
23
26
|
import { contactCreate } from "./handlers/contact-create.write";
|
|
24
27
|
import { contactDetail } from "./handlers/contact-detail.query";
|
|
28
|
+
import { invoiceReceive } from "./handlers/invoice-receive.write";
|
|
29
|
+
import { invoiceReceiveScreen } from "./screens/invoice-receive.screen";
|
|
25
30
|
|
|
26
31
|
export { contactEntity } from "./entities/contact";
|
|
27
32
|
export { invoiceEntity } from "./entities/invoice";
|
|
@@ -43,6 +48,7 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
|
|
|
43
48
|
|
|
44
49
|
r.writeHandler(contactCreate);
|
|
45
50
|
r.queryHandler(contactDetail);
|
|
51
|
+
r.writeHandler(invoiceReceive);
|
|
46
52
|
|
|
47
53
|
r.crud("product", productEntity, { write: adminWrite, read: openRead });
|
|
48
54
|
r.crud("invoice", invoiceEntity, { write: adminWrite, read: openRead });
|
|
@@ -55,4 +61,6 @@ export const embeddedFeature = defineFeature("contacts", (r) => {
|
|
|
55
61
|
sections: [{ columns: 1, fields: ["customer", "lines"] }],
|
|
56
62
|
},
|
|
57
63
|
});
|
|
64
|
+
|
|
65
|
+
r.screen(invoiceReceiveScreen);
|
|
58
66
|
});
|