@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.
- package/dist/cdn/svgrid-enterprise.svelte-external.js +26039 -805
- package/dist/designer/assets/index-Dp44bTid.js +939 -0
- package/dist/designer/assets/index-RJp6x8tw.css +1 -0
- package/dist/designer/assets/jszip.min-CjMo-QGg.js +2 -0
- package/dist/designer/index.html +13 -0
- package/dist/node/studio.js +22554 -0
- package/package.json +10 -3
- package/src/SvAuthGate.svelte +115 -0
- package/src/SvBoard.dom.test.ts +67 -0
- package/src/SvBoard.svelte +192 -0
- package/src/SvExportMenu.svelte +553 -0
- package/src/SvFileInput.svelte +113 -0
- package/src/SvGridEditPanel.dom.test.ts +146 -0
- package/src/SvGridEditPanel.svelte +793 -0
- package/src/SvGridMasterDetail.svelte +95 -0
- package/src/SvImportDialog.svelte +1020 -0
- package/src/SvLookupInput.svelte +180 -0
- package/src/SvRecordDetail.dom.test.ts +137 -0
- package/src/SvRecordDetail.svelte +288 -0
- package/src/SvSchedule.dom.test.ts +57 -0
- package/src/SvSchedule.svelte +156 -0
- package/src/SvSchemaChart.svelte +233 -0
- package/src/SvSchemaDashboard.svelte +130 -0
- package/src/ai-export-pdf.test.ts +74 -0
- package/src/ai-export-xlsx.test.ts +87 -0
- package/src/ai-export.test.ts +110 -0
- package/src/ai.ts +1188 -782
- package/src/edit-panel.test.ts +213 -0
- package/src/edit-panel.ts +228 -0
- package/src/export-conditional.test.ts +60 -0
- package/src/export-conditional.ts +94 -0
- package/src/export-ooxml.test.ts +152 -0
- package/src/export-ooxml.ts +485 -0
- package/src/export-pdf.test.ts +138 -0
- package/src/export-pdf.ts +245 -0
- package/src/export-print.test.ts +66 -0
- package/src/export-print.ts +132 -0
- package/src/export-serialize.test.ts +56 -0
- package/src/export-serialize.ts +24 -0
- package/src/export-xls.ts +196 -0
- package/src/export-xlsx-roundtrip.test.ts +120 -0
- package/src/export-xlsx.test.ts +150 -0
- package/src/export.test.ts +349 -0
- package/src/export.ts +1452 -549
- package/src/import-automap.test.ts +131 -0
- package/src/import-hardening.test.ts +158 -0
- package/src/import.ts +1042 -648
- package/src/index.ts +282 -0
- package/src/install.ts +134 -114
- package/src/license-core.test.ts +23 -0
- package/src/license-core.ts +35 -0
- package/src/license.ts +103 -90
- package/src/master-detail.test.ts +61 -0
- package/src/master-detail.ts +42 -0
- package/src/print.ts +107 -127
- package/src/schema-designer.test.ts +121 -0
- package/src/schema-designer.ts +142 -0
- package/src/schema.test.ts +268 -0
- package/src/schema.ts +499 -0
- package/src/smart-shim.ts +107 -105
- package/src/sources/aggregate.test.ts +79 -0
- package/src/sources/aggregate.ts +102 -0
- package/src/sources/auth-supabase.test.ts +80 -0
- package/src/sources/auth-supabase.ts +87 -0
- package/src/sources/dashboard.kpi.test.ts +50 -0
- package/src/sources/dashboard.test.ts +61 -0
- package/src/sources/dashboard.ts +136 -0
- package/src/sources/field-inference.test.ts +60 -0
- package/src/sources/field-inference.ts +63 -0
- package/src/sources/filters.test.ts +58 -0
- package/src/sources/filters.ts +64 -0
- package/src/sources/index.ts +67 -0
- package/src/sources/introspect-supabase.test.ts +100 -0
- package/src/sources/introspect-supabase.ts +119 -0
- package/src/sources/realtime-supabase.test.ts +93 -0
- package/src/sources/realtime-supabase.ts +99 -0
- package/src/sources/relation-lookup.test.ts +99 -0
- package/src/sources/relation-lookup.ts +128 -0
- package/src/sources/rest-adapters.test.ts +95 -0
- package/src/sources/rest-adapters.ts +120 -0
- package/src/sources/rest.test.ts +104 -0
- package/src/sources/rest.ts +129 -0
- package/src/sources/schema-from-columns.test.ts +106 -0
- package/src/sources/schema-from-columns.ts +88 -0
- package/src/sources/supabase.test.ts +110 -0
- package/src/sources/supabase.ts +99 -0
- package/src/sources/with-entity-rules.test.ts +104 -0
- package/src/sources/with-entity-rules.ts +78 -0
- package/src/sources/with-relation-labels.test.ts +75 -0
- package/src/sources/with-relation-labels.ts +73 -0
- package/src/studio/bug-report.test.ts +101 -0
- package/src/studio/bug-report.ts +165 -0
- package/src/studio/cli.test.ts +187 -0
- package/src/studio/cli.ts +112 -0
- package/src/studio/csv.test.ts +90 -0
- package/src/studio/csv.ts +165 -0
- package/src/studio/db-connect-string.test.ts +94 -0
- package/src/studio/db-connect-string.ts +128 -0
- package/src/studio/emit-project.test.ts +923 -0
- package/src/studio/emit-project.ts +1273 -0
- package/src/studio/emit-schema.test.ts +94 -0
- package/src/studio/emit-schema.ts +920 -0
- package/src/studio/index.ts +195 -0
- package/src/studio/introspect-db.test.ts +186 -0
- package/src/studio/introspect-db.ts +312 -0
- package/src/studio/introspect-prisma.test.ts +99 -0
- package/src/studio/introspect-prisma.ts +175 -0
- package/src/studio/introspect.test.ts +172 -0
- package/src/studio/introspect.ts +310 -0
- package/src/studio/pipeline.test.ts +42 -0
- package/src/studio/project-robust.test.ts +157 -0
- package/src/studio/project.test.ts +473 -0
- package/src/studio/project.ts +916 -0
- package/src/studio/sample-data.test.ts +58 -0
- package/src/studio/sample-data.ts +200 -0
- package/src/studio/samples/ats.ts +202 -0
- package/src/studio/samples/clinic.ts +177 -0
- package/src/studio/samples/crm.ts +250 -0
- package/src/studio/samples/ecommerce.ts +187 -0
- package/src/studio/samples/events.ts +199 -0
- package/src/studio/samples/fleet.ts +184 -0
- package/src/studio/samples/gym.ts +213 -0
- package/src/studio/samples/hr.ts +193 -0
- package/src/studio/samples/index.ts +55 -0
- package/src/studio/samples/insurance.ts +195 -0
- package/src/studio/samples/inventory.ts +182 -0
- package/src/studio/samples/invoicing.ts +162 -0
- package/src/studio/samples/library.ts +180 -0
- package/src/studio/samples/live-data.test.ts +98 -0
- package/src/studio/samples/live-data.ts +308 -0
- package/src/studio/samples/projects.ts +190 -0
- package/src/studio/samples/realestate.ts +196 -0
- package/src/studio/samples/restaurant.ts +187 -0
- package/src/studio/samples/samples.test.ts +208 -0
- package/src/studio/samples/school.ts +197 -0
- package/src/studio/samples/seed-floor.test.ts +25 -0
- package/src/studio/samples/shared.ts +305 -0
- package/src/studio/samples/subscriptions.ts +183 -0
- package/src/studio/samples/support.ts +182 -0
- package/src/studio/scaffold-app.test.ts +91 -0
- package/src/studio/scaffold-app.ts +161 -0
- package/src/studio/scaffold.test.ts +178 -0
- package/src/studio/scaffold.ts +374 -0
- package/src/studio/themes.ts +172 -0
- package/src/studio/verify.test.ts +47 -0
- package/src/studio/verify.ts +79 -0
- package/src/sveltekit/in-memory.test.ts +104 -0
- package/src/sveltekit/in-memory.ts +129 -0
- package/src/sveltekit/index.ts +23 -0
- package/src/sveltekit/query-plan.test.ts +109 -0
- package/src/sveltekit/query-plan.ts +125 -0
- package/src/sveltekit/sql-source.test.ts +185 -0
- package/src/sveltekit/sql-source.ts +166 -0
- package/src/sveltekit/sql.test.ts +80 -0
- package/src/sveltekit/sql.ts +131 -0
- package/src/sveltekit/transport.test.ts +175 -0
- package/src/sveltekit/transport.ts +254 -0
- package/src/sveltekit/types.ts +11 -0
- 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
|
+
}
|