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:
31
app/utils/constraintDisplay.ts
Normal file
31
app/utils/constraintDisplay.ts
Normal 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
42
app/utils/dateHelpers.ts
Normal 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
67
app/utils/planLimits.ts
Normal 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
|
||||
Reference in New Issue
Block a user