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:
2026-04-18 07:47:31 +02:00
parent 2ea4ca5d52
commit 36e0946ee4
38 changed files with 4254 additions and 133 deletions

View File

@@ -0,0 +1,98 @@
import { getAnthropicClient } from '~/server/utils/anthropic'
import { getPBAdminClient } from '~/server/utils/pb-admin'
import type { ParsedConstraintResult, ConstraintJSON } from '~/shared/types/constraint'
export default defineEventHandler(async (event): Promise<ParsedConstraintResult> => {
const body = await readBody(event)
const { text, org_id } = body
if (!text || !org_id) {
throw createError({ statusCode: 400, message: 'text and org_id are required' })
}
const pb = await getPBAdminClient()
// Fetch employees and framework for context
const [employeesResult, frameworkResult] = await Promise.allSettled([
pb.collection('employees').getFullList({ filter: `org_id = "${org_id}" && active = true`, fields: 'id,name,roles' }),
pb.collection('shift_frameworks').getFirstListItem(`org_id = "${org_id}"`),
])
const employees = employeesResult.status === 'fulfilled'
? employeesResult.value.map((e: { id: string; name: string; roles: string[] }) => ({ id: e.id, name: e.name, roles: e.roles }))
: []
const periods = frameworkResult.status === 'fulfilled'
? ((frameworkResult.value as { periods?: Array<{ id: string; name: string }> }).periods || [])
: []
const SYSTEM_PROMPT = `You are a scheduling constraint parser for a workforce management application called ShiftCraft.
Given free-text input from a manager, extract one or more scheduling constraints and output them as a JSON array.
AVAILABLE EMPLOYEES: ${JSON.stringify(employees)}
AVAILABLE SHIFT PERIODS: ${JSON.stringify(periods)}
CONSTRAINT TYPES YOU CAN USE:
- max_hours_per_day: params: {max_hours: number}
- max_hours_per_week: params: {max_hours: number}
- min_rest_between_shifts: params: {min_hours: number}
- max_consecutive_shifts: params: {max_count: number}
- max_consecutive_shift_type: params: {period_id: string, max_count: number}
- min_consecutive_days_off: params: {min_days: number}
- forbidden_shift_sequence: params: {first_period_id: string, second_period_id: string}
- employee_avoids_period: params: {period_id: string}
- employee_prefers_period: params: {period_id: string, prefer_count_per_week?: number}
- max_weekend_shifts_per_month: params: {max_count: number}
- fair_distribution: params: {metric: "total_shifts"|"night_shifts"|"weekend_shifts", max_deviation_percent: number}
RULES:
- Preference language ("doesn't like", "prefers", "mag keine", "bevorzugt") → hard: false, weight: 65
- Obligation language ("must not", "cannot", "never", "darf nicht") → hard: true
- Resolve employee names to their IDs from the list above. If ambiguous, use scope: {type: "global"}.
- Always include natural_language_summary in the same language as the input.
- Output ONLY a valid JSON array. No commentary.
OUTPUT FORMAT:
[
{
"type": "constraint_type",
"scope": {"type": "global"} | {"type": "employee", "employee_id": "..."} | {"type": "role", "role": "..."},
"params": {...},
"hard": true|false,
"weight": 1-100,
"natural_language_summary": "..."
}
]`
try {
const client = getAnthropicClient()
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 2048,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: text }],
})
const content = response.content[0]
if (content.type !== 'text') throw new Error('Unexpected response type')
// Extract JSON from response
const jsonMatch = content.text.match(/\[[\s\S]*\]/)
if (!jsonMatch) throw new Error('No JSON array found in response')
const constraints: ConstraintJSON[] = JSON.parse(jsonMatch[0])
const ambiguities: string[] = []
// Validate basic structure
for (const c of constraints) {
if (!c.type || !c.scope || !c.params) {
ambiguities.push(`Unvollständige Bedingung erkannt: ${JSON.stringify(c)}`)
}
}
return { constraints, ambiguities }
} catch (err) {
console.error('Constraint parse error:', err)
throw createError({ statusCode: 500, message: `KI-Fehler: ${String(err)}` })
}
})

View File

@@ -0,0 +1,47 @@
import { getPBAdminClient } from '~/server/utils/pb-admin'
import { solveSchedule } from '~/server/utils/solver'
import type { SolveInput } from '~/shared/types/schedule'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { run_id } = body
if (!run_id) throw createError({ statusCode: 400, message: 'run_id required' })
const pb = await getPBAdminClient()
// Fetch the schedule run
const run = await pb.collection('schedule_runs').getOne(run_id)
// Update status to solving
await pb.collection('schedule_runs').update(run_id, { status: 'solving' })
try {
const input: SolveInput = {
organization_id: run.org_id as string,
period_start: run.period_start as string,
period_end: run.period_end as string,
framework: run.framework_snapshot as SolveInput['framework'],
employees: run.employees_snapshot as SolveInput['employees'],
constraints: (run.constraints_snapshot as SolveInput['constraints']) || [],
}
const result = await solveSchedule(input)
await pb.collection('schedule_runs').update(run_id, {
status: result.status,
result: result.assignments,
objective_value: result.objective_value,
solver_duration_ms: result.duration_ms,
infeasibility_hints: result.infeasibility_hints || [],
})
return result
} catch (err) {
await pb.collection('schedule_runs').update(run_id, {
status: 'error',
infeasibility_hints: [{ description: String(err) }],
})
throw err
}
})