@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,58 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { generateValue, generateRows } from './sample-data.js'
3
+ import type { EntityField } from '../schema.js'
4
+
5
+ const f = (over: Partial<EntityField>): EntityField => ({ field: 'x', type: 'text', ...over } as EntityField)
6
+
7
+ describe('generateValue - realistic, field-name aware', () => {
8
+ it('emails look like emails, derived from a person', () => {
9
+ const v = generateValue(f({ field: 'email', type: 'text' }), 0)
10
+ expect(String(v)).toMatch(/^[a-z.]+@[a-z.]+$/)
11
+ })
12
+
13
+ it('name fields get a full person name (not "name 1")', () => {
14
+ const v = String(generateValue(f({ field: 'name', type: 'text' }), 0))
15
+ expect(v).toMatch(/^\w+ [\w-]+/)
16
+ expect(v).not.toMatch(/\bname 1\b/i)
17
+ })
18
+
19
+ it('company-ish fields get a company', () => {
20
+ expect(['company', 'vendor', 'client', 'organization'].every((n) => /[A-Z]/.test(String(generateValue(f({ field: n }), 1))))).toBe(true)
21
+ })
22
+
23
+ it('currency-ish numbers use realistic amounts, not i*25', () => {
24
+ const total = generateValue(f({ field: 'total', type: 'number' }), 0)
25
+ expect(typeof total).toBe('number')
26
+ expect(total).toBeGreaterThan(50)
27
+ })
28
+
29
+ it('quantity-ish numbers stay small', () => {
30
+ const qty = generateValue(f({ field: 'quantity', type: 'number' }), 3) as number
31
+ expect(qty).toBeGreaterThan(0)
32
+ expect(qty).toBeLessThanOrEqual(50)
33
+ })
34
+
35
+ it('enum/options fields cycle their options', () => {
36
+ const field = f({ field: 'status', type: 'enum', options: [{ value: 'a', label: 'A' }, { value: 'b', label: 'B' }] })
37
+ expect(generateValue(field, 0)).toBe('a')
38
+ expect(generateValue(field, 1)).toBe('b')
39
+ expect(generateValue(field, 2)).toBe('a')
40
+ })
41
+
42
+ it('dates are ISO; datetime carries time', () => {
43
+ expect(String(generateValue(f({ field: 'created', type: 'date' }), 0))).toMatch(/^\d{4}-\d{2}-\d{2}$/)
44
+ expect(String(generateValue(f({ field: 'created', type: 'datetime' }), 0))).toMatch(/^\d{4}-\d{2}-\d{2}T/)
45
+ })
46
+
47
+ it('is deterministic across calls', () => {
48
+ const a = generateValue(f({ field: 'name' }), 2)
49
+ const b = generateValue(f({ field: 'name' }), 2)
50
+ expect(a).toBe(b)
51
+ })
52
+
53
+ it('generateRows fills every field', () => {
54
+ const rows = generateRows([f({ field: 'id', type: 'number' }), f({ field: 'name' }), f({ field: 'email' })], 4)
55
+ expect(rows).toHaveLength(4)
56
+ expect(Object.keys(rows[0]!)).toEqual(['id', 'name', 'email'])
57
+ })
58
+ })
@@ -0,0 +1,200 @@
1
+ /**
2
+ * Realistic sample-data generator - shared by the designer preview
3
+ * (`schema-designer.ts`) and the codegen seed (`emit-schema.ts`), so a Studio
4
+ * app looks *believable* out of the box (real-looking people, companies,
5
+ * amounts, dates, statuses) instead of `name 1` / `email 1` / `10, 20, 30`.
6
+ *
7
+ * Pure + node-safe (studio subtree: `.js` imports, types only, no Svelte / grid /
8
+ * DOM). DETERMINISTIC by (field, row index) - no `Math.random`, so previews and
9
+ * generated seed are stable across runs and don't churn tests or git diffs.
10
+ */
11
+ import type { EntityField } from '../schema.js'
12
+
13
+ // --- curated value pools ----------------------------------------------------
14
+
15
+ const FIRST_NAMES = ['Ada', 'Alan', 'Grace', 'Linus', 'Barbara', 'Katherine', 'Donald', 'Margaret', 'Tim', 'Radia', 'Guido', 'Anita', 'Ken', 'Dorothy', 'Vint', 'Hedy']
16
+ const LAST_NAMES = ['Lovelace', 'Turing', 'Hopper', 'Torvalds', 'Liskov', 'Johnson', 'Knuth', 'Hamilton', 'Berners-Lee', 'Perlman', 'van Rossum', 'Borg', 'Thompson', 'Vaughan', 'Cerf', 'Lamarr']
17
+ const COMPANIES = ['Acme Corp', 'Globex', 'Initech', 'Umbrella', 'Soylent', 'Hooli', 'Stark Industries', 'Wayne Enterprises', 'Cyberdyne', 'Wonka Industries', 'Aperture', 'Tyrell Corp', 'Massive Dynamic', 'Pied Piper', 'Vandelay', 'Gekko & Co']
18
+ const PRODUCTS = ['Aeron Chair', 'Standing Desk', 'Mechanical Keyboard', 'Noise-Cancelling Headphones', '4K Monitor', 'Webcam Pro', 'USB-C Hub', 'Ergonomic Mouse', 'Laptop Stand', 'Desk Lamp', 'Cable Organizer', 'Wireless Charger', 'Notebook', 'Water Bottle', 'Backpack', 'Coffee Mug']
19
+ const CITIES = ['San Francisco', 'New York', 'London', 'Berlin', 'Toronto', 'Amsterdam', 'Sydney', 'Tokyo', 'Austin', 'Paris', 'Singapore', 'Dublin']
20
+ const COUNTRIES = ['United States', 'United Kingdom', 'Germany', 'Canada', 'Australia', 'France', 'Japan', 'Netherlands', 'Ireland', 'Singapore', 'Spain', 'Sweden']
21
+ const STREETS = ['Market St', 'Main St', 'Oak Ave', 'Elm St', 'Pine Rd', 'Maple Dr', '5th Ave', 'King St', 'Queen St', 'Broadway', 'High St', 'Park Ln']
22
+ const WORDS = ['Standard', 'Premium', 'Basic', 'Advanced', 'Starter', 'Professional', 'Enterprise', 'Custom', 'General', 'Priority']
23
+ const SENTENCES = [
24
+ 'Follow up on the latest changes and confirm next steps.',
25
+ 'Reviewed the proposal and shared feedback with the team.',
26
+ 'Waiting on approval before moving to the next phase.',
27
+ 'Everything looks good - ready to ship this week.',
28
+ 'Blocked by a dependency; escalating to the owner.',
29
+ 'Scheduled a call to walk through the requirements.',
30
+ 'Updated the estimate after the scope changed.',
31
+ 'Closed out the remaining items from last sprint.',
32
+ ]
33
+ const COLORS = ['Black', 'White', 'Silver', 'Blue', 'Red', 'Green', 'Graphite', 'Gold']
34
+ const DOMAINS = ['example.com', 'acme.io', 'globex.co', 'mail.dev', 'company.org', 'work.app']
35
+ const CURRENCY_POOL = [1200, 340, 980, 4500, 220, 1750, 60, 8900, 430, 2600, 150, 3200, 720, 5400, 90, 1980]
36
+ // Tag pools for `chips` (SvTagsInput) fields, chosen by field name.
37
+ const SKILLS = ['TypeScript', 'React', 'Svelte', 'Python', 'Go', 'SQL', 'AWS', 'Figma', 'Rust', 'GraphQL', 'Docker', 'Kubernetes', 'Node.js', 'Leadership', 'Product', 'UX']
38
+ const AMENITIES = ['Parking', 'Pool', 'Gym', 'Balcony', 'Garden', 'A/C', 'Heating', 'Elevator', 'Pet-friendly', 'Furnished', 'Laundry', 'Dishwasher', 'Fireplace', 'Garage']
39
+ const DIET_TAGS = ['Vegan', 'Vegetarian', 'Gluten-free', 'Dairy-free', 'Nut-free', 'Halal', 'Kosher', 'Spicy', 'Organic', 'Keto']
40
+ const TOPIC_TAGS = ['Keynote', 'Workshop', 'Networking', 'Panel', 'Q&A', 'Hands-on', 'Beginner', 'Advanced', 'Remote', 'Live']
41
+ const HEX_COLORS = ['#6366f1', '#10b981', '#f59e0b', '#ef4444', '#3b82f6', '#8b5cf6', '#ec4899', '#14b8a6']
42
+
43
+ // --- helpers ----------------------------------------------------------------
44
+
45
+ function titleCase(s: string): string {
46
+ return s.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).trim()
47
+ }
48
+
49
+ /** Stable small offset from a field name, so different fields don't align. */
50
+ function offsetFor(name: string): number {
51
+ let h = 0
52
+ for (let i = 0; i < name.length; i++) h = (h + name.charCodeAt(i) * (i + 1)) % 997
53
+ return h
54
+ }
55
+
56
+ const pick = <T>(pool: readonly T[], i: number): T => pool[i % pool.length]!
57
+
58
+ const firstName = (i: number) => pick(FIRST_NAMES, i)
59
+ const lastName = (i: number) => pick(LAST_NAMES, i)
60
+ const fullName = (i: number) => `${firstName(i)} ${lastName(i + 3)}`
61
+ const slug = (s: string) => s.toLowerCase().replace(/[^a-z]+/g, '')
62
+
63
+ /** A fixed "today" so date spreads are deterministic (no `new Date()`). */
64
+ const BASE_YEAR = 2026
65
+ const BASE_MONTH = 6 // July (0-based)
66
+ const BASE_DAY = 14
67
+
68
+ /** 2-4 distinct tags from the pool that best matches the field name. */
69
+ function tagArray(name: string, i: number, off: number): string[] {
70
+ const pool = /skill|tech|stack|expert/.test(name) ? SKILLS
71
+ : /amenit|feature|facilit/.test(name) ? AMENITIES
72
+ : /diet|cuisine|allerg|food/.test(name) ? DIET_TAGS
73
+ : /topic|track|tag|interest|categor|label/.test(name) ? TOPIC_TAGS
74
+ : WORDS
75
+ const n = 2 + ((off + i) % 3) // 2-4 tags
76
+ const out = new Set<string>()
77
+ for (let k = 0; k < n; k++) out.add(pick(pool, off + i * 3 + k))
78
+ return [...out]
79
+ }
80
+
81
+ /** A masked code (VIN, plate, ISBN, SSN, ...) chosen by field name. */
82
+ function maskedCode(name: string, n: number): string {
83
+ const d = (len: number, seed: number) => String(seed % Math.pow(10, len)).padStart(len, '0')
84
+ if (/vin/.test(name)) return `1HGCM${d(6, n * 7)}${String.fromCharCode(65 + (n % 26))}`
85
+ if (/plate|registration/.test(name)) return `${String.fromCharCode(65 + n % 26)}${String.fromCharCode(65 + (n + 3) % 26)}${String.fromCharCode(65 + (n + 7) % 26)}-${d(4, n * 13 + 1000)}`
86
+ if (/isbn/.test(name)) return `978-0-${d(5, n * 271 + 10000)}-${d(3, n * 7 + 100)}-${(n % 10)}`
87
+ if (/ssn/.test(name)) return `${d(3, n + 100)}-${d(2, n + 10)}-${d(4, n * 3 + 1000)}`
88
+ if (/ein|tax/.test(name)) return `${d(2, n + 10)}-${d(7, n * 137 + 1000000)}`
89
+ if (/zip|postal/.test(name)) return d(5, n * 37 + 10000)
90
+ return d(4, n + 1000)
91
+ }
92
+
93
+ function isoDate(offsetDays: number, withTime: boolean): string {
94
+ // Build a date by simple arithmetic off the fixed base (avoids `Date.now()`).
95
+ const dayOfYear = (BASE_MONTH * 30 + BASE_DAY) + offsetDays
96
+ const month = Math.max(0, Math.min(11, Math.floor(((dayOfYear % 360) + 360) % 360 / 30)))
97
+ const day = Math.max(1, Math.min(28, (((dayOfYear % 30) + 30) % 30) + 1))
98
+ const mm = String(month + 1).padStart(2, '0')
99
+ const dd = String(day).padStart(2, '0')
100
+ return withTime ? `${BASE_YEAR}-${mm}-${dd}T09:${String(10 + (offsetDays % 40)).padStart(2, '0')}:00Z` : `${BASE_YEAR}-${mm}-${dd}`
101
+ }
102
+
103
+ // --- the generator ----------------------------------------------------------
104
+
105
+ /**
106
+ * A believable value for `field` on row `i` (0-based). Type drives the shape;
107
+ * the field NAME refines it (an `email` field gets an email, a `price` field
108
+ * gets a currency amount, a `status` field cycles a status pool).
109
+ */
110
+ export function generateValue(field: EntityField, i: number): unknown {
111
+ const name = String(field.field).toLowerCase()
112
+ const off = offsetFor(name)
113
+ const j = i + off
114
+
115
+ // A field with explicit options (enum / select) always cycles its options.
116
+ if (field.options && field.options.length) return field.options[i % field.options.length]!.value
117
+
118
+ // The chosen editor refines the shape ahead of the base type (a `slider` field
119
+ // is a number 0-100, a `chips` field is a tag array, a `mask` field a code).
120
+ switch (field.input?.editorType) {
121
+ case 'chips':
122
+ return tagArray(name, i, off)
123
+ case 'mask':
124
+ return maskedCode(name, j)
125
+ case 'rating':
126
+ return 1 + (j % 5)
127
+ case 'slider': {
128
+ const lo = field.min ?? 0
129
+ const hi = field.max ?? 100
130
+ return lo + ((j * 7) % Math.max(1, hi - lo + 1))
131
+ }
132
+ case 'color':
133
+ return pick(HEX_COLORS, j)
134
+ case 'country':
135
+ return pick(COUNTRIES, j)
136
+ case 'phone':
137
+ return `+1 (${200 + (j % 700)}) ${String(100 + (j % 900)).padStart(3, '0')}-${String(1000 + (j % 9000)).padStart(4, '0')}`
138
+ }
139
+
140
+ switch (field.type) {
141
+ case 'relation':
142
+ return `${field.relation?.entity ?? 'ref'}-${i + 1}`
143
+ case 'boolean':
144
+ // active / enabled / paid / verified skew true; otherwise ~2/3 true.
145
+ return /inactive|archived|deleted|disabled/.test(name) ? i % 3 === 0 : i % 3 !== 0
146
+ case 'json':
147
+ return {}
148
+ case 'date':
149
+ case 'dateString':
150
+ case 'datetime': {
151
+ const withTime = field.type === 'datetime'
152
+ // created/joined in the past, due/expires in the future, else spread around now.
153
+ const dir = /due|expire|deadline|end|renew/.test(name) ? 1 : /created|joined|start|signed|opened|order/.test(name) ? -1 : (i % 2 ? 1 : -1)
154
+ return isoDate(dir * (5 + ((j * 7) % 90)), withTime)
155
+ }
156
+ case 'number': {
157
+ if (/price|amount|total|cost|salary|mrr|arr|revenue|balance|fee|value|budget|spent|paid|subtotal|tax/.test(name)) return pick(CURRENCY_POOL, j)
158
+ if (/qty|quantity|stock|count|units|seats|items|inventory/.test(name)) return 1 + (j % 50)
159
+ if (/age/.test(name)) return 22 + (j % 44)
160
+ if (/percent|discount|progress|score|rate|completion/.test(name)) return (j * 7) % 100
161
+ if (/year/.test(name)) return 2020 + (j % 7)
162
+ if (/rating|stars/.test(name)) return 1 + (j % 5)
163
+ return (i + 1) * 25
164
+ }
165
+ default: {
166
+ // text: refine heavily by field name.
167
+ if (/e-?mail/.test(name)) return `${slug(firstName(i))}.${slug(lastName(i + 3))}@${pick(DOMAINS, j)}`
168
+ if (/first.?name|given/.test(name)) return firstName(i)
169
+ if (/last.?name|surname|family/.test(name)) return lastName(i)
170
+ if (/full.?name|^name$|contact|customer|author|owner|assignee|manager|agent|member|person|lead|rep|user/.test(name)) return fullName(i)
171
+ if (/compan|organi|vendor|supplier|account|client|business|employer/.test(name)) return pick(COMPANIES, j)
172
+ if (/product|item|^sku$|part/.test(name)) return pick(PRODUCTS, j)
173
+ if (/city|town/.test(name)) return pick(CITIES, j)
174
+ if (/country|nation/.test(name)) return pick(COUNTRIES, j)
175
+ if (/state|province|region/.test(name)) return pick(CITIES, j + 2)
176
+ if (/address|street/.test(name)) return `${100 + ((j * 13) % 900)} ${pick(STREETS, j)}`
177
+ if (/phone|mobile|tel|fax/.test(name)) return `+1 (${200 + (j % 700)}) ${String(100 + (j % 900)).padStart(3, '0')}-${String(1000 + (j % 9000)).padStart(4, '0')}`
178
+ if (/url|website|link|site|domain/.test(name)) return `https://${slug(pick(COMPANIES, j))}.com`
179
+ if (/description|notes?|summary|comment|message|body|detail|remark/.test(name)) return pick(SENTENCES, j)
180
+ if (/subject|title|headline|task|name/.test(name)) return `${pick(WORDS, j)} ${pick(PRODUCTS, j + 1)}`
181
+ if (/status|stage|priority|category|tier|plan|role|department|type|kind|level|group/.test(name)) return pick(WORDS, j)
182
+ if (/color|colour/.test(name)) return pick(COLORS, j)
183
+ if (/code|ref|reference|invoice|^no$|number|order|ticket|^id$/.test(name)) {
184
+ const prefix = (name.match(/[a-z]{2,4}/)?.[0] ?? 'ref').toUpperCase().slice(0, 3)
185
+ return `${prefix}-${1000 + i * 7 + (off % 90)}`
186
+ }
187
+ // Fallback: a title-cased label + index (still readable).
188
+ return `${field.label ?? titleCase(field.field)} ${i + 1}`
189
+ }
190
+ }
191
+ }
192
+
193
+ /** Deterministic rows for a schema's fields (used by the designer preview). */
194
+ export function generateRows(fields: ReadonlyArray<EntityField>, count = 6): Array<Record<string, unknown>> {
195
+ return Array.from({ length: count }, (_, i) => {
196
+ const row: Record<string, unknown> = {}
197
+ for (const f of fields) row[String(f.field)] = generateValue(f, i)
198
+ return row
199
+ })
200
+ }
@@ -0,0 +1,202 @@
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 jobs: EntitySchema = {
6
+ name: 'jobs',
7
+ label: 'Job',
8
+ idField: 'id',
9
+ fields: [
10
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
11
+ { field: 'title', type: 'text', required: true, minLength: 3 },
12
+ { field: 'department', type: 'enum', required: true, options: [
13
+ { value: 'engineering', label: 'Engineering', color: '#6366f1' },
14
+ { value: 'design', label: 'Design', color: '#ec4899' },
15
+ { value: 'sales', label: 'Sales', color: '#f59e0b' },
16
+ { value: 'ops', label: 'Ops', color: '#0ea5e9' },
17
+ ] },
18
+ { field: 'location', type: 'text', label: 'Location', input: { editorType: 'country', help: 'Where the role is based' } },
19
+ { field: 'status', type: 'enum', required: true, defaultValue: 'open', options: [
20
+ { value: 'open', label: 'Open', color: '#10b981' },
21
+ { value: 'on_hold', label: 'On hold', color: '#f59e0b' },
22
+ { value: 'closed', label: 'Closed', color: '#94a3b8' },
23
+ ] },
24
+ { field: 'openings', type: 'number', label: 'Openings', min: 0, max: 25, defaultValue: 1, input: { help: 'Headcount to fill' } },
25
+ { field: 'salaryMin', type: 'number', label: 'Salary min ($)', min: 0 },
26
+ { field: 'salaryMax', type: 'number', label: 'Salary max ($)', min: 0 },
27
+ // Derived: midpoint of the posted salary band (display-only).
28
+ { field: 'salaryMid', type: 'number', label: 'Salary mid ($)', readonly: true, formula: '(salaryMin + salaryMax) / 2' },
29
+ { field: 'hiringManager', type: 'text', label: 'Hiring manager' },
30
+ { field: 'postedDate', type: 'date', label: 'Posted date' },
31
+ ],
32
+ }
33
+
34
+ const candidates: EntitySchema = {
35
+ name: 'candidates',
36
+ label: 'Candidate',
37
+ idField: 'id',
38
+ fields: [
39
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
40
+ { field: 'name', type: 'text', required: true, minLength: 2 },
41
+ { field: 'email', type: 'text', format: 'email', required: true },
42
+ { field: 'phone', type: 'text', input: { editorType: 'phone' } },
43
+ { field: 'country', type: 'text', label: 'Country', input: { editorType: 'country' } },
44
+ { field: 'source', type: 'enum', options: [
45
+ { value: 'referral', label: 'Referral', color: '#10b981' },
46
+ { value: 'linkedin', label: 'LinkedIn', color: '#0ea5e9' },
47
+ { value: 'website', label: 'Website', color: '#6366f1' },
48
+ { value: 'agency', label: 'Agency', color: '#f59e0b' },
49
+ ] },
50
+ { field: 'skills', type: 'text', label: 'Skills', input: { editorType: 'chips', help: 'Key skills / stack' } },
51
+ { field: 'rating', type: 'number', label: 'Rating', min: 1, max: 5, defaultValue: 3, input: { editorType: 'rating', help: '1-5 recruiter score' } },
52
+ { field: 'linkedin', type: 'text', label: 'LinkedIn', format: 'url', input: { placeholder: 'https://linkedin.com/in/…' } },
53
+ { field: 'portfolio', type: 'text', label: 'Portfolio', format: 'url', input: { placeholder: 'https://…' } },
54
+ ],
55
+ }
56
+
57
+ const applications: EntitySchema = {
58
+ name: 'applications',
59
+ label: 'Application',
60
+ idField: 'id',
61
+ fields: [
62
+ { field: 'id', type: 'text', primaryKey: true, readonly: true },
63
+ { field: 'candidateId', type: 'relation', label: 'Candidate', relation: { entity: 'candidates', foreignKey: 'candidateId', labelField: 'name' } },
64
+ { field: 'jobId', type: 'relation', label: 'Job', relation: { entity: 'jobs', foreignKey: 'jobId', labelField: 'title' } },
65
+ { field: 'stage', type: 'enum', required: true, options: [
66
+ { value: 'applied', label: 'Applied', color: '#94a3b8' },
67
+ { value: 'screen', label: 'Screen', color: '#0ea5e9' },
68
+ { value: 'interview', label: 'Interview', color: '#6366f1' },
69
+ { value: 'offer', label: 'Offer', color: '#f59e0b' },
70
+ { value: 'hired', label: 'Hired', color: '#10b981' },
71
+ { value: 'rejected', label: 'Rejected', color: '#ef4444' },
72
+ ] },
73
+ { field: 'score', type: 'number', label: 'Score', min: 0, max: 100, defaultValue: 50, input: { editorType: 'slider', help: 'Overall fit 0-100' } },
74
+ { field: 'fit', type: 'number', label: 'Culture fit', min: 1, max: 5, input: { editorType: 'rating' } },
75
+ { field: 'appliedDate', type: 'date', label: 'Applied date' },
76
+ { field: 'interviewAt', type: 'datetime', label: 'Interview at', input: { editorType: 'datetime', help: 'Scheduled interview slot' } },
77
+ ],
78
+ }
79
+
80
+ // Application grid formatting: stage pills + score thresholds (strong green/bold,
81
+ // weak red) - data-driven color beyond the pipeline status.
82
+ const appFormats: FormatRule[] = [
83
+ ...statusPills(applications, 'stage'),
84
+ { field: 'score', op: 'gte', value: 80, color: '#16a34a', bold: true },
85
+ { field: 'score', op: 'lt', value: 50, color: '#dc2626' },
86
+ ]
87
+ // Job grid formatting: status + department pills + urgent-openings bold.
88
+ const jobFormats: FormatRule[] = [
89
+ ...statusPills(jobs, 'status'),
90
+ ...statusPills(jobs, 'department'),
91
+ { field: 'openings', op: 'gte', value: 3, bold: true },
92
+ ]
93
+ // Candidate grid formatting: source pills + rating thresholds.
94
+ const candidateFormats: FormatRule[] = [
95
+ ...statusPills(candidates, 'source'),
96
+ { field: 'rating', op: 'gte', value: 4, color: '#16a34a', bold: true },
97
+ { field: 'rating', op: 'lte', value: 2, color: '#dc2626' },
98
+ ]
99
+
100
+ const seed = {
101
+ jobs: [
102
+ { id: 'jb1', title: 'Senior Frontend Engineer', department: 'engineering', location: 'United States', status: 'open', openings: 2, salaryMin: 140000, salaryMax: 180000, hiringManager: 'Sam Rivera', postedDate: '2026-06-02' },
103
+ { id: 'jb2', title: 'Product Designer', department: 'design', location: 'United Kingdom', status: 'open', openings: 1, salaryMin: 90000, salaryMax: 120000, hiringManager: 'Jamie Chen', postedDate: '2026-06-10' },
104
+ { id: 'jb3', title: 'Account Executive', department: 'sales', location: 'Germany', status: 'on_hold', openings: 3, salaryMin: 80000, salaryMax: 110000, hiringManager: 'Priya Patel', postedDate: '2026-05-18' },
105
+ { id: 'jb4', title: 'DevOps Engineer', department: 'engineering', location: 'Canada', status: 'open', openings: 1, salaryMin: 130000, salaryMax: 165000, hiringManager: 'Sam Rivera', postedDate: '2026-06-21' },
106
+ { id: 'jb5', title: 'Operations Manager', department: 'ops', location: 'Netherlands', status: 'closed', openings: 1, salaryMin: 85000, salaryMax: 115000, hiringManager: 'Jamie Chen', postedDate: '2026-04-30' },
107
+ { id: 'jb6', title: 'UX Researcher', department: 'design', location: 'Australia', status: 'open', openings: 1, salaryMin: 95000, salaryMax: 125000, hiringManager: 'Priya Patel', postedDate: '2026-06-27' },
108
+ ],
109
+ candidates: [
110
+ { id: 'cd1', name: 'Maya Fernandez', email: 'maya.fernandez@example.com', phone: '+1 (415) 555-0110', country: 'United States', source: 'referral', skills: ['React', 'TypeScript', 'GraphQL'], rating: 4, linkedin: 'https://linkedin.com/in/mayafernandez', portfolio: 'https://mayafernandez.dev' },
111
+ { id: 'cd2', name: 'Liam Chen', email: 'liam.chen@example.com', phone: '+44 20 7946 0111', country: 'United Kingdom', source: 'linkedin', skills: ['Svelte', 'Node.js'], rating: 5, linkedin: 'https://linkedin.com/in/liamchen', portfolio: 'https://liamchen.io' },
112
+ { id: 'cd3', name: 'Priya Nair', email: 'priya.nair@example.com', phone: '+91 22 5550 0112', country: 'India', source: 'website', skills: ['Figma', 'UX', 'Product'], rating: 4, linkedin: 'https://linkedin.com/in/priyanair', portfolio: 'https://priyanair.design' },
113
+ { id: 'cd4', name: 'Noah Williams', email: 'noah.williams@example.com', phone: '+1 (312) 555-0113', country: 'United States', source: 'agency', skills: ['Go', 'Docker', 'Kubernetes'], rating: 3, linkedin: 'https://linkedin.com/in/noahwilliams', portfolio: 'https://noahwilliams.dev' },
114
+ { id: 'cd5', name: 'Sofia Rossi', email: 'sofia.rossi@example.com', phone: '+39 06 5550 0114', country: 'Italy', source: 'linkedin', skills: ['Python', 'SQL', 'AWS'], rating: 5, linkedin: 'https://linkedin.com/in/sofiarossi', portfolio: 'https://sofiarossi.tech' },
115
+ { id: 'cd6', name: 'Ethan Brooks', email: 'ethan.brooks@example.com', phone: '+1 (206) 555-0115', country: 'Canada', source: 'referral', skills: ['Leadership', 'Product'], rating: 3, linkedin: 'https://linkedin.com/in/ethanbrooks', portfolio: 'https://ethanbrooks.co' },
116
+ { id: 'cd7', name: 'Amara Okafor', email: 'amara.okafor@example.com', phone: '+234 1 555 0116', country: 'Nigeria', source: 'website', skills: ['Rust', 'Go', 'AWS'], rating: 4, linkedin: 'https://linkedin.com/in/amaraokafor', portfolio: 'https://amaraokafor.dev' },
117
+ ],
118
+ applications: [
119
+ { id: 'ap1', candidateId: 'cd1', jobId: 'jb1', stage: 'interview', score: 82, fit: 4, appliedDate: '2026-06-05', interviewAt: '2026-07-08T09:30:00Z' },
120
+ { id: 'ap2', candidateId: 'cd2', jobId: 'jb1', stage: 'offer', score: 91, fit: 5, appliedDate: '2026-06-12', interviewAt: '2026-07-02T14:00:00Z' },
121
+ { id: 'ap3', candidateId: 'cd3', jobId: 'jb2', stage: 'screen', score: 74, fit: 4, appliedDate: '2026-06-15', interviewAt: '2026-07-11T10:00:00Z' },
122
+ { id: 'ap4', candidateId: 'cd4', jobId: 'jb3', stage: 'applied', score: 60, fit: 3, appliedDate: '2026-06-20', interviewAt: '2026-07-15T15:30:00Z' },
123
+ { id: 'ap5', candidateId: 'cd5', jobId: 'jb4', stage: 'hired', score: 95, fit: 5, appliedDate: '2026-05-28', interviewAt: '2026-06-18T11:00:00Z' },
124
+ { id: 'ap6', candidateId: 'cd6', jobId: 'jb1', stage: 'rejected', score: 48, fit: 2, appliedDate: '2026-06-08', interviewAt: '2026-06-25T13:00:00Z' },
125
+ { id: 'ap7', candidateId: 'cd7', jobId: 'jb6', stage: 'interview', score: 79, fit: 4, appliedDate: '2026-06-22', interviewAt: '2026-07-14T09:00:00Z' },
126
+ { id: 'ap8', candidateId: 'cd3', jobId: 'jb4', stage: 'screen', score: 68, fit: 3, appliedDate: '2026-06-25', interviewAt: '2026-07-16T16:00:00Z' },
127
+ ],
128
+ }
129
+
130
+ export const ats: SampleApp = {
131
+ id: 'ats',
132
+ name: 'Recruiting',
133
+ description: 'Jobs, candidates and an application pipeline - hiring + openings + candidate dashboards (score KPI sparkline + gauge, applications-over-time trend, department x status pivot, tabbed breakdown), status-pill grids with score/rating conditional formatting, filters, row actions, master/detail, and rich edit forms.',
134
+ emoji: '\u{1F4DD}',
135
+ accent: '#7c3aed',
136
+ build: () => {
137
+ const jobRows = pad(jobs, seed.jobs, 12)
138
+ const candidateRows = pad(candidates, seed.candidates, 20)
139
+ const jobPool = ids(jobRows)
140
+ const candidatePool = ids(candidateRows)
141
+ const applicationRows = pad(applications, seed.applications, 30, { jobId: jobPool, candidateId: candidatePool })
142
+ return project({
143
+ title: 'HireDesk',
144
+ brand: 'HireDesk',
145
+ accent: '#7c3aed',
146
+ preset: 'ag-alpine',
147
+ footer: '',
148
+ entities: [jobs, candidates, applications],
149
+ seed: { jobs: jobRows, candidates: candidateRows, applications: applicationRows },
150
+ screens: [
151
+ // Hiring pipeline: score KPI (sparkline over applied date) + a score gauge,
152
+ // stage breakdowns, an applications-over-time trend, a tabbed view, a filter,
153
+ // and a stage-pill grid with score conditional formatting.
154
+ dashScreen(applications, { id: 'overview', title: 'Pipeline', order: 0 }, [
155
+ { kpi: 'Applications', reduce: 'count', span: 1 },
156
+ { kpi: 'Avg score', measure: 'score', reduce: 'avg', trendField: 'appliedDate', trendReduce: 'avg', span: 1 },
157
+ { kpi: 'Top score', measure: 'score', reduce: 'max', span: 1 },
158
+ { gauge: 'Avg fit score', measure: 'score', reduce: 'avg', min: 0, max: 100, span: 1 },
159
+ { chart: 'stage', reduce: 'count', type: 'bar', span: 2 },
160
+ { chart: 'stage', reduce: 'count', type: 'pie', span: 1 },
161
+ { chart: 'appliedDate', reduce: 'count', type: 'area', span: 3 },
162
+ { tabs: [
163
+ { label: 'By stage', tiles: [{ chart: 'stage', reduce: 'count', type: 'bar' }] },
164
+ { label: 'Score by stage', tiles: [{ chart: 'stage', measure: 'score', reduce: 'avg', type: 'bar' }] },
165
+ ], span: 3 },
166
+ { filter: ['stage'], span: 3 },
167
+ { grid: true, format: appFormats, summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
168
+ ]),
169
+ // The signature ATS view: a candidate pipeline as draggable Kanban columns
170
+ // by stage (Applied -> Hired), each card the candidate name + job + score.
171
+ boardScreen(applications, { id: 'pipeline-board', title: 'Board', order: 1 }, { groupBy: 'stage', titleField: 'candidateId', subtitleField: 'jobId', badgeField: 'score', filter: ['stage', 'jobId'] }),
172
+ // Openings: headcount KPIs, an openings gauge, department breakdowns, a
173
+ // department x status pivot, tabs, and a status-pill job grid.
174
+ dashScreen(jobs, { id: 'openings', title: 'Openings', order: 2 }, [
175
+ { kpi: 'Open roles', reduce: 'count', span: 1 },
176
+ { kpi: 'Total openings', measure: 'openings', reduce: 'sum', span: 1 },
177
+ { kpi: 'Avg salary', measure: 'salaryMax', reduce: 'avg', format: 'compact', span: 1 },
178
+ { gauge: 'Total openings', measure: 'openings', reduce: 'sum', min: 0, max: 30, span: 1 },
179
+ { chart: 'department', measure: 'openings', reduce: 'sum', type: 'bar', span: 2 },
180
+ { chart: 'status', reduce: 'count', type: 'pie', span: 1 },
181
+ { pivot: { rows: ['department'], cols: ['status'], measure: 'openings', aggregate: 'sum' }, span: 3 },
182
+ { tabs: [
183
+ { label: 'Openings by dept', tiles: [{ chart: 'department', measure: 'openings', reduce: 'sum', type: 'bar' }] },
184
+ { label: 'Roles by status', tiles: [{ chart: 'status', reduce: 'count', type: 'bar' }] },
185
+ ], span: 3 },
186
+ { grid: true, format: jobFormats, summaries: true, rowActions: [{ kind: 'edit' }], span: 3 },
187
+ ]),
188
+ detailScreen(jobs, { id: 'job-detail', title: 'Job detail', order: 3 }, {
189
+ titleField: 'title', subtitleField: 'department', statusField: 'status',
190
+ metricFields: ['openings', 'salaryMax'],
191
+ related: [{ entity: 'applications', foreignKey: 'jobId', label: 'Applications', titleField: 'candidateId', subtitleField: 'stage', dateField: 'appliedDate', statusField: 'stage' }],
192
+ }),
193
+ screen(jobs, 'master-detail', { id: 'jobs', title: 'Jobs', order: 4, child: applications, foreignKey: 'jobId', linkScreen: 'job-detail' }),
194
+ formScreen(applications, { id: 'applications', title: 'Applications', order: 5 }, undefined, { format: appFormats, summaries: true, rowActions: [{ kind: 'edit' }] }, ['stage']),
195
+ formScreen(candidates, { id: 'candidates', title: 'Candidates', order: 6 }, undefined, { format: candidateFormats, summaries: true, rowActions: [{ kind: 'edit' }] }, ['source']),
196
+ // Interview schedule: applications placed on a month calendar by their
197
+ // scheduled interview slot (card title resolves to the candidate name).
198
+ calendarScreen(applications, { id: 'interviews', title: 'Interviews', order: 7 }, { dateField: 'interviewAt', titleField: 'candidateId', filter: ['stage', 'jobId'] }),
199
+ ],
200
+ })
201
+ },
202
+ }