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,31 @@
import type { ConstraintJSON } from '~/shared/types/constraint'
export function constraintToHuman(constraint: ConstraintJSON): string {
return constraint.natural_language_summary || formatConstraintFallback(constraint)
}
function formatConstraintFallback(c: ConstraintJSON): string {
const params = c.params as Record<string, unknown>
switch (c.type) {
case 'max_hours_per_day':
return `Maximal ${params.max_hours} Stunden pro Tag`
case 'max_hours_per_week':
return `Maximal ${params.max_hours} Stunden pro Woche`
case 'min_rest_between_shifts':
return `Mindestens ${params.min_hours} Stunden Ruhezeit zwischen Schichten`
case 'max_consecutive_shifts':
return `Maximal ${params.max_count} Schichten am Stück`
case 'max_consecutive_shift_type':
return `Maximal ${params.max_count} ${params.period_id}-Schichten am Stück`
case 'forbidden_shift_sequence':
return `${params.first_period_id}-Schicht darf nicht direkt auf ${params.second_period_id}-Schicht folgen`
case 'employee_avoids_period':
return `Mitarbeiter bevorzugt keine ${params.period_id}-Schichten`
case 'employee_prefers_period':
return `Mitarbeiter bevorzugt ${params.period_id}-Schichten`
case 'fair_distribution':
return `Faire Verteilung der ${params.metric === 'night_shifts' ? 'Nachtschichten' : 'Schichten'} (max. ${params.max_deviation_percent}% Abweichung)`
default:
return c.type.replace(/_/g, ' ')
}
}

42
app/utils/dateHelpers.ts Normal file
View File

@@ -0,0 +1,42 @@
export function formatDate(date: string | Date): string {
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(date))
}
export function formatDateShort(date: string | Date): string {
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }).format(new Date(date))
}
export function getDaysInRange(start: string, end: string): string[] {
const days: string[] = []
const current = new Date(start)
const endDate = new Date(end)
while (current <= endDate) {
days.push(current.toISOString().split('T')[0])
current.setDate(current.getDate() + 1)
}
return days
}
export function getWeekday(date: string): number {
const d = new Date(date)
return (d.getDay() + 6) % 7 // 0=Mon, 6=Sun
}
export function isWeekend(date: string): boolean {
const day = getWeekday(date)
return day === 5 || day === 6
}
export function addDays(date: string, days: number): string {
const d = new Date(date)
d.setDate(d.getDate() + days)
return d.toISOString().split('T')[0]
}
export function getWeekdayName(date: string): string {
return new Intl.DateTimeFormat('de-DE', { weekday: 'short' }).format(new Date(date))
}
export function getMonthName(date: string): string {
return new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }).format(new Date(date))
}

67
app/utils/planLimits.ts Normal file
View File

@@ -0,0 +1,67 @@
export const PLAN_LIMITS = {
free: {
name: 'Free',
price_eur_month: 0,
employee_limit: 5,
history_months: 1,
solve_runs_per_month: 10,
ai_parses_per_month: 20,
legal_templates: true,
pdf_export: false,
excel_export: false,
support: 'community',
stripe_price_id: null,
description: 'Für kleine Teams zum Ausprobieren',
features: [
'Bis zu 5 Mitarbeiter',
'10 Schichtpläne/Monat',
'Gesetzliche Vorlagen (Deutschland)',
'KI-Bedingungserkennung',
],
},
pro: {
name: 'Pro',
price_eur_month: 29,
employee_limit: 25,
history_months: 6,
solve_runs_per_month: 100,
ai_parses_per_month: 200,
legal_templates: true,
pdf_export: true,
excel_export: true,
support: 'email',
stripe_price_id: 'price_pro_monthly',
description: 'Für wachsende Teams',
features: [
'Bis zu 25 Mitarbeiter',
'100 Schichtpläne/Monat',
'PDF & Excel Export',
'6 Monate Verlauf',
'E-Mail Support',
],
},
business: {
name: 'Business',
price_eur_month: 99,
employee_limit: Infinity,
history_months: Infinity,
solve_runs_per_month: Infinity,
ai_parses_per_month: Infinity,
legal_templates: true,
pdf_export: true,
excel_export: true,
support: 'priority',
stripe_price_id: 'price_business_monthly',
description: 'Für große Unternehmen',
features: [
'Unbegrenzte Mitarbeiter',
'Unbegrenzte Schichtpläne',
'PDF & Excel Export',
'Unbegrenzter Verlauf',
'Priority Support',
'API-Zugang (demnächst)',
],
},
} as const
export type PlanTier = keyof typeof PLAN_LIMITS