@svgrid/enterprise 1.2.0 → 2.0.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 (159) hide show
  1. package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
  2. package/dist/designer/assets/index-Dp44bTid.js +939 -0
  3. package/dist/designer/assets/index-RJp6x8tw.css +1 -0
  4. package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
  5. package/dist/designer/index.html +13 -0
  6. package/dist/node/studio.js +22554 -0
  7. package/package.json +10 -3
  8. package/src/SvAuthGate.svelte +115 -0
  9. package/src/SvBoard.dom.test.ts +67 -0
  10. package/src/SvBoard.svelte +192 -0
  11. package/src/SvExportMenu.svelte +553 -0
  12. package/src/SvFileInput.svelte +113 -0
  13. package/src/SvGridEditPanel.dom.test.ts +146 -0
  14. package/src/SvGridEditPanel.svelte +793 -0
  15. package/src/SvGridMasterDetail.svelte +95 -0
  16. package/src/SvImportDialog.svelte +1020 -0
  17. package/src/SvLookupInput.svelte +180 -0
  18. package/src/SvRecordDetail.dom.test.ts +137 -0
  19. package/src/SvRecordDetail.svelte +288 -0
  20. package/src/SvSchedule.dom.test.ts +57 -0
  21. package/src/SvSchedule.svelte +156 -0
  22. package/src/SvSchemaChart.svelte +233 -0
  23. package/src/SvSchemaDashboard.svelte +130 -0
  24. package/src/ai-export-pdf.test.ts +74 -0
  25. package/src/ai-export-xlsx.test.ts +87 -0
  26. package/src/ai-export.test.ts +110 -0
  27. package/src/ai.ts +1188 -782
  28. package/src/edit-panel.test.ts +213 -0
  29. package/src/edit-panel.ts +228 -0
  30. package/src/export-conditional.test.ts +60 -0
  31. package/src/export-conditional.ts +94 -0
  32. package/src/export-ooxml.test.ts +152 -0
  33. package/src/export-ooxml.ts +485 -0
  34. package/src/export-pdf.test.ts +138 -0
  35. package/src/export-pdf.ts +245 -0
  36. package/src/export-print.test.ts +66 -0
  37. package/src/export-print.ts +132 -0
  38. package/src/export-serialize.test.ts +56 -0
  39. package/src/export-serialize.ts +24 -0
  40. package/src/export-xls.ts +196 -0
  41. package/src/export-xlsx-roundtrip.test.ts +120 -0
  42. package/src/export-xlsx.test.ts +150 -0
  43. package/src/export.test.ts +349 -0
  44. package/src/export.ts +1452 -549
  45. package/src/import-automap.test.ts +131 -0
  46. package/src/import-hardening.test.ts +158 -0
  47. package/src/import.ts +1042 -648
  48. package/src/index.ts +282 -0
  49. package/src/install.ts +134 -114
  50. package/src/license-core.test.ts +23 -0
  51. package/src/license-core.ts +35 -0
  52. package/src/license.ts +103 -90
  53. package/src/master-detail.test.ts +61 -0
  54. package/src/master-detail.ts +42 -0
  55. package/src/print.ts +107 -127
  56. package/src/schema-designer.test.ts +121 -0
  57. package/src/schema-designer.ts +142 -0
  58. package/src/schema.test.ts +268 -0
  59. package/src/schema.ts +499 -0
  60. package/src/smart-shim.ts +107 -105
  61. package/src/sources/aggregate.test.ts +79 -0
  62. package/src/sources/aggregate.ts +102 -0
  63. package/src/sources/auth-supabase.test.ts +80 -0
  64. package/src/sources/auth-supabase.ts +87 -0
  65. package/src/sources/dashboard.kpi.test.ts +50 -0
  66. package/src/sources/dashboard.test.ts +61 -0
  67. package/src/sources/dashboard.ts +136 -0
  68. package/src/sources/field-inference.test.ts +60 -0
  69. package/src/sources/field-inference.ts +63 -0
  70. package/src/sources/filters.test.ts +58 -0
  71. package/src/sources/filters.ts +64 -0
  72. package/src/sources/index.ts +67 -0
  73. package/src/sources/introspect-supabase.test.ts +100 -0
  74. package/src/sources/introspect-supabase.ts +119 -0
  75. package/src/sources/realtime-supabase.test.ts +93 -0
  76. package/src/sources/realtime-supabase.ts +99 -0
  77. package/src/sources/relation-lookup.test.ts +99 -0
  78. package/src/sources/relation-lookup.ts +128 -0
  79. package/src/sources/rest-adapters.test.ts +95 -0
  80. package/src/sources/rest-adapters.ts +120 -0
  81. package/src/sources/rest.test.ts +104 -0
  82. package/src/sources/rest.ts +129 -0
  83. package/src/sources/schema-from-columns.test.ts +106 -0
  84. package/src/sources/schema-from-columns.ts +88 -0
  85. package/src/sources/supabase.test.ts +110 -0
  86. package/src/sources/supabase.ts +99 -0
  87. package/src/sources/with-entity-rules.test.ts +104 -0
  88. package/src/sources/with-entity-rules.ts +78 -0
  89. package/src/sources/with-relation-labels.test.ts +75 -0
  90. package/src/sources/with-relation-labels.ts +73 -0
  91. package/src/studio/bug-report.test.ts +101 -0
  92. package/src/studio/bug-report.ts +165 -0
  93. package/src/studio/cli.test.ts +187 -0
  94. package/src/studio/cli.ts +112 -0
  95. package/src/studio/csv.test.ts +90 -0
  96. package/src/studio/csv.ts +165 -0
  97. package/src/studio/db-connect-string.test.ts +94 -0
  98. package/src/studio/db-connect-string.ts +128 -0
  99. package/src/studio/emit-project.test.ts +923 -0
  100. package/src/studio/emit-project.ts +1273 -0
  101. package/src/studio/emit-schema.test.ts +94 -0
  102. package/src/studio/emit-schema.ts +920 -0
  103. package/src/studio/index.ts +195 -0
  104. package/src/studio/introspect-db.test.ts +186 -0
  105. package/src/studio/introspect-db.ts +312 -0
  106. package/src/studio/introspect-prisma.test.ts +99 -0
  107. package/src/studio/introspect-prisma.ts +175 -0
  108. package/src/studio/introspect.test.ts +172 -0
  109. package/src/studio/introspect.ts +310 -0
  110. package/src/studio/pipeline.test.ts +42 -0
  111. package/src/studio/project-robust.test.ts +157 -0
  112. package/src/studio/project.test.ts +473 -0
  113. package/src/studio/project.ts +916 -0
  114. package/src/studio/sample-data.test.ts +58 -0
  115. package/src/studio/sample-data.ts +200 -0
  116. package/src/studio/samples/ats.ts +202 -0
  117. package/src/studio/samples/clinic.ts +177 -0
  118. package/src/studio/samples/crm.ts +250 -0
  119. package/src/studio/samples/ecommerce.ts +187 -0
  120. package/src/studio/samples/events.ts +199 -0
  121. package/src/studio/samples/fleet.ts +184 -0
  122. package/src/studio/samples/gym.ts +213 -0
  123. package/src/studio/samples/hr.ts +193 -0
  124. package/src/studio/samples/index.ts +55 -0
  125. package/src/studio/samples/insurance.ts +195 -0
  126. package/src/studio/samples/inventory.ts +182 -0
  127. package/src/studio/samples/invoicing.ts +162 -0
  128. package/src/studio/samples/library.ts +180 -0
  129. package/src/studio/samples/live-data.test.ts +98 -0
  130. package/src/studio/samples/live-data.ts +308 -0
  131. package/src/studio/samples/projects.ts +190 -0
  132. package/src/studio/samples/realestate.ts +196 -0
  133. package/src/studio/samples/restaurant.ts +187 -0
  134. package/src/studio/samples/samples.test.ts +208 -0
  135. package/src/studio/samples/school.ts +197 -0
  136. package/src/studio/samples/seed-floor.test.ts +25 -0
  137. package/src/studio/samples/shared.ts +305 -0
  138. package/src/studio/samples/subscriptions.ts +183 -0
  139. package/src/studio/samples/support.ts +182 -0
  140. package/src/studio/scaffold-app.test.ts +91 -0
  141. package/src/studio/scaffold-app.ts +161 -0
  142. package/src/studio/scaffold.test.ts +178 -0
  143. package/src/studio/scaffold.ts +374 -0
  144. package/src/studio/themes.ts +172 -0
  145. package/src/studio/verify.test.ts +47 -0
  146. package/src/studio/verify.ts +79 -0
  147. package/src/sveltekit/in-memory.test.ts +104 -0
  148. package/src/sveltekit/in-memory.ts +129 -0
  149. package/src/sveltekit/index.ts +23 -0
  150. package/src/sveltekit/query-plan.test.ts +109 -0
  151. package/src/sveltekit/query-plan.ts +125 -0
  152. package/src/sveltekit/sql-source.test.ts +185 -0
  153. package/src/sveltekit/sql-source.ts +166 -0
  154. package/src/sveltekit/sql.test.ts +80 -0
  155. package/src/sveltekit/sql.ts +131 -0
  156. package/src/sveltekit/transport.test.ts +175 -0
  157. package/src/sveltekit/transport.ts +254 -0
  158. package/src/sveltekit/types.ts +11 -0
  159. package/src/upgrade-prompt.ts +149 -148
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Shared helpers for the curated sample apps. A `SampleApp` is a ready-made
3
+ * `StudioProject` (entities + screens + curated seed + theme) that a user loads
4
+ * to see a beautiful, working multi-entity app in a couple of clicks.
5
+ *
6
+ * Pure + node-safe (studio subtree): each sample is plain data built from the
7
+ * same immutable ops the designer uses, so it round-trips through
8
+ * parse/serialize and generates a runnable app with no special-casing.
9
+ */
10
+ import type { EntitySchema } from '../../schema.js'
11
+ import type { ChartType } from '@svgrid/grid'
12
+ import { generateValue } from '../sample-data.js'
13
+ import { defaultBlockConfig, sanitizeProject, screenFromTemplate, type Block, type BlockConfig, type DetailRelated, type FormatRule, type GridConfig, type GridDensity, type KpiFormat, type Reduce, type RowAction, type RowLink, type Screen, type ScreenTemplate, type StudioProject, type EntityDataSource } from '../project.js'
14
+
15
+ type Row = Record<string, unknown>
16
+
17
+ /**
18
+ * Extend a curated seed to `target` rows so grids scroll and charts / KPIs look
19
+ * substantial (real dashboards have data). The hand-authored rows stay first;
20
+ * extra rows get realistic generated values, valid ids, and foreign keys drawn
21
+ * from `fkPools` (relation field -> list of parent ids).
22
+ */
23
+ export function pad(schema: EntitySchema, rows: Row[], target: number, fkPools: Record<string, string[]> = {}): Row[] {
24
+ if (rows.length >= target) return rows
25
+ const pk = schema.idField ?? schema.fields.find((f) => f.primaryKey)?.field ?? 'id'
26
+ const prefix = String(rows[0]?.[pk] ?? 'r').replace(/\d+$/, '') || 'r'
27
+ // Title-like text fields (a record's display name) must stay ON-DOMAIN - the
28
+ // generic generator would put "Premium Backpack" on a property board. So cycle
29
+ // the entity's own curated seed values (with a numeric suffix on wrap) instead.
30
+ // Exclude identifier-shaped fields (ref / number / sku / code) which have formats.
31
+ const firstText = schema.fields.find((f) => f.type === 'text' && f.field !== pk && !/id$|ref|no$|number|code|sku/i.test(f.field))?.field
32
+ const titleLike = new Set(schema.fields.filter((f) => f.type === 'text' && (/^(name|title|subject|headline|label)$/i.test(f.field) || f.field === firstText)).map((f) => f.field))
33
+ const seedVals = (field: string) => rows.map((r) => r[field]).filter((v) => v != null && v !== '')
34
+ const out = [...rows]
35
+ for (let i = rows.length; i < target; i++) {
36
+ const row: Row = {}
37
+ for (const f of schema.fields) {
38
+ if (f.field === pk) { row[f.field] = `${prefix}${i + 1}`; continue }
39
+ if (f.computed || f.formula) continue // derived at runtime - never seeded
40
+ if (f.type === 'relation') {
41
+ const pool = fkPools[f.field] ?? []
42
+ row[f.field] = pool.length ? pool[i % pool.length] : (rows[i % rows.length]?.[f.field] ?? '')
43
+ continue
44
+ }
45
+ if (titleLike.has(f.field)) {
46
+ const vals = seedVals(f.field)
47
+ if (vals.length) { const round = Math.floor(i / vals.length); row[f.field] = round === 0 ? vals[i] : `${vals[i % vals.length]} ${round + 1}`; continue }
48
+ }
49
+ row[f.field] = generateValue(f, i)
50
+ }
51
+ out.push(row)
52
+ }
53
+ return out
54
+ }
55
+
56
+ /** The `id` values of a seed array (for building foreign-key pools). */
57
+ export const ids = (rows: Row[]): string[] => rows.map((r) => String(r.id))
58
+
59
+ /** A ready-made app shown in the gallery. */
60
+ export type SampleApp = {
61
+ /** Stable id (also the `--template` value). */
62
+ id: string
63
+ /** Display name, e.g. "CRM". */
64
+ name: string
65
+ /** One-line description for the gallery card. */
66
+ description: string
67
+ /** A single emoji used as the card icon. */
68
+ emoji: string
69
+ /** Accent color (themes the built app). */
70
+ accent: string
71
+ /** Build the full project (fresh each call). */
72
+ build: () => StudioProject
73
+ }
74
+
75
+ /** Rich-grid options an enterprise screen layers onto the default grid: status
76
+ * pills / thresholds (formatRules), a totals row, row actions, row-click drill,
77
+ * and density. */
78
+ export type GridOpts = {
79
+ format?: FormatRule[]
80
+ rowActions?: RowAction[]
81
+ rowLink?: RowLink
82
+ summaries?: boolean
83
+ density?: GridDensity
84
+ pageSize?: number
85
+ }
86
+
87
+ /** A dashboard tile: KPI card (with trend/target/format), chart, gauge, pivot,
88
+ * tree, a rich grid, or a tabbed group of display tiles. */
89
+ export type Tile =
90
+ | { kpi: string; measure?: string; reduce: Reduce; format?: KpiFormat; trendField?: string; trendReduce?: Reduce; target?: number; span?: 1 | 2 | 3 }
91
+ | { chart: string; measure?: string; reduce?: Reduce; type?: ChartType; span?: 1 | 2 | 3 }
92
+ | { gauge: string; measure?: string; reduce: Reduce; min?: number; max?: number; unit?: string; span?: 1 | 2 | 3 }
93
+ | { pivot: { rows: string[]; cols: string[]; measure?: string; aggregate?: Reduce }; span?: 1 | 2 | 3 }
94
+ | { tree: { labelField: string; parentField: string }; span?: 1 | 2 | 3 }
95
+ | ({ grid: true; span?: 1 | 2 | 3 } & GridOpts)
96
+ | { filter: string[]; span?: 1 | 2 | 3 }
97
+ | { tabs: { label: string; tiles: Tile[] }[]; span?: 1 | 2 | 3 }
98
+
99
+ /** The default grid config with enterprise options merged on. */
100
+ function gridConfig(entity: EntitySchema, opts: GridOpts): BlockConfig {
101
+ const base = defaultBlockConfig('grid', entity) as GridConfig
102
+ return {
103
+ ...base,
104
+ ...(opts.format ? { formatRules: opts.format } : {}),
105
+ ...(opts.rowActions ? { rowActions: opts.rowActions } : {}),
106
+ ...(opts.rowLink ? { rowLink: opts.rowLink } : {}),
107
+ ...(opts.summaries ? { rowSummaries: true } : {}),
108
+ ...(opts.density ? { density: opts.density } : {}),
109
+ ...(opts.pageSize ? { pageSize: opts.pageSize } : {}),
110
+ }
111
+ }
112
+
113
+ /** One dashboard tile -> a Block. Recurses for `tabs` (whose children must be
114
+ * display blocks: chart / kpi / gauge / pivot / tree). */
115
+ function tileBlock(entity: EntitySchema, t: Tile, i: number): Block {
116
+ const id = `blk-${i + 1}`
117
+ if ('kpi' in t) {
118
+ const config: BlockConfig = {
119
+ kind: 'kpi', label: t.kpi, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce,
120
+ ...(t.format ? { format: t.format } : {}), ...(t.trendField ? { trendField: t.trendField } : {}),
121
+ ...(t.trendReduce ? { trendReduce: t.trendReduce } : {}), ...(t.target != null ? { target: t.target } : {}),
122
+ }
123
+ return { id, span: t.span ?? 1, config }
124
+ }
125
+ if ('chart' in t) {
126
+ return { id, span: t.span ?? 2, config: { kind: 'chart', dimension: t.chart, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce ?? 'sum', type: t.type ?? 'bar' } }
127
+ }
128
+ if ('gauge' in t) {
129
+ return { id, span: t.span ?? 1, config: { kind: 'gauge', label: t.gauge, ...(t.measure ? { measure: t.measure } : {}), reduce: t.reduce, min: t.min ?? 0, max: t.max ?? 100, ...(t.unit ? { unit: t.unit } : {}) } }
130
+ }
131
+ if ('pivot' in t) {
132
+ return { id, span: t.span ?? 3, config: { kind: 'pivot', rows: t.pivot.rows, cols: t.pivot.cols, ...(t.pivot.measure ? { measure: t.pivot.measure } : {}), aggregate: t.pivot.aggregate ?? 'sum' } }
133
+ }
134
+ if ('tree' in t) {
135
+ return { id, span: t.span ?? 1, config: { kind: 'tree', labelField: t.tree.labelField, parentField: t.tree.parentField } }
136
+ }
137
+ if ('filter' in t) {
138
+ return { id, span: t.span ?? 3, config: { kind: 'filter', fields: t.filter } }
139
+ }
140
+ if ('tabs' in t) {
141
+ return { id, span: t.span ?? 3, config: { kind: 'tabs', tabs: t.tabs.map((tab) => ({ label: tab.label, blocks: tab.tiles.map((tt, j) => tileBlock(entity, tt, j)) })) } }
142
+ }
143
+ return { id, span: t.span ?? 3, config: gridConfig(entity, t) }
144
+ }
145
+
146
+ /** Compose a dashboard screen from explicit tiles (KPI cards + gauges + charts +
147
+ * pivots + trees + a rich grid). */
148
+ export function dashScreen(
149
+ entity: EntitySchema,
150
+ meta: { id: string; title: string; order: number },
151
+ tiles: Tile[],
152
+ ): Screen {
153
+ const blocks = tiles.map((t, i) => tileBlock(entity, t, i))
154
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
155
+ }
156
+
157
+ /** FormatRule[] that color-code an enum field's cells by its option colors -
158
+ * turns a plain status column into enterprise status pills. */
159
+ export function statusPills(entity: EntitySchema, field: string): FormatRule[] {
160
+ const f = entity.fields.find((x) => x.field === field)
161
+ if (!f?.options) return []
162
+ return f.options
163
+ .filter((o) => o.color)
164
+ .map((o) => ({ field, op: 'eq' as const, value: o.value as string | number, background: `color-mix(in srgb, ${o.color} 18%, transparent)`, color: o.color, bold: true }))
165
+ }
166
+
167
+ /** A list screen: an optional faceted filter panel + a rich grid (status pills,
168
+ * totals, row actions, drill-through). */
169
+ export function listScreen(
170
+ entity: EntitySchema,
171
+ meta: { id: string; title: string; order: number },
172
+ opts: { filter?: string[]; grid?: GridOpts } = {},
173
+ ): Screen {
174
+ const blocks: Block[] = []
175
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
176
+ blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, opts.grid ?? {}) })
177
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
178
+ }
179
+
180
+ /**
181
+ * A form-showcase screen: a grid to pick a row + an editable record panel that
182
+ * renders the full field form (phone / rating / tags / mask / slider editors)
183
+ * inline. Lets a user see and try the rich editors on load, not just on edit.
184
+ */
185
+ export function formScreen(
186
+ entity: EntitySchema,
187
+ meta: { id: string; title: string; order: number },
188
+ fields?: string[],
189
+ grid?: GridOpts,
190
+ filter?: string[],
191
+ ): Screen {
192
+ const blocks: Block[] = []
193
+ if (filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: filter } })
194
+ blocks.push({ id: 'grid-1', span: 3, config: gridConfig(entity, grid ?? {}) })
195
+ blocks.push({ id: 'record-1', span: 3, config: { kind: 'record', editable: true, ...(fields ? { fields } : {}) } })
196
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
197
+ }
198
+
199
+ /** A Kanban board screen: an optional filter panel + a board grouping rows by an
200
+ * enum field into draggable columns (the signature "pipeline" view). */
201
+ export function boardScreen(
202
+ entity: EntitySchema,
203
+ meta: { id: string; title: string; order: number },
204
+ opts: { groupBy: string; titleField: string; badgeField?: string; subtitleField?: string; filter?: string[]; openScreen?: string },
205
+ ): Screen {
206
+ const blocks: Block[] = []
207
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
208
+ blocks.push({ id: 'board-1', span: 3, config: {
209
+ kind: 'board', groupBy: opts.groupBy, titleField: opts.titleField,
210
+ ...(opts.badgeField ? { badgeField: opts.badgeField } : {}),
211
+ ...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
212
+ ...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
213
+ } })
214
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
215
+ }
216
+
217
+ /** A calendar screen: an optional filter + a month event-calendar placing rows by
218
+ * a date field (the signature "schedule" view). */
219
+ export function calendarScreen(
220
+ entity: EntitySchema,
221
+ meta: { id: string; title: string; order: number },
222
+ opts: { dateField: string; titleField: string; colorField?: string; filter?: string[]; openScreen?: string },
223
+ ): Screen {
224
+ const blocks: Block[] = []
225
+ if (opts.filter?.length) blocks.push({ id: 'filter-1', span: 3, config: { kind: 'filter', fields: opts.filter } })
226
+ blocks.push({ id: 'calendar-1', span: 3, config: {
227
+ kind: 'calendar', dateField: opts.dateField, titleField: opts.titleField,
228
+ ...(opts.colorField ? { colorField: opts.colorField } : {}),
229
+ ...(opts.openScreen ? { openScreen: opts.openScreen } : {}),
230
+ } })
231
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
232
+ }
233
+
234
+ /** A record detail-page screen: a full record view (header + status pill + metric
235
+ * tiles + tabbed Overview / related timelines) - the signature view for
236
+ * relation-heavy records (a customer / deal / patient / trip page). */
237
+ export function detailScreen(
238
+ entity: EntitySchema,
239
+ meta: { id: string; title: string; order: number },
240
+ opts: { titleField: string; subtitleField?: string; statusField?: string; metricFields?: string[]; sections?: { label: string; fields: string[] }[]; related?: DetailRelated[] },
241
+ ): Screen {
242
+ const blocks: Block[] = [{ id: 'detail-1', span: 3, config: {
243
+ kind: 'detail', titleField: opts.titleField,
244
+ ...(opts.subtitleField ? { subtitleField: opts.subtitleField } : {}),
245
+ ...(opts.statusField ? { statusField: opts.statusField } : {}),
246
+ ...(opts.metricFields?.length ? { metricFields: opts.metricFields } : {}),
247
+ ...(opts.sections?.length ? { sections: opts.sections } : {}),
248
+ ...(opts.related?.length ? { related: opts.related } : {}),
249
+ } }]
250
+ return { id: meta.id, entity: entity.name, title: meta.title, route: meta.id, blocks, nav: { show: true, label: meta.title, order: meta.order } }
251
+ }
252
+
253
+ /** Build a screen from a template, then override its id / route / title / nav.
254
+ * `linkScreen` wires a master-detail parent row to drill into a detail screen. */
255
+ export function screen(
256
+ entity: EntitySchema,
257
+ template: ScreenTemplate,
258
+ over: { id: string; title: string; route?: string; order: number; child?: EntitySchema; foreignKey?: string; linkScreen?: string },
259
+ ): Screen {
260
+ const base = screenFromTemplate(entity, template, { child: over.child, foreignKey: over.foreignKey })
261
+ const blocks = over.linkScreen
262
+ ? base.blocks.map((b) => (b.config.kind === 'master-detail' ? { ...b, config: { ...b.config, linkScreen: over.linkScreen } } : b))
263
+ : base.blocks
264
+ return {
265
+ ...base,
266
+ blocks,
267
+ id: over.id,
268
+ title: over.title,
269
+ route: over.route ?? over.id,
270
+ nav: { show: true, label: over.title, order: over.order },
271
+ }
272
+ }
273
+
274
+ /** Assemble a sample project: entities + screens + per-entity curated seed + theme/shell. */
275
+ export function project(opts: {
276
+ title: string
277
+ brand: string
278
+ accent: string
279
+ footer?: string
280
+ navStyle?: 'sidebar' | 'top-nav'
281
+ navPosition?: 'left' | 'right'
282
+ /** A design-system preset id (font + radius + palette) from `studioThemes` -
283
+ * what makes each app read as a distinct product (Salesforce vs Excel vs Linear). */
284
+ preset?: string
285
+ mode?: 'light' | 'dark'
286
+ entities: EntitySchema[]
287
+ screens: Screen[]
288
+ seed: Record<string, Record<string, unknown>[]>
289
+ }): StudioProject {
290
+ const dataSources: Record<string, EntityDataSource> = {}
291
+ for (const e of opts.entities) dataSources[e.name] = { kind: 'memory', seed: opts.seed[e.name] ?? [] }
292
+ return sanitizeProject({
293
+ title: opts.title,
294
+ entities: opts.entities,
295
+ screens: opts.screens,
296
+ dataSource: 'memory',
297
+ dataSources,
298
+ theme: {
299
+ accent: opts.accent,
300
+ ...(opts.preset ? { preset: opts.preset } : {}),
301
+ ...(opts.mode ? { mode: opts.mode } : {}),
302
+ shell: { style: opts.navStyle ?? 'sidebar', brand: opts.brand, footer: opts.footer ?? '', navPosition: opts.navPosition ?? 'left' },
303
+ },
304
+ })
305
+ }
@@ -0,0 +1,183 @@
1
+ import type { EntitySchema } from '../../schema.js'
2
+ import type { FormatRule } from '../project.js'
3
+ import { screen, formScreen, boardScreen, calendarScreen, project, dashScreen, detailScreen, statusPills, pad, ids, type SampleApp } from './shared.js'
4
+
5
+ const plans: EntitySchema = {
6
+ name: 'plans',
7
+ label: 'Plan',
8
+ idField: 'id',
9
+ fields: [
10
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
11
+ { field: 'name', type: 'text', required: true, minLength: 2, input: { help: 'Public plan name shown at checkout' } },
12
+ { field: 'price', type: 'number', label: 'Price ($)', required: true, min: 0, defaultValue: 0, input: { prefix: '$', help: 'Recurring charge per interval' } },
13
+ { field: 'interval', type: 'enum', required: true, defaultValue: 'monthly', options: [
14
+ { value: 'monthly', label: 'Monthly', color: '#6366f1' },
15
+ { value: 'yearly', label: 'Yearly', color: '#10b981' },
16
+ ] },
17
+ { field: 'seats', type: 'number', label: 'Included seats', min: 1, defaultValue: 1, input: { help: 'Users allowed on this plan' } },
18
+ { field: 'features', type: 'text', label: 'Included features', input: { editorType: 'chips', help: 'What this plan unlocks' } },
19
+ { field: 'color', type: 'text', label: 'Badge color', defaultValue: '#6366f1', input: { editorType: 'color', help: 'Plan badge in the UI' } },
20
+ { field: 'popular', type: 'boolean', label: 'Featured', defaultValue: false },
21
+ ],
22
+ }
23
+
24
+ const customers: EntitySchema = {
25
+ name: 'customers',
26
+ label: 'Customer',
27
+ idField: 'id',
28
+ fields: [
29
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
30
+ { field: 'name', type: 'text', required: true, minLength: 2, input: { help: 'Billing account name' } },
31
+ { field: 'email', type: 'text', format: 'email', required: true, input: { help: 'Where invoices are sent' } },
32
+ { field: 'phone', type: 'text', label: 'Phone', input: { editorType: 'phone', help: 'Billing contact number' } },
33
+ { field: 'country', type: 'text', label: 'Country', input: { editorType: 'country', help: 'Billing country' } },
34
+ { field: 'taxId', type: 'text', label: 'Tax ID', input: { editorType: 'mask', mask: '##-#######', help: 'VAT / tax registration number' } },
35
+ { field: 'satisfaction', type: 'number', label: 'Satisfaction', min: 1, max: 5, defaultValue: 3, input: { editorType: 'rating', help: '1-5 account health score' } },
36
+ { field: 'since', type: 'date', label: 'Customer since' },
37
+ { field: 'active', type: 'boolean', defaultValue: true },
38
+ ],
39
+ }
40
+
41
+ const subs: EntitySchema = {
42
+ name: 'subscriptions',
43
+ label: 'Subscription',
44
+ idField: 'id',
45
+ fields: [
46
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
47
+ { field: 'customerId', type: 'relation', label: 'Customer', relation: { entity: 'customers', foreignKey: 'customerId', labelField: 'name' } },
48
+ { field: 'planId', type: 'relation', label: 'Plan', relation: { entity: 'plans', foreignKey: 'planId', labelField: 'name' } },
49
+ { field: 'status', type: 'enum', required: true, defaultValue: 'trialing', options: [
50
+ { value: 'trialing', label: 'Trialing', color: '#0ea5e9' },
51
+ { value: 'active', label: 'Active', color: '#10b981' },
52
+ { value: 'past_due', label: 'Past due', color: '#f59e0b' },
53
+ { value: 'canceled', label: 'Canceled', color: '#ef4444' },
54
+ ] },
55
+ { field: 'mrr', type: 'number', label: 'MRR ($)', required: true, min: 0, defaultValue: 0, input: { prefix: '$', help: 'Monthly recurring revenue' } },
56
+ { field: 'seats', type: 'number', label: 'Seats', min: 1, defaultValue: 1, input: { help: 'Provisioned seats' } },
57
+ { field: 'usage', type: 'number', label: 'Usage (%)', min: 0, max: 100, defaultValue: 0, input: { editorType: 'slider', help: 'Share of plan limits consumed' } },
58
+ { field: 'startedAt', type: 'date', label: 'Started at' },
59
+ { field: 'renewsAt', type: 'datetime', label: 'Renews at', input: { editorType: 'datetime', help: 'Next billing timestamp' } },
60
+ // Derived: annualized revenue from the monthly rate (display-only).
61
+ { field: 'annual', type: 'number', label: 'Annual ($)', readonly: true, formula: 'mrr * 12' },
62
+ // Derived: revenue per seat (display-only, never stored).
63
+ { field: 'perSeat', type: 'number', label: 'MRR / seat ($)', readonly: true, formula: 'mrr / seats' },
64
+ { field: 'autoRenew', type: 'boolean', label: 'Auto-renew', defaultValue: true },
65
+ ],
66
+ }
67
+
68
+ // Subscription grid formatting: status pills, plus numeric thresholds (healthy
69
+ // MRR green + bold, churned $0 rows red, near-limit usage red + bold, idle usage
70
+ // muted) - data-driven color, not just status pills.
71
+ const subFormats: FormatRule[] = [
72
+ ...statusPills(subs, 'status'),
73
+ { field: 'mrr', op: 'gte', value: 200, color: '#16a34a', bold: true },
74
+ { field: 'mrr', op: 'eq', value: 0, color: '#dc2626' },
75
+ { field: 'usage', op: 'gte', value: 80, color: '#dc2626', bold: true },
76
+ { field: 'usage', op: 'lt', value: 20, color: '#94a3b8' },
77
+ ]
78
+
79
+ const seed = {
80
+ plans: [
81
+ { id: 'pl1', name: 'Starter', price: 29, interval: 'monthly', seats: 3, features: ['Email support', 'Basic analytics'], color: '#6366f1', popular: false },
82
+ { id: 'pl2', name: 'Growth', price: 99, interval: 'monthly', seats: 10, features: ['Priority support', 'Advanced analytics', 'Integrations'], color: '#10b981', popular: true },
83
+ { id: 'pl3', name: 'Scale', price: 299, interval: 'monthly', seats: 25, features: ['SSO', 'Audit logs', 'Dedicated CSM', 'API access'], color: '#8b5cf6', popular: false },
84
+ { id: 'pl4', name: 'Starter Annual', price: 290, interval: 'yearly', seats: 3, features: ['Email support', 'Basic analytics'], color: '#6366f1', popular: false },
85
+ { id: 'pl5', name: 'Growth Annual', price: 990, interval: 'yearly', seats: 10, features: ['Priority support', 'Advanced analytics', 'Integrations'], color: '#10b981', popular: true },
86
+ { id: 'pl6', name: 'Enterprise', price: 2400, interval: 'yearly', seats: 100, features: ['SSO', 'Custom contracts', '24/7 support', 'SLA'], color: '#f59e0b', popular: false },
87
+ ],
88
+ customers: [
89
+ { id: 'cu1', name: 'Northwind Traders', email: 'billing@northwind.com', phone: '+1 (415) 555-0101', country: 'United States', taxId: '12-3456789', satisfaction: 4, since: '2025-03-12', active: true },
90
+ { id: 'cu2', name: 'Fabrikam', email: 'accounts@fabrikam.co', phone: '+44 20 7946 0102', country: 'United Kingdom', taxId: '44-2233445', satisfaction: 3, since: '2025-06-01', active: true },
91
+ { id: 'cu3', name: 'Contoso', email: 'finance@contoso.de', phone: '+49 30 5550103', country: 'Germany', taxId: '49-8877665', satisfaction: 5, since: '2024-11-20', active: true },
92
+ { id: 'cu4', name: 'Adventure Works', email: 'ap@adventure-works.ca', phone: '+1 (416) 555-0104', country: 'Canada', taxId: '11-9988776', satisfaction: 2, since: '2025-01-08', active: true },
93
+ { id: 'cu5', name: 'Tailspin Toys', email: 'billing@tailspin.com', phone: '+61 2 5550 0105', country: 'Australia', taxId: '61-4455667', satisfaction: 3, since: '2025-09-05', active: false },
94
+ { id: 'cu6', name: 'Wingtip', email: 'accounts@wingtip.io', phone: '+1 (212) 555-0106', country: 'United States', taxId: '13-2211009', satisfaction: 4, since: '2026-02-18', active: true },
95
+ ],
96
+ subscriptions: [
97
+ { id: 'sb1', customerId: 'cu1', planId: 'pl2', status: 'active', mrr: 99, seats: 10, usage: 72, startedAt: '2026-01-15', renewsAt: '2026-08-15T09:00:00Z', autoRenew: true },
98
+ { id: 'sb2', customerId: 'cu2', planId: 'pl1', status: 'trialing', mrr: 29, seats: 3, usage: 20, startedAt: '2026-07-01', renewsAt: '2026-07-15T09:00:00Z', autoRenew: true },
99
+ { id: 'sb3', customerId: 'cu3', planId: 'pl3', status: 'active', mrr: 299, seats: 25, usage: 88, startedAt: '2025-11-20', renewsAt: '2026-08-20T10:00:00Z', autoRenew: true },
100
+ { id: 'sb4', customerId: 'cu4', planId: 'pl6', status: 'past_due', mrr: 200, seats: 100, usage: 55, startedAt: '2025-09-10', renewsAt: '2026-09-10T09:00:00Z', autoRenew: false },
101
+ { id: 'sb5', customerId: 'cu5', planId: 'pl2', status: 'canceled', mrr: 0, seats: 10, usage: 5, startedAt: '2026-03-05', renewsAt: '2026-09-05T09:00:00Z', autoRenew: false },
102
+ { id: 'sb6', customerId: 'cu6', planId: 'pl5', status: 'active', mrr: 82, seats: 10, usage: 64, startedAt: '2026-02-28', renewsAt: '2027-02-28T09:00:00Z', autoRenew: true },
103
+ { id: 'sb7', customerId: 'cu1', planId: 'pl1', status: 'active', mrr: 29, seats: 3, usage: 41, startedAt: '2026-05-18', renewsAt: '2026-08-18T09:00:00Z', autoRenew: true },
104
+ ],
105
+ }
106
+
107
+ export const subscriptions: SampleApp = {
108
+ id: 'subscriptions',
109
+ name: 'Subscriptions',
110
+ description: 'Customers, plans and subscriptions - revenue + growth + plan dashboards (MRR sparkline over start dates, a $30k target, a usage gauge, status/interval charts, a status x auto-renew pivot, tabs), filtered status-pill grids with row actions, master/detail, and rich edit forms (phone, country, tax-id mask, satisfaction rating, usage slider, feature chips, plan color, renewal datetime).',
111
+ emoji: '\u{1F504}',
112
+ accent: '#14b8a6',
113
+ build: () => {
114
+ const planRows = pad(plans, seed.plans, 8)
115
+ const customerRows = pad(customers, seed.customers, 18)
116
+ const subRows = pad(subs, seed.subscriptions, 32, { customerId: ids(customerRows), planId: ids(planRows) })
117
+ return project({
118
+ title: 'SubHub',
119
+ brand: 'SubHub',
120
+ accent: '#14b8a6',
121
+ preset: 'notion',
122
+ footer: '',
123
+ entities: [plans, customers, subs],
124
+ seed: { plans: planRows, customers: customerRows, subscriptions: subRows },
125
+ screens: [
126
+ // Revenue dashboard: KPI cards (a Total MRR card with a sparkline over start
127
+ // dates + a $30k target), a usage gauge, an MRR-by-status funnel, a pie, a
128
+ // tabbed breakdown, and a status-pill subscription grid with row actions.
129
+ dashScreen(subs, { id: 'overview', title: 'Revenue', order: 0 }, [
130
+ { kpi: 'Total MRR', measure: 'mrr', reduce: 'sum', format: 'currency', trendField: 'startedAt', trendReduce: 'sum', target: 30000, span: 1 },
131
+ { kpi: 'Subscriptions', reduce: 'count', span: 1 },
132
+ { kpi: 'Avg MRR', measure: 'mrr', reduce: 'avg', format: 'currency', span: 1 },
133
+ { gauge: 'Avg usage', measure: 'usage', reduce: 'avg', min: 0, max: 100, unit: '%', span: 1 },
134
+ { chart: 'status', measure: 'mrr', reduce: 'sum', type: 'bar', span: 2 },
135
+ { chart: 'status', reduce: 'count', type: 'pie', span: 1 },
136
+ { tabs: [
137
+ { label: 'MRR by status', tiles: [{ chart: 'status', measure: 'mrr', reduce: 'sum', type: 'bar' }] },
138
+ { label: 'Subscriptions by status', tiles: [{ chart: 'status', reduce: 'count', type: 'bar' }] },
139
+ ], span: 3 },
140
+ { grid: true, format: subFormats, summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
141
+ ]),
142
+ // Growth: revenue + usage KPIs, an MRR-over-time area trend, and a pivot of MRR
143
+ // by status (rows) x auto-renew (columns) to surface churn / non-renewing spend.
144
+ dashScreen(subs, { id: 'growth', title: 'Growth', order: 1 }, [
145
+ { kpi: 'Total MRR', measure: 'mrr', reduce: 'sum', format: 'currency', span: 1 },
146
+ { kpi: 'Avg usage', measure: 'usage', reduce: 'avg', format: 'percent', span: 1 },
147
+ { kpi: 'Largest MRR', measure: 'mrr', reduce: 'max', format: 'currency', span: 1 },
148
+ { chart: 'startedAt', measure: 'mrr', reduce: 'sum', type: 'area', span: 3 },
149
+ { pivot: { rows: ['status'], cols: ['autoRenew'], measure: 'mrr', aggregate: 'sum' }, span: 3 },
150
+ ]),
151
+ // Plans dashboard: pricing KPIs, an average-seats gauge, interval breakdowns, a
152
+ // filter, and a plan grid with interval status pills and inline edit actions.
153
+ dashScreen(plans, { id: 'plans-overview', title: 'Plans', order: 2 }, [
154
+ { kpi: 'Plans', reduce: 'count', span: 1 },
155
+ { kpi: 'Avg price', measure: 'price', reduce: 'avg', format: 'currency', span: 1 },
156
+ { kpi: 'Top price', measure: 'price', reduce: 'max', format: 'currency', span: 1 },
157
+ { gauge: 'Avg seats', measure: 'seats', reduce: 'avg', min: 0, max: 100, span: 1 },
158
+ { chart: 'interval', measure: 'price', reduce: 'sum', type: 'bar', span: 2 },
159
+ { chart: 'interval', reduce: 'count', type: 'pie', span: 1 },
160
+ { filter: ['interval', 'popular'], span: 3 },
161
+ { grid: true, format: statusPills(plans, 'interval'), summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
162
+ ]),
163
+ // Customer 360: a record detail page - header (name + email + satisfaction
164
+ // tile), a tabbed Overview, and a Subscriptions timeline of the customer's subs.
165
+ detailScreen(customers, { id: 'customer-360', title: 'Customer 360', order: 3 }, {
166
+ titleField: 'name', subtitleField: 'email',
167
+ metricFields: ['satisfaction'],
168
+ related: [{ entity: 'subscriptions', foreignKey: 'customerId', label: 'Subscriptions', titleField: 'planId', subtitleField: 'mrr', dateField: 'startedAt', statusField: 'status' }],
169
+ }),
170
+ screen(customers, 'master-detail', { id: 'customers', title: 'Customers', order: 4, child: subs, foreignKey: 'customerId', linkScreen: 'customer-360' }),
171
+ // Subscriptions status board: filter by status / plan / customer, with status
172
+ // pills + numeric thresholds and inline edit.
173
+ formScreen(subs, { id: 'subscriptions', title: 'Subscriptions', order: 5 }, undefined, { format: subFormats, summaries: true, rowActions: [{ kind: 'edit' }] }, ['status', 'planId', 'customerId']),
174
+ formScreen(plans, { id: 'plans-form', title: 'Manage plans', order: 6 }, undefined, { format: statusPills(plans, 'interval'), summaries: true, rowActions: [{ kind: 'edit' }] }, ['interval', 'popular']),
175
+ // Lifecycle board: subscriptions as draggable Kanban lanes by status
176
+ // (Trialing -> Active -> Past due -> Canceled), card = customer + MRR.
177
+ boardScreen(subs, { id: 'board', title: 'Lifecycle', order: 7 }, { groupBy: 'status', titleField: 'customerId', badgeField: 'mrr', subtitleField: 'planId', filter: ['status', 'planId'] }),
178
+ // Renewals calendar: subscriptions placed by their next renewal date.
179
+ calendarScreen(subs, { id: 'renewals', title: 'Renewals', order: 8 }, { dateField: 'renewsAt', titleField: 'customerId', filter: ['status', 'planId'] }),
180
+ ],
181
+ })
182
+ },
183
+ }