@cosmicdrift/kumiko-samples 0.359.0 → 0.361.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 +9 -2
- package/packages/bundled-features/src/audit/changes.json +6 -0
- package/packages/bundled-features/src/audit/handlers/details.query.ts +2 -1
- package/packages/bundled-features/src/audit/handlers/list.query.ts +4 -5
- package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +6 -0
- package/packages/bundled-features/src/auth-email-password/web/session.tsx +39 -3
- package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +4 -4
- package/packages/bundled-features/src/cap-counter/changes.json +6 -0
- package/packages/bundled-features/src/cap-counter/index.ts +1 -0
- package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +11 -6
- package/packages/bundled-features/src/folders-user-data/hooks.ts +4 -2
- package/packages/bundled-features/src/form-draft-user-data/hooks.ts +4 -1
- package/packages/bundled-features/src/jobs/changes.json +6 -0
- package/packages/bundled-features/src/jobs/job-run-logger.ts +23 -10
- package/packages/bundled-features/src/renderer-pdf/README.md +27 -0
- package/packages/bundled-features/src/renderer-pdf/changes.json +8 -0
- package/packages/bundled-features/src/renderer-pdf/feature.ts +48 -0
- package/packages/bundled-features/src/renderer-pdf/index.ts +9 -0
- package/packages/bundled-features/src/renderer-pdf/render-table-pdf.ts +433 -0
- package/packages/bundled-features/src/shared/encrypt-for-direct-write.ts +2 -2
- package/packages/bundled-features/src/shared/index.ts +1 -0
- package/packages/bundled-features/src/shared/redact-pii-ciphertext.ts +16 -0
- package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +7 -2
- package/packages/bundled-features/src/tenant-lifecycle/stages.ts +2 -1
- package/packages/bundled-features/src/user/changes.json +6 -0
- package/packages/bundled-features/src/user/handlers/me.query.ts +5 -1
- package/packages/bundled-features/src/user-data-rights/handlers/my-audit-log.query.ts +2 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/file-ref.userdata-hook.ts +6 -1
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/tenant-invitation.userdata-hook.ts +3 -2
- package/packages/bundled-features/src/user-data-rights-defaults/hooks/user.userdata-hook.ts +3 -3
- package/packages/bundled-features/src/user-profile/changes.json +6 -0
- package/packages/bundled-features/src/user-profile/feature.ts +21 -11
- package/packages/bundled-features/src/user-profile/index.ts +1 -1
- package/samples/apps/styleguide/src/features/examples/feature.ts +52 -3
- package/samples/apps/styleguide/src/features/examples/i18n.ts +3 -0
- package/samples/apps/ui-walkthrough/src/app/seed-rental.ts +3 -2
- package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +11 -2
- package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +17 -5
- package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +12 -1
- package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +46 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/feature.ts +2 -0
- package/samples/apps/ui-walkthrough/src/features/vehicles/i18n.ts +3 -1
- package/samples/apps/ui-walkthrough/src/features/vehicles/screens.ts +18 -0
- package/samples/apps/use-all-bundled/feature-manifest.json +28 -2
- package/samples/apps/use-all-bundled/schema/generate.ts +5 -0
- package/samples/apps/use-all-bundled/src/run-config.ts +2 -0
- package/samples/recipes/files-post-processing/src/feature.ts +11 -1
- package/samples/recipes/pdf-audit-export/README.md +22 -0
- package/samples/recipes/pdf-audit-export/package.json +12 -0
- package/samples/recipes/pdf-audit-export/src/feature.ts +114 -0
|
@@ -12,13 +12,21 @@ import {
|
|
|
12
12
|
} from "./constants.js";
|
|
13
13
|
import { changeEmailWrite } from "./handlers/change-email.write.js";
|
|
14
14
|
|
|
15
|
-
export
|
|
15
|
+
export type UserProfileFeatureOptions = {
|
|
16
|
+
/** Default true. false drops the change-email handler and the email
|
|
17
|
+
* section — for apps whose login email is managed elsewhere (SSO, admin-set). */
|
|
18
|
+
readonly allowEmailChange?: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function createUserProfileFeature(options?: UserProfileFeatureOptions): FeatureDefinition {
|
|
22
|
+
const allowEmailChange = options?.allowEmailChange ?? true;
|
|
16
23
|
return defineFeature("user-profile", (r) => {
|
|
17
24
|
r.describe(
|
|
18
25
|
"Self-service account page (fw#2312: declarative `projectionDetail` bound to " +
|
|
19
26
|
"the signed-in user's own `me` row) plus its `change-email` write handler " +
|
|
20
27
|
"(re-auth via current password, uniqueness check, resets emailVerified and " +
|
|
21
|
-
"expects the app to trigger the verification flow
|
|
28
|
+
"expects the app to trigger the verification flow; opt out with " +
|
|
29
|
+
"`allowEmailChange: false`, which drops handler and section). Change-password and " +
|
|
22
30
|
"change-email stay custom `EditExtensionSection` components (re-auth flows a " +
|
|
23
31
|
"declarative action can't express); account deletion (user-data-rights " +
|
|
24
32
|
"request/cancel with grace period) is declarative fields + actions. Apps " +
|
|
@@ -38,9 +46,7 @@ export function createUserProfileFeature(): FeatureDefinition {
|
|
|
38
46
|
r.requires("user-data-rights");
|
|
39
47
|
r.requires("user-data-rights-defaults");
|
|
40
48
|
|
|
41
|
-
const handlers = {
|
|
42
|
-
changeEmail: r.writeHandler(changeEmailWrite),
|
|
43
|
-
};
|
|
49
|
+
const handlers = allowEmailChange ? { changeEmail: r.writeHandler(changeEmailWrite) } : {};
|
|
44
50
|
|
|
45
51
|
// Boot-validator requires a "screen:<id>.title" key per r.screen(), plus
|
|
46
52
|
// one entry per i18nKey()-marked dot-form label below — kept inline (not
|
|
@@ -100,12 +106,16 @@ export function createUserProfileFeature(): FeatureDefinition {
|
|
|
100
106
|
},
|
|
101
107
|
layout: {
|
|
102
108
|
sections: [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
...(allowEmailChange
|
|
110
|
+
? [
|
|
111
|
+
{
|
|
112
|
+
kind: "extension" as const,
|
|
113
|
+
title: i18nKey("profile.email.title"),
|
|
114
|
+
component: { react: { __component: CHANGE_EMAIL_SECTION_EXTENSION_NAME } },
|
|
115
|
+
entityName: "user",
|
|
116
|
+
},
|
|
117
|
+
]
|
|
118
|
+
: []),
|
|
109
119
|
{
|
|
110
120
|
kind: "extension",
|
|
111
121
|
title: i18nKey("profile.password.title"),
|
|
@@ -37,14 +37,19 @@ export const shippingEntity = createEntity({
|
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
// Profile — testet das Avatar-Image-Upload-Feld (createImageField) in der
|
|
40
|
-
// Auto-Form:
|
|
40
|
+
// Auto-Form: rechteckige Preview + "Change"-Button, Upload an /api/files.
|
|
41
41
|
export const profileEntity = createEntity({
|
|
42
42
|
table: "read_examples_profile",
|
|
43
43
|
fields: {
|
|
44
44
|
avatar: createImageField({ maxSize: "5mb", accept: ["jpg", "jpeg", "png"] }),
|
|
45
45
|
fullName: createTextField({ personal: { of: "id" }, find: "none", required: true }),
|
|
46
46
|
// Demo-Daten, kein echtes PII-Encryption-Setup → Plaintext bewusst erlaubt.
|
|
47
|
-
email: createTextField({
|
|
47
|
+
email: createTextField({
|
|
48
|
+
required: true,
|
|
49
|
+
personal: false,
|
|
50
|
+
reason: "is_business_data",
|
|
51
|
+
sortable: true,
|
|
52
|
+
}),
|
|
48
53
|
bio: createTextField({ personal: { of: "id" }, find: "none", multiline: { rows: 3 } }),
|
|
49
54
|
},
|
|
50
55
|
});
|
|
@@ -105,7 +110,51 @@ export const examplesFeature = defineFeature("examples", (r) => {
|
|
|
105
110
|
},
|
|
106
111
|
});
|
|
107
112
|
|
|
108
|
-
r.crud("profile", profileEntity,
|
|
113
|
+
r.crud("profile", profileEntity, {
|
|
114
|
+
...editFormOnly,
|
|
115
|
+
verbs: { delete: false, restore: false },
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
r.screen({
|
|
119
|
+
id: "profile-list",
|
|
120
|
+
type: "entityList",
|
|
121
|
+
entity: "profile",
|
|
122
|
+
dormant: true,
|
|
123
|
+
columns: ["avatar", "fullName", "email"],
|
|
124
|
+
searchable: true,
|
|
125
|
+
defaultSort: { field: "email", dir: "asc" },
|
|
126
|
+
rowActions: [
|
|
127
|
+
{
|
|
128
|
+
kind: "navigate",
|
|
129
|
+
id: "view",
|
|
130
|
+
label: "examples:profile:view",
|
|
131
|
+
screen: "profile-view",
|
|
132
|
+
entityId: "id",
|
|
133
|
+
rowClick: true,
|
|
134
|
+
},
|
|
135
|
+
],
|
|
136
|
+
access: { openToAll: { reason: openReason } },
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
r.screen({
|
|
140
|
+
id: "profile-view",
|
|
141
|
+
type: "entityEdit",
|
|
142
|
+
entity: "profile",
|
|
143
|
+
dormant: true,
|
|
144
|
+
listScreenId: "profile-list",
|
|
145
|
+
layout: {
|
|
146
|
+
sections: [
|
|
147
|
+
{
|
|
148
|
+
columns: 2,
|
|
149
|
+
fields: [
|
|
150
|
+
{ field: "avatar", span: 2, readOnly: true },
|
|
151
|
+
{ field: "fullName", readOnly: true },
|
|
152
|
+
{ field: "email", readOnly: true },
|
|
153
|
+
],
|
|
154
|
+
},
|
|
155
|
+
],
|
|
156
|
+
},
|
|
157
|
+
});
|
|
109
158
|
|
|
110
159
|
r.screen({
|
|
111
160
|
id: "profile-edit",
|
|
@@ -38,6 +38,9 @@ export const EXAMPLES_I18N: Readonly<Record<string, LocalizedString>> = {
|
|
|
38
38
|
de: "Aktualisiere deine persönlichen Daten und wie andere dich sehen.",
|
|
39
39
|
en: "Update your personal information and how others see you.",
|
|
40
40
|
},
|
|
41
|
+
"screen:profile-list.title": { de: "Profile", en: "Profiles" },
|
|
42
|
+
"screen:profile-view.title": { de: "Profil ansehen", en: "View profile" },
|
|
43
|
+
"examples:profile:view": { de: "Ansehen", en: "View" },
|
|
41
44
|
"examples:profile:submit": { de: "Änderungen speichern", en: "Save changes" },
|
|
42
45
|
"examples:entity:profile:field:avatar": { de: "Avatar", en: "Avatar" },
|
|
43
46
|
"examples:entity:profile:field:fullName": { de: "Vollständiger Name", en: "Full name" },
|
|
@@ -163,6 +163,7 @@ const BOARD_LEASES: readonly BoardLease[] = [
|
|
|
163
163
|
|
|
164
164
|
const MAX_NACHMIETER_INDEX = 2;
|
|
165
165
|
const GENERATED_LEASE_COUNT = 300;
|
|
166
|
+
const TERMINATED_LEASE_ID = "00000000-0000-4000-8000-000000003999";
|
|
166
167
|
const FIRST_NAMES = [
|
|
167
168
|
"Anna",
|
|
168
169
|
"Ben",
|
|
@@ -204,7 +205,7 @@ function generatedLease(index: number): BoardLease & { readonly terminated: bool
|
|
|
204
205
|
einheit: `WE-${String(100 + index)}`,
|
|
205
206
|
liegenschaft: PROPERTIES[index % PROPERTIES.length] ?? "Haus Eichenweg",
|
|
206
207
|
beginn: `${year}-${month}-01`,
|
|
207
|
-
...(terminated && { ende: "
|
|
208
|
+
...(terminated && { ende: "2027-03-31" }),
|
|
208
209
|
miete: 500 + (index % 20) * 15,
|
|
209
210
|
terminated,
|
|
210
211
|
};
|
|
@@ -240,7 +241,7 @@ export const seedRental: SeedFn = async (stack) => {
|
|
|
240
241
|
}
|
|
241
242
|
for (let index = 0; index < GENERATED_LEASE_COUNT; index++) {
|
|
242
243
|
const lease = generatedLease(index);
|
|
243
|
-
await createLease(lease, lease.terminated);
|
|
244
|
+
await createLease(lease, lease.terminated, lease.terminated ? TERMINATED_LEASE_ID : undefined);
|
|
244
245
|
}
|
|
245
246
|
|
|
246
247
|
const maxId = boardLeaseId(MAX_NACHMIETER_INDEX);
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
germanStatusLabel,
|
|
12
12
|
idPayloadSchema,
|
|
13
13
|
leaseExecutor,
|
|
14
|
+
noticePeriodEnd,
|
|
14
15
|
parseLeaseRow,
|
|
15
16
|
partyExecutor,
|
|
16
17
|
positionExecutor,
|
|
@@ -22,9 +23,11 @@ import {
|
|
|
22
23
|
} from "./lease-support";
|
|
23
24
|
import {
|
|
24
25
|
adjustRentScreen,
|
|
26
|
+
leaseDetailGapsScreen,
|
|
25
27
|
leaseDetailScreen,
|
|
26
28
|
leaseEditScreen,
|
|
27
29
|
leaseHubScreen,
|
|
30
|
+
leaseListEmptyScreen,
|
|
28
31
|
leaseListScreen,
|
|
29
32
|
leaseListShortScreen,
|
|
30
33
|
partyEditScreen,
|
|
@@ -86,7 +89,8 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
86
89
|
return {
|
|
87
90
|
id: lease.id,
|
|
88
91
|
mieter: lease.mieter,
|
|
89
|
-
|
|
92
|
+
status: lease.status,
|
|
93
|
+
statusLabel: germanStatusLabel(lease.status, lease.ende),
|
|
90
94
|
grundmiete: formatGermanMoney(lease.grundmiete),
|
|
91
95
|
beginn: formatGermanDate(lease.beginn),
|
|
92
96
|
kuendigungsfrist: lease.kuendigungsfrist ?? "",
|
|
@@ -152,7 +156,10 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
152
156
|
leaseExecutor.update(
|
|
153
157
|
{
|
|
154
158
|
id: event.payload.id,
|
|
155
|
-
changes: {
|
|
159
|
+
changes: {
|
|
160
|
+
status: "terminated",
|
|
161
|
+
ende: noticePeriodEnd(ctx.tz.today(ctx.tz.tenant).toString()),
|
|
162
|
+
},
|
|
156
163
|
},
|
|
157
164
|
event.user,
|
|
158
165
|
ctx.db,
|
|
@@ -232,6 +239,8 @@ export const rentalFeature = defineFeature("rental", (r) => {
|
|
|
232
239
|
r.screen(partyEditScreen);
|
|
233
240
|
r.screen(leaseDetailScreen);
|
|
234
241
|
r.screen(leaseHubScreen);
|
|
242
|
+
r.screen(leaseDetailGapsScreen);
|
|
243
|
+
r.screen(leaseListEmptyScreen);
|
|
235
244
|
r.screen(rentalDashboardScreen);
|
|
236
245
|
r.screen(adjustRentScreen);
|
|
237
246
|
r.screen(recordPositionsScreen);
|
|
@@ -29,15 +29,22 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
29
29
|
"rental.tab.notes": "Notizen",
|
|
30
30
|
|
|
31
31
|
"rental.action.open": "Öffnen",
|
|
32
|
-
"rental.action.terminate": "
|
|
32
|
+
"rental.action.terminate": "Kündigung erfassen",
|
|
33
33
|
"rental.action.editLease": "Vertragsdaten bearbeiten",
|
|
34
|
-
"rental.action.terminateConfirm": "
|
|
34
|
+
"rental.action.terminateConfirm": "Kündigung zum Ende der Kündigungsfrist erfassen?",
|
|
35
35
|
"rental.action.addPosition": "Position hinzufügen",
|
|
36
36
|
"rental.action.adjustRent": "Miete anpassen",
|
|
37
37
|
"rental.adjustRent.hint":
|
|
38
38
|
"Die alte Position endet am Vortag, die neue beginnt am Stichtag. Art, Menge und Einheit bleiben dabei unverändert, so bleibt die Mietgeschichte nachvollziehbar.",
|
|
39
|
+
"rental.leaseListEmpty.description":
|
|
40
|
+
"Gekündigte Verträge, die aktuell als aktiv geführt werden.",
|
|
41
|
+
"rental.leaseListEmpty.emptyTitle": "Alles in Ordnung",
|
|
42
|
+
"rental.leaseListEmpty.emptyDescription":
|
|
43
|
+
"Es gibt keine gekündigten Verträge, die noch als aktiv geführt werden.",
|
|
44
|
+
"screen:lease-detail-gaps.title": "Mietvertrag",
|
|
45
|
+
"screen:lease-list-empty.title": "Mietverträge (Prüfliste)",
|
|
39
46
|
"rental.action.createLease": "Mietvertrag abschließen",
|
|
40
|
-
"rental:entity:lease:noun": { one: "Mietvertrag", other: "
|
|
47
|
+
"rental:entity:lease:noun": { one: "Mietvertrag", other: "Mietverträge" },
|
|
41
48
|
"rental.search.placeholder": "Mieter, Einheit oder Liegenschaft",
|
|
42
49
|
"rental.positions.description": "Alle Positionen dieses Vertrags mit ihrem Verlauf.",
|
|
43
50
|
"rental.positions.noun": { one: "Position", other: "Positionen" },
|
|
@@ -134,13 +141,18 @@ export const rentalTranslations: TranslationsByLocale = {
|
|
|
134
141
|
"rental.tab.notes": "Notes",
|
|
135
142
|
|
|
136
143
|
"rental.action.open": "Open",
|
|
137
|
-
"rental.action.terminate": "
|
|
144
|
+
"rental.action.terminate": "Record notice",
|
|
138
145
|
"rental.action.editLease": "Edit lease details",
|
|
139
|
-
"rental.action.terminateConfirm": "
|
|
146
|
+
"rental.action.terminateConfirm": "Record notice to the end of the notice period?",
|
|
140
147
|
"rental.action.addPosition": "Add position",
|
|
141
148
|
"rental.action.adjustRent": "Adjust rent",
|
|
142
149
|
"rental.adjustRent.hint":
|
|
143
150
|
"The old position ends the day before, the new one starts on the effective date. Type, quantity and unit stay unchanged, so the rent history stays traceable.",
|
|
151
|
+
"rental.leaseListEmpty.description": "Terminated leases that are still marked active.",
|
|
152
|
+
"rental.leaseListEmpty.emptyTitle": "All clear",
|
|
153
|
+
"rental.leaseListEmpty.emptyDescription": "No terminated leases are still marked active.",
|
|
154
|
+
"screen:lease-detail-gaps.title": "Lease",
|
|
155
|
+
"screen:lease-list-empty.title": "Leases (review list)",
|
|
144
156
|
"rental.action.createLease": "Sign lease",
|
|
145
157
|
"rental:entity:lease:noun": { one: "lease", other: "leases" },
|
|
146
158
|
"rental.search.placeholder": "Tenant, unit or property",
|
|
@@ -61,6 +61,7 @@ const leaseRowSchema = z.object({
|
|
|
61
61
|
liegenschaft: z.string().nullish(),
|
|
62
62
|
beginn: readDateSchema,
|
|
63
63
|
status: z.string(),
|
|
64
|
+
ende: readDateSchema.nullish(),
|
|
64
65
|
grundmiete: moneySchema,
|
|
65
66
|
kuendigungsfrist: z.string().nullish(),
|
|
66
67
|
zahltag: z.number().nullish(),
|
|
@@ -90,10 +91,20 @@ const GERMAN_STATUS_LABEL: Readonly<Record<string, string>> = {
|
|
|
90
91
|
terminated: "Gekündigt",
|
|
91
92
|
};
|
|
92
93
|
|
|
93
|
-
export function germanStatusLabel(status: string): string {
|
|
94
|
+
export function germanStatusLabel(status: string, ende?: string | null): string {
|
|
95
|
+
if (status === "terminated" && ende) return `Gekündigt zum ${formatGermanDate(ende)}`;
|
|
94
96
|
return GERMAN_STATUS_LABEL[status] ?? status;
|
|
95
97
|
}
|
|
96
98
|
|
|
99
|
+
// Tenant notice (§ 573c BGB, simplified to the 3rd calendar day instead of the
|
|
100
|
+
// 3rd working day): notice received by then ends the lease after the month after
|
|
101
|
+
// next, later notice one month later.
|
|
102
|
+
export function noticePeriodEnd(isoToday: string): string {
|
|
103
|
+
const today = getTemporal().PlainDate.from(isoToday);
|
|
104
|
+
const target = today.add({ months: today.day <= 3 ? 2 : 3 });
|
|
105
|
+
return target.with({ day: target.daysInMonth }).toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
97
108
|
export function formatGermanDate(isoDate: string): string {
|
|
98
109
|
const [year, month, day] = isoDate.split("-");
|
|
99
110
|
return `${day}.${month}.${year}`;
|
|
@@ -165,7 +165,8 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
|
|
|
165
165
|
{ field: "liegenschaft", navigate: { screen: "lease-list" } },
|
|
166
166
|
],
|
|
167
167
|
status: "statusLabel",
|
|
168
|
-
|
|
168
|
+
statusToneField: "status",
|
|
169
|
+
statusTones: { active: "ok", terminated: "bad" },
|
|
169
170
|
},
|
|
170
171
|
metrics: ["grundmiete", "beginn", "kuendigungsfrist", "zahltag", "mieter"],
|
|
171
172
|
fieldLabels: {
|
|
@@ -286,6 +287,7 @@ export const leaseDetailScreen: ProjectionDetailScreenDefinition = {
|
|
|
286
287
|
handler: RENTAL_WRITES.leaseTerminate,
|
|
287
288
|
payload: { pick: ["id"] },
|
|
288
289
|
confirm: "rental.action.terminateConfirm",
|
|
290
|
+
visible: { field: "status", eq: "active" },
|
|
289
291
|
},
|
|
290
292
|
{
|
|
291
293
|
kind: "navigate",
|
|
@@ -452,3 +454,46 @@ export const recordPositionsScreen: ActionFormScreenDefinition = {
|
|
|
452
454
|
submitLabel: "rental.action.recordPositions",
|
|
453
455
|
access: OPEN_ACCESS,
|
|
454
456
|
};
|
|
457
|
+
|
|
458
|
+
// Fixtures for the UI-gap screenshots: a confirm-gated primary action that is
|
|
459
|
+
// visible only while the lease is active (a terminated lease falls back to edit
|
|
460
|
+
// as the main button), and a list that is empty because of its default filter.
|
|
461
|
+
export const leaseDetailGapsScreen: ProjectionDetailScreenDefinition = {
|
|
462
|
+
...leaseDetailScreen,
|
|
463
|
+
id: "lease-detail-gaps",
|
|
464
|
+
detailFor: "lease",
|
|
465
|
+
primaryAction: "terminate",
|
|
466
|
+
actions: [
|
|
467
|
+
{
|
|
468
|
+
kind: "writeHandler",
|
|
469
|
+
id: "terminate",
|
|
470
|
+
icon: "x",
|
|
471
|
+
label: "rental.action.terminate",
|
|
472
|
+
handler: RENTAL_WRITES.leaseTerminate,
|
|
473
|
+
payload: { pick: ["id"] },
|
|
474
|
+
confirm: "rental.action.terminateConfirm",
|
|
475
|
+
style: "primary",
|
|
476
|
+
visible: { field: "status", eq: "active" },
|
|
477
|
+
},
|
|
478
|
+
],
|
|
479
|
+
};
|
|
480
|
+
|
|
481
|
+
export const leaseListEmptyScreen: EntityListScreenDefinition = {
|
|
482
|
+
id: "lease-list-empty",
|
|
483
|
+
type: "entityList",
|
|
484
|
+
entity: "lease",
|
|
485
|
+
dormant: true,
|
|
486
|
+
description: "rental.leaseListEmpty.description",
|
|
487
|
+
emptyState: {
|
|
488
|
+
title: "rental.leaseListEmpty.emptyTitle",
|
|
489
|
+
description: "rental.leaseListEmpty.emptyDescription",
|
|
490
|
+
},
|
|
491
|
+
columns: [...OPEN_FILL_COLUMNS],
|
|
492
|
+
defaultSort: { field: "beginn", dir: "desc" },
|
|
493
|
+
filter: { field: "status", op: "eq", value: "terminated" },
|
|
494
|
+
defaultFilters: { status: ["active"] },
|
|
495
|
+
createLabel: "rental.action.createLease",
|
|
496
|
+
searchPlaceholder: "rental.search.placeholder",
|
|
497
|
+
rowActions: [openLeaseDetail],
|
|
498
|
+
access: OPEN_ACCESS,
|
|
499
|
+
};
|
|
@@ -13,6 +13,7 @@ import { vehiclesTranslations } from "./i18n";
|
|
|
13
13
|
import {
|
|
14
14
|
campaignEditScreen,
|
|
15
15
|
campaignListScreen,
|
|
16
|
+
vehicleEditFlowScreen,
|
|
16
17
|
vehicleEditScreen,
|
|
17
18
|
vehicleListScreen,
|
|
18
19
|
vehicleWizardScreen,
|
|
@@ -67,6 +68,7 @@ export const vehiclesFeature = defineFeature("vehicles", (r) => {
|
|
|
67
68
|
r.screen(vehicleListScreen);
|
|
68
69
|
r.screen(vehicleEditScreen);
|
|
69
70
|
r.screen(vehicleWizardScreen);
|
|
71
|
+
r.screen(vehicleEditFlowScreen);
|
|
70
72
|
r.screen(campaignListScreen);
|
|
71
73
|
r.screen(campaignEditScreen);
|
|
72
74
|
|
|
@@ -10,6 +10,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
|
|
|
10
10
|
"screen:vehicle-list.title": "Fahrzeuge",
|
|
11
11
|
"screen:vehicle-edit.title": "Angaben bearbeiten",
|
|
12
12
|
"screen:vehicle-wizard.title": "Fahrzeug bearbeiten",
|
|
13
|
+
"screen:vehicle-edit-flow.title": "Ausstattung bearbeiten",
|
|
13
14
|
"screen:campaign-list.title": "Kampagnen",
|
|
14
15
|
|
|
15
16
|
"vehicles.action.edit": "Bearbeiten",
|
|
@@ -17,7 +18,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
|
|
|
17
18
|
"vehicles.action.details": "Details",
|
|
18
19
|
"vehicles.action.saveChanges": "Änderungen speichern",
|
|
19
20
|
"vehicles.search.placeholder": "Fahrzeug suchen",
|
|
20
|
-
"vehicles:entity:vehicle:noun": { one: "Fahrzeug", other: "
|
|
21
|
+
"vehicles:entity:vehicle:noun": { one: "Fahrzeug", other: "Fahrzeuge" },
|
|
21
22
|
"vehicles:entity:campaign:noun": { one: "Kampagne", other: "Kampagnen" },
|
|
22
23
|
|
|
23
24
|
"vehicles.section.basics": "Basisdaten",
|
|
@@ -119,6 +120,7 @@ export const vehiclesTranslations: TranslationsByLocale = {
|
|
|
119
120
|
"screen:vehicle-list.title": "Vehicles",
|
|
120
121
|
"screen:vehicle-edit.title": "Edit details",
|
|
121
122
|
"screen:vehicle-wizard.title": "Edit vehicle",
|
|
123
|
+
"screen:vehicle-edit-flow.title": "Edit equipment",
|
|
122
124
|
"screen:campaign-list.title": "Campaigns",
|
|
123
125
|
|
|
124
126
|
"vehicles.action.edit": "Edit",
|
|
@@ -92,6 +92,24 @@ export const vehicleEditScreen: EntityEditScreenDefinition = {
|
|
|
92
92
|
access: OPEN_ACCESS,
|
|
93
93
|
};
|
|
94
94
|
|
|
95
|
+
export const vehicleEditFlowScreen: EntityEditScreenDefinition = {
|
|
96
|
+
id: "vehicle-edit-flow",
|
|
97
|
+
type: "entityEdit",
|
|
98
|
+
entity: "vehicle",
|
|
99
|
+
dormant: true,
|
|
100
|
+
listScreenId: "vehicle-list",
|
|
101
|
+
recordTitleField: "modell",
|
|
102
|
+
layout: {
|
|
103
|
+
sections: [
|
|
104
|
+
{
|
|
105
|
+
title: "vehicles.section.equipment",
|
|
106
|
+
fields: ["marke", "modell", { field: "ausstattung", span: 2 }],
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
access: OPEN_ACCESS,
|
|
111
|
+
};
|
|
112
|
+
|
|
95
113
|
export const vehicleWizardScreen: EntityEditScreenDefinition = {
|
|
96
114
|
id: "vehicle-wizard",
|
|
97
115
|
type: "entityEdit",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"source": "samples/apps/use-all-bundled APP_FEATURES (composeFeatures includeBundled + signup)",
|
|
3
|
-
"featureCount":
|
|
3
|
+
"featureCount": 82,
|
|
4
4
|
"features": [
|
|
5
5
|
{
|
|
6
6
|
"name": "admin-shell",
|
|
@@ -2072,6 +2072,32 @@
|
|
|
2072
2072
|
"recommended": false
|
|
2073
2073
|
}
|
|
2074
2074
|
},
|
|
2075
|
+
{
|
|
2076
|
+
"name": "renderer-pdf",
|
|
2077
|
+
"description": "Renderer plugin for `kind=\"document-pdf\"` that draws a table document (title, meta lines, weighted columns, repeating header, page footer) as a PDF in pure JavaScript, without a browser or Puppeteer service. `payload.variables` must be a `PdfTableDocument`. Requires `renderer-foundation`; mount a Puppeteer-based plugin instead when you need HTML/CSS layouts.",
|
|
2078
|
+
"toggleableDefault": null,
|
|
2079
|
+
"requires": [
|
|
2080
|
+
"renderer-foundation"
|
|
2081
|
+
],
|
|
2082
|
+
"optionalRequires": [],
|
|
2083
|
+
"configReads": [],
|
|
2084
|
+
"exposesApis": [],
|
|
2085
|
+
"usesApis": [],
|
|
2086
|
+
"extensionsUsed": [
|
|
2087
|
+
{
|
|
2088
|
+
"extensionName": "renderer",
|
|
2089
|
+
"entityName": "pdf"
|
|
2090
|
+
}
|
|
2091
|
+
],
|
|
2092
|
+
"configKeys": [],
|
|
2093
|
+
"secrets": [],
|
|
2094
|
+
"writeHandlers": [],
|
|
2095
|
+
"uiHints": {
|
|
2096
|
+
"displayLabel": "Renderer · PDF tables",
|
|
2097
|
+
"category": "notifications",
|
|
2098
|
+
"recommended": false
|
|
2099
|
+
}
|
|
2100
|
+
},
|
|
2075
2101
|
{
|
|
2076
2102
|
"name": "renderer-simple",
|
|
2077
2103
|
"description": "Default renderer plugin for `kind=\"notification\"`: takes a structured `EmailTemplateData` variable map (with `header`, `sections[]` of text/button objects, and optional `footer`; falls back to `title`/`body` if no structured fields are present) and returns rendered HTML with inline CSS. Requires `renderer-foundation`; sufficient for plain notification emails — swap it for `renderer-mail-html` if you need MJML/Markdown layouts.",
|
|
@@ -3054,7 +3080,7 @@
|
|
|
3054
3080
|
},
|
|
3055
3081
|
{
|
|
3056
3082
|
"name": "user-profile",
|
|
3057
|
-
"description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
|
|
3083
|
+
"description": "Self-service account page (fw#2312: declarative `projectionDetail` bound to the signed-in user's own `me` row) plus its `change-email` write handler (re-auth via current password, uniqueness check, resets emailVerified and expects the app to trigger the verification flow; opt out with `allowEmailChange: false`, which drops handler and section). Change-password and change-email stay custom `EditExtensionSection` components (re-auth flows a declarative action can't express); account deletion (user-data-rights request/cancel with grace period) is declarative fields + actions. Apps place the screen (id \"profile\") in their logged-in area via r.nav — no `type: \"custom\"` / `__component` registration needed on the app side anymore. Requires `user`, `auth-email-password`, `user-data-rights`, and `user-data-rights-defaults` (so the GDPR boot-validator finds export/delete hooks for `user`'s PII fields once user-data-rights is mounted).",
|
|
3058
3084
|
"toggleableDefault": null,
|
|
3059
3085
|
"requires": [
|
|
3060
3086
|
"user",
|
|
@@ -322,6 +322,11 @@ const FEATURE_IMPORT_REGISTRY: Record<string, FeatureImport> = {
|
|
|
322
322
|
path: "@cosmicdrift/kumiko-bundled-features/renderer-simple",
|
|
323
323
|
factory: "createRendererSimpleFeature",
|
|
324
324
|
},
|
|
325
|
+
"renderer-pdf": {
|
|
326
|
+
kind: "factory",
|
|
327
|
+
path: "@cosmicdrift/kumiko-bundled-features/renderer-pdf",
|
|
328
|
+
factory: "createRendererPdfFeature",
|
|
329
|
+
},
|
|
325
330
|
"rate-limiting": {
|
|
326
331
|
kind: "factory",
|
|
327
332
|
path: "@cosmicdrift/kumiko-bundled-features/rate-limiting",
|
|
@@ -76,6 +76,7 @@ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-f
|
|
|
76
76
|
import { securityBaselineFeatures } from "@cosmicdrift/kumiko-bundled-features/presets";
|
|
77
77
|
import { readinessFeature } from "@cosmicdrift/kumiko-bundled-features/readiness";
|
|
78
78
|
import { createRendererFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-foundation";
|
|
79
|
+
import { createRendererPdfFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-pdf";
|
|
79
80
|
import { createRendererSimpleFeature } from "@cosmicdrift/kumiko-bundled-features/renderer-simple";
|
|
80
81
|
import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
|
|
81
82
|
import { createSeoFeature } from "@cosmicdrift/kumiko-bundled-features/seo";
|
|
@@ -336,6 +337,7 @@ export const APP_FEATURES = [
|
|
|
336
337
|
collectionLabelsFeature,
|
|
337
338
|
createRendererFoundationFeature(),
|
|
338
339
|
createRendererSimpleFeature(),
|
|
340
|
+
createRendererPdfFeature(),
|
|
339
341
|
|
|
340
342
|
// managed-pages: requires config (auto-bundled). Smoke resolver never serves
|
|
341
343
|
// (boot-only). allowCustomCss:true boot-validates the CSS code path + emits
|
|
@@ -21,6 +21,9 @@ import type { DerivativePublicPredicateArgs } from "@cosmicdrift/kumiko-bundled-
|
|
|
21
21
|
import {
|
|
22
22
|
createEntity,
|
|
23
23
|
createImageField,
|
|
24
|
+
defineEntityCreateHandler,
|
|
25
|
+
defineEntityDetailHandler,
|
|
26
|
+
defineEntityUpdateHandler,
|
|
24
27
|
defineFeature,
|
|
25
28
|
EXT_DERIVATIVE_PUBLIC_PREDICATE,
|
|
26
29
|
} from "@cosmicdrift/kumiko-framework/engine";
|
|
@@ -32,7 +35,7 @@ import { z } from "zod";
|
|
|
32
35
|
// the public route's fixed presets (thumb/card/hero/full, entry point 3)
|
|
33
36
|
// share the name `thumb` but are a completely separate spec — same label,
|
|
34
37
|
// different whitelist, different actual pixels.
|
|
35
|
-
const photoEntity = createEntity({
|
|
38
|
+
export const photoEntity = createEntity({
|
|
36
39
|
table: "files_post_processing_photos",
|
|
37
40
|
fields: {
|
|
38
41
|
original: createImageField({
|
|
@@ -59,6 +62,13 @@ export const filesPostProcessingFeature = defineFeature("files-post-processing",
|
|
|
59
62
|
r.requires("file-derivatives");
|
|
60
63
|
r.entity("photo", photoEntity);
|
|
61
64
|
|
|
65
|
+
// Uploading a file to a photo's field needs the same rights as saving the
|
|
66
|
+
// photo, so the photo must exist and the uploader needs update access.
|
|
67
|
+
const photoAccess = { access: { roles: ["Admin"] } } as const;
|
|
68
|
+
r.writeHandler(defineEntityCreateHandler("photo", photoEntity, photoAccess));
|
|
69
|
+
r.writeHandler(defineEntityUpdateHandler("photo", photoEntity, photoAccess));
|
|
70
|
+
r.queryHandler(defineEntityDetailHandler("photo", photoEntity, photoAccess));
|
|
71
|
+
|
|
62
72
|
// Entry point 2 — imperative. A real app would gate this behind real
|
|
63
73
|
// roles; `openToAll` keeps the recipe's HTTP wiring to one line.
|
|
64
74
|
r.writeHandler(
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# PDF Audit Export
|
|
2
|
+
|
|
3
|
+
A write handler exports tenant data as a PDF and keeps it as a FileRef.
|
|
4
|
+
|
|
5
|
+
## What it shows
|
|
6
|
+
|
|
7
|
+
- `renderTablePdf` (`@cosmicdrift/kumiko-bundled-features/renderer-pdf`) draws a table document in pure JavaScript: repeating header, weighted columns, page footer. The handler passes ready-formatted strings.
|
|
8
|
+
- `createFileRefFromBytes` (`@cosmicdrift/kumiko-framework/files`) stores the bytes like an upload (validation, tenant-prefixed storage key, `fileRef.created` event); the tenant comes from the caller.
|
|
9
|
+
- The result is a normal FileRef: `GET /api/files/:id` serves it with `Content-Type: application/pdf`, other tenants get 404.
|
|
10
|
+
|
|
11
|
+
## Source
|
|
12
|
+
|
|
13
|
+
Feature entry point: `src/feature.ts`. The export reads at most 200 inspections per call.
|
|
14
|
+
|
|
15
|
+
## Tests
|
|
16
|
+
|
|
17
|
+
Needs a running Postgres and `TEST_DATABASE_URL` set (e.g. `postgres://postgres:postgres@127.0.0.1:5432/postgres`, see `demo/.env.example`).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
cd samples/recipes/pdf-audit-export
|
|
21
|
+
bun test
|
|
22
|
+
```
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@cosmicdrift/kumiko-sample-pdf-audit-export",
|
|
3
|
+
"description": "Exports an audit table as a PDF: a write handler renders rows with renderTablePdf and stores the bytes as a FileRef via createFileRefFromBytes.",
|
|
4
|
+
"private": true,
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"@cosmicdrift/kumiko-bundled-features": "workspace:*",
|
|
7
|
+
"@cosmicdrift/kumiko-framework": "workspace:*"
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"unpdf": "^1.8.1"
|
|
11
|
+
}
|
|
12
|
+
}
|