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,280 @@
<template>
<div class="max-w-3xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Schichtrahmen</h1>
<p class="text-gray-500 mt-1 text-sm">Definieren Sie die Schichten und Zeiträume für Ihre Organisation</p>
</div>
<!-- Settings nav -->
<div class="flex gap-1 mb-6 border-b border-gray-100">
<NuxtLink
v-for="tab in settingsTabs"
:key="tab.to"
:to="tab.to"
class="px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px"
:class="$route.path === tab.to
? 'border-indigo-600 text-indigo-700'
: 'border-transparent text-gray-500 hover:text-gray-700'"
>
{{ tab.label }}
</NuxtLink>
</div>
<div v-if="loading" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 text-center text-gray-400">
<UIcon name="i-lucide-loader-2" class="w-6 h-6 animate-spin mx-auto mb-2" />
Lade Schichtrahmen...
</div>
<div v-else class="space-y-6">
<!-- Periods section -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-gray-900">Schichtzeiträume</h2>
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-plus" @click="addPeriod">
Zeitraum hinzufügen
</UButton>
</div>
<div class="space-y-3">
<div
v-for="(period, idx) in periods"
:key="period.id"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-xl"
>
<input
type="color"
v-model="period.color"
class="w-8 h-8 rounded-lg cursor-pointer border border-gray-200 p-0.5"
/>
<UInput v-model="period.name" placeholder="Name" size="sm" class="w-32" />
<UInput v-model="period.start_time" type="time" size="sm" class="w-28" />
<span class="text-gray-400 text-sm"></span>
<UInput v-model="period.end_time" type="time" size="sm" class="w-28" />
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
class="ml-auto"
@click="periods.splice(idx, 1)"
/>
</div>
<div v-if="periods.length === 0" class="text-center py-6 text-gray-400 text-sm">
Noch keine Schichtzeiträume. Fügen Sie z.B. Früh-, Spät- und Nachtschicht hinzu.
</div>
</div>
</div>
<!-- Shifts section -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-gray-900">Schichten</h2>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
:disabled="periods.length === 0"
@click="addShift"
>
Schicht hinzufügen
</UButton>
</div>
<div class="space-y-3">
<div
v-for="(shift, idx) in shifts"
:key="shift.id"
class="p-4 bg-gray-50 rounded-xl"
>
<div class="flex items-center gap-3 mb-3">
<UInput v-model="shift.name" placeholder="Schichtname" size="sm" class="flex-1" />
<USelect
v-model="shift.period_id"
:options="periodOptions"
size="sm"
class="w-36"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
@click="shifts.splice(idx, 1)"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<UFormField label="Stunden/Schicht" size="sm">
<UInput v-model.number="shift.duration_hours" type="number" min="1" max="24" step="0.5" size="sm" class="w-full" />
</UFormField>
<UFormField label="Mitarbeiter benötigt" size="sm">
<UInput v-model.number="shift.workers_required" type="number" min="1" max="100" size="sm" class="w-full" />
</UFormField>
<UFormField label="Wochentage" size="sm">
<p class="text-xs text-gray-500 mt-1">(leer = alle Tage)</p>
<div class="flex gap-1 mt-1">
<button
v-for="(day, d) in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']"
:key="d"
class="px-1.5 py-0.5 rounded text-xs font-medium transition-colors"
:class="shift.days_applicable.includes(d)
? 'bg-indigo-600 text-white'
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'"
@click="toggleDay(shift, d)"
>
{{ day }}
</button>
</div>
</UFormField>
</div>
</div>
<div v-if="shifts.length === 0" class="text-center py-6 text-gray-400 text-sm">
Noch keine Schichten. Erstellen Sie zuerst einen Schichtzeitraum.
</div>
</div>
</div>
<!-- Horizon -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 class="font-bold text-gray-900 mb-4">Planungshorizont</h2>
<UFormField label="Maximale Planungsperiode (Tage)">
<UInput v-model.number="schedulingHorizonDays" type="number" min="7" max="365" class="w-32" />
</UFormField>
</div>
<div class="flex gap-3">
<UButton color="primary" :loading="saving" @click="save">
Schichtrahmen speichern
</UButton>
<UButton color="neutral" variant="ghost" @click="loadFramework">
Zurücksetzen
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ShiftFramework } from '~/shared/types/pocketbase'
import type { Period, Shift } from '~/shared/types/schedule'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const loading = ref(true)
const saving = ref(false)
const frameworkId = ref<string | null>(null)
const periods = ref<Period[]>([])
const shifts = ref<Shift[]>([])
const schedulingHorizonDays = ref(28)
const settingsTabs = [
{ to: '/settings/organization', label: 'Organisation' },
{ to: '/settings/shifts', label: 'Schichten' },
{ to: '/settings/billing', label: 'Abonnement' },
]
const periodOptions = computed(() =>
periods.value.map(p => ({ label: p.name, value: p.id }))
)
function generateId() {
return Math.random().toString(36).slice(2, 10)
}
function addPeriod() {
const colors = ['#6366f1', '#f59e0b', '#1e293b', '#10b981']
periods.value.push({
id: generateId(),
name: '',
start_time: '06:00',
end_time: '14:00',
color: colors[periods.value.length % colors.length],
})
}
function addShift() {
if (periods.value.length === 0) return
shifts.value.push({
id: generateId(),
period_id: periods.value[0].id,
name: '',
duration_hours: 8,
workers_required: 2,
days_applicable: [],
})
}
function toggleDay(shift: Shift, day: number) {
const idx = shift.days_applicable.indexOf(day)
if (idx === -1) {
shift.days_applicable.push(day)
} else {
shift.days_applicable.splice(idx, 1)
}
}
async function loadFramework() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
try {
const result = await pb.collection('shift_frameworks').getFirstListItem(`org_id = "${orgId}"`)
const fw = result as unknown as ShiftFramework
frameworkId.value = fw.id
periods.value = fw.periods ? JSON.parse(JSON.stringify(fw.periods)) : []
shifts.value = fw.shifts ? JSON.parse(JSON.stringify(fw.shifts)) : []
schedulingHorizonDays.value = fw.scheduling_horizon_days || 28
} catch {
// No framework yet — start fresh
frameworkId.value = null
periods.value = [
{ id: generateId(), name: 'Frühschicht', start_time: '06:00', end_time: '14:00', color: '#6366f1' },
{ id: generateId(), name: 'Spätschicht', start_time: '14:00', end_time: '22:00', color: '#f59e0b' },
{ id: generateId(), name: 'Nachtschicht', start_time: '22:00', end_time: '06:00', color: '#1e293b' },
]
shifts.value = []
}
}
async function save() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
saving.value = true
try {
const data = {
org_id: orgId,
periods: periods.value,
shifts: shifts.value,
scheduling_horizon_days: schedulingHorizonDays.value,
}
if (frameworkId.value) {
await pb.collection('shift_frameworks').update(frameworkId.value, data)
} else {
const created = await pb.collection('shift_frameworks').create(data)
frameworkId.value = created.id
}
toast.add({ color: 'success', title: 'Schichtrahmen gespeichert' })
} catch (err) {
toast.add({ color: 'error', title: 'Fehler beim Speichern', description: String(err) })
} finally {
saving.value = false
}
}
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
await loadFramework()
loading.value = false
})
</script>