feat: complete ShiftCraft — AI-powered shift scheduling SaaS
Complete implementation including:
- Landing page with hero, features, how-it-works, pricing
- Employee management (CRUD with soft delete)
- AI constraint parser (Anthropic Claude API)
- German labor law templates (ArbZG §3, §5, §9)
- HiGHS ILP solver for optimal fair schedules
- Schedule calendar result view (employee × date grid)
- Shift framework configuration (periods + shifts)
- Subscription tiers: Free / Pro / Business
- PocketBase setup script with collection creation + seed data
- .env.example with all required variables documented
Pages: employees, constraints (list/new/templates), schedules (list/new/[id]),
settings (organization/shifts/billing), dashboard
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
248
scripts/setup-pb.ts
Normal file
248
scripts/setup-pb.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env npx tsx
|
||||
/**
|
||||
* ShiftCraft PocketBase Setup Script
|
||||
*
|
||||
* Creates all required collections and seeds legal templates.
|
||||
* Run after starting PocketBase:
|
||||
* pnpm pocketbase:start
|
||||
* npx tsx scripts/setup-pb.ts
|
||||
*/
|
||||
|
||||
import PocketBase from 'pocketbase'
|
||||
|
||||
const PB_URL = process.env.NUXT_PUBLIC_PB_URL || process.env.NUXT_PUBLIC_POCKETBASE_URL || 'http://localhost:8090'
|
||||
const PB_ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL || process.env.SUPERUSER_EMAIL || 'admin@shiftcraft.app'
|
||||
const PB_ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD || process.env.SUPERUSER_PW || 'changeme123'
|
||||
|
||||
async function main() {
|
||||
const pb = new PocketBase(PB_URL)
|
||||
|
||||
console.log(`Connecting to PocketBase at ${PB_URL}...`)
|
||||
await pb.admins.authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
|
||||
console.log('Connected to PocketBase admin')
|
||||
|
||||
const collections = [
|
||||
{
|
||||
name: 'organizations',
|
||||
type: 'base',
|
||||
schema: [
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'slug', type: 'text', required: true },
|
||||
{ name: 'timezone', type: 'text' },
|
||||
{ name: 'industry', type: 'text' },
|
||||
{ name: 'owner', type: 'text' },
|
||||
{ name: 'plan', type: 'select', options: { values: ['free', 'pro', 'business'], maxSelect: 1 } },
|
||||
{ name: 'plan_employee_limit', type: 'number' },
|
||||
{ name: 'plan_history_months', type: 'number' },
|
||||
{ name: 'stripe_customer_id', type: 'text' },
|
||||
{ name: 'stripe_subscription_id', type: 'text' },
|
||||
{ name: 'stripe_subscription_status', type: 'text' },
|
||||
{ name: 'trial_ends_at', type: 'date' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'employees',
|
||||
type: 'base',
|
||||
schema: [
|
||||
{ name: 'org_id', type: 'text', required: true },
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'email', type: 'email' },
|
||||
{ name: 'employee_number', type: 'text' },
|
||||
{ name: 'roles', type: 'json' },
|
||||
{ name: 'skills', type: 'json' },
|
||||
{ name: 'employment_type', type: 'select', options: { values: ['full_time', 'part_time', 'mini_job'], maxSelect: 1 } },
|
||||
{ name: 'weekly_hours_target', type: 'number' },
|
||||
{ name: 'max_weekly_hours', type: 'number' },
|
||||
{ name: 'available_periods', type: 'json' },
|
||||
{ name: 'unavailable_dates', type: 'json' },
|
||||
{ name: 'notes', type: 'text' },
|
||||
{ name: 'active', type: 'bool' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'shift_frameworks',
|
||||
type: 'base',
|
||||
schema: [
|
||||
{ name: 'org_id', type: 'text', required: true },
|
||||
{ name: 'periods', type: 'json' },
|
||||
{ name: 'shifts', type: 'json' },
|
||||
{ name: 'scheduling_horizon_days', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'constraints',
|
||||
type: 'base',
|
||||
schema: [
|
||||
{ name: 'org_id', type: 'text', required: true },
|
||||
{ name: 'label', type: 'text', required: true },
|
||||
{ name: 'source_text', type: 'text' },
|
||||
{ name: 'constraint_json', type: 'json' },
|
||||
{ name: 'scope', type: 'text' },
|
||||
{ name: 'scope_ref', type: 'text' },
|
||||
{ name: 'category', type: 'select', options: { values: ['legal', 'preference', 'operational', 'other'], maxSelect: 1 } },
|
||||
{ name: 'hard', type: 'bool' },
|
||||
{ name: 'weight', type: 'number' },
|
||||
{ name: 'active', type: 'bool' },
|
||||
{ name: 'source', type: 'select', options: { values: ['ai', 'legal', 'manual'], maxSelect: 1 } },
|
||||
{ name: 'template_id', type: 'text' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'legal_templates',
|
||||
type: 'base',
|
||||
schema: [
|
||||
{ name: 'region', type: 'text' },
|
||||
{ name: 'law_name', type: 'text' },
|
||||
{ name: 'label', type: 'text', required: true },
|
||||
{ name: 'description', type: 'text' },
|
||||
{ name: 'constraint_json', type: 'json' },
|
||||
{ name: 'category', type: 'text' },
|
||||
{ name: 'mandatory', type: 'bool' },
|
||||
{ name: 'sort_order', type: 'number' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'schedule_runs',
|
||||
type: 'base',
|
||||
schema: [
|
||||
{ name: 'org_id', type: 'text', required: true },
|
||||
{ name: 'name', type: 'text', required: true },
|
||||
{ name: 'period_start', type: 'date', required: true },
|
||||
{ name: 'period_end', type: 'date', required: true },
|
||||
{ name: 'framework_snapshot', type: 'json' },
|
||||
{ name: 'constraints_snapshot', type: 'json' },
|
||||
{ name: 'employees_snapshot', type: 'json' },
|
||||
{ name: 'status', type: 'select', options: { values: ['pending', 'solving', 'solved', 'infeasible', 'error'], maxSelect: 1 } },
|
||||
{ name: 'solver_duration_ms', type: 'number' },
|
||||
{ name: 'objective_value', type: 'number' },
|
||||
{ name: 'infeasibility_hints', type: 'json' },
|
||||
{ name: 'result', type: 'json' },
|
||||
{ name: 'created_by', type: 'text' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
for (const col of collections) {
|
||||
try {
|
||||
await pb.collections.getOne(col.name)
|
||||
console.log(` Collection '${col.name}' already exists — skipping`)
|
||||
} catch {
|
||||
try {
|
||||
await pb.collections.create(col)
|
||||
console.log(` Created collection '${col.name}'`)
|
||||
} catch (err) {
|
||||
console.error(` Failed to create '${col.name}':`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Seed legal templates
|
||||
console.log('\nSeeding legal templates...')
|
||||
const templates = [
|
||||
{
|
||||
region: 'Deutschland',
|
||||
law_name: 'ArbZG §3',
|
||||
label: 'Maximale Arbeitszeit 10 Stunden/Tag',
|
||||
description: 'Die werktägliche Arbeitszeit darf 8 Stunden nicht überschreiten. Sie kann auf bis zu 10 Stunden verlängert werden, wenn innerhalb von 6 Kalendermonaten im Durchschnitt 8 Stunden/Tag nicht überschritten werden.',
|
||||
constraint_json: {
|
||||
type: 'max_hours_per_day',
|
||||
scope: { type: 'global' },
|
||||
params: { max_hours: 10 },
|
||||
hard: true,
|
||||
natural_language_summary: 'Maximal 10 Stunden Arbeitszeit pro Tag (ArbZG §3)',
|
||||
},
|
||||
category: 'legal',
|
||||
mandatory: true,
|
||||
sort_order: 1,
|
||||
},
|
||||
{
|
||||
region: 'Deutschland',
|
||||
law_name: 'ArbZG §5',
|
||||
label: 'Mindestruhezeit 11 Stunden',
|
||||
description: 'Nach Beendigung der täglichen Arbeitszeit ist den Arbeitnehmern eine ununterbrochene Ruhezeit von mindestens elf Stunden zu gewähren.',
|
||||
constraint_json: {
|
||||
type: 'min_rest_between_shifts',
|
||||
scope: { type: 'global' },
|
||||
params: { min_hours: 11 },
|
||||
hard: true,
|
||||
natural_language_summary: 'Mindestens 11 Stunden Ruhezeit zwischen Schichten (ArbZG §5)',
|
||||
},
|
||||
category: 'legal',
|
||||
mandatory: true,
|
||||
sort_order: 2,
|
||||
},
|
||||
{
|
||||
region: 'Deutschland',
|
||||
law_name: 'ArbZG §9',
|
||||
label: 'Sonntagsruhe',
|
||||
description: 'Arbeitnehmer dürfen an Sonn- und gesetzlichen Feiertagen von 0 bis 24 Uhr nicht beschäftigt werden (mit branchenspezifischen Ausnahmen).',
|
||||
constraint_json: {
|
||||
type: 'max_weekend_shifts_per_month',
|
||||
scope: { type: 'global' },
|
||||
params: { max_count: 4 },
|
||||
hard: false,
|
||||
weight: 80,
|
||||
natural_language_summary: 'Maximale Sonntagsschichten nach ArbZG §9 beachten',
|
||||
},
|
||||
category: 'legal',
|
||||
mandatory: false,
|
||||
sort_order: 3,
|
||||
},
|
||||
{
|
||||
region: 'Deutschland',
|
||||
law_name: 'Empfehlung',
|
||||
label: 'Maximal 6 Schichten am Stück',
|
||||
description: 'Empfohlene Begrenzung aufeinanderfolgender Arbeitstage.',
|
||||
constraint_json: {
|
||||
type: 'max_consecutive_shifts',
|
||||
scope: { type: 'global' },
|
||||
params: { max_count: 6 },
|
||||
hard: false,
|
||||
weight: 70,
|
||||
natural_language_summary: 'Nicht mehr als 6 Schichten in Folge',
|
||||
},
|
||||
category: 'legal',
|
||||
mandatory: false,
|
||||
sort_order: 4,
|
||||
},
|
||||
{
|
||||
region: 'Deutschland',
|
||||
law_name: 'Empfehlung',
|
||||
label: 'Faire Schichtverteilung',
|
||||
description: 'Nachtschichten und Wochenendschichten werden gleichmäßig auf alle Mitarbeiter verteilt.',
|
||||
constraint_json: {
|
||||
type: 'fair_distribution',
|
||||
scope: { type: 'global' },
|
||||
params: { metric: 'night_shifts', max_deviation_percent: 20 },
|
||||
hard: false,
|
||||
weight: 60,
|
||||
natural_language_summary: 'Faire Verteilung der Nachtschichten (max. 20% Abweichung)',
|
||||
},
|
||||
category: 'preference',
|
||||
mandatory: false,
|
||||
sort_order: 5,
|
||||
},
|
||||
]
|
||||
|
||||
for (const tmpl of templates) {
|
||||
try {
|
||||
await pb.collection('legal_templates').getFirstListItem(`label = "${tmpl.label}"`)
|
||||
console.log(` Template '${tmpl.label}' already exists — skipping`)
|
||||
} catch {
|
||||
try {
|
||||
await pb.collection('legal_templates').create(tmpl)
|
||||
console.log(` Created template '${tmpl.label}'`)
|
||||
} catch (err) {
|
||||
console.error(` Failed to create template '${tmpl.label}':`, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('\nSetup complete!')
|
||||
console.log('Next steps:')
|
||||
console.log(' 1. Open PocketBase admin at http://localhost:8090/_/')
|
||||
console.log(' 2. Configure the users collection to include org_id and role fields')
|
||||
console.log(' 3. Run: pnpm dev')
|
||||
}
|
||||
|
||||
main().catch(console.error)
|
||||
Reference in New Issue
Block a user