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,218 @@
<template>
<div>
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">
Guten {{ greeting }}, {{ firstName }}! 👋
</h1>
<p class="text-gray-500 mt-1">Hier ist ein Überblick über Ihr ShiftCraft-Konto</p>
</div>
<!-- Stats grid -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
<div v-for="stat in stats" :key="stat.label" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
<div class="flex items-center justify-between mb-3">
<p class="text-sm text-gray-500 font-medium">{{ stat.label }}</p>
<div class="w-9 h-9 rounded-xl flex items-center justify-center" :class="stat.iconBg">
<UIcon :name="stat.icon" class="w-5 h-5" :class="stat.iconColor" />
</div>
</div>
<p class="text-3xl font-bold text-gray-900">{{ stat.loading ? '...' : stat.value }}</p>
<p v-if="stat.sub" class="text-xs text-gray-400 mt-1">{{ stat.sub }}</p>
</div>
</div>
<!-- Quick actions -->
<div class="grid md:grid-cols-3 gap-4 mb-8">
<div class="bg-gradient-to-br from-indigo-600 to-violet-600 rounded-2xl p-6 text-white">
<div class="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center mb-4">
<UIcon name="i-lucide-calendar-plus" class="w-5 h-5" />
</div>
<h3 class="font-bold text-lg mb-2">Neuer Schichtplan</h3>
<p class="text-indigo-100 text-sm mb-4">Erstellen Sie jetzt einen optimierten Schichtplan</p>
<NuxtLink to="/schedules/new">
<UButton color="white" variant="solid" size="sm">
Plan erstellen
</UButton>
</NuxtLink>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center mb-4">
<UIcon name="i-lucide-user-plus" class="w-5 h-5 text-emerald-600" />
</div>
<h3 class="font-bold text-gray-900 text-lg mb-2">Mitarbeiter hinzufügen</h3>
<p class="text-gray-500 text-sm mb-4">Verwalten Sie Ihr Team und deren Verfügbarkeiten</p>
<NuxtLink to="/employees">
<UButton color="neutral" variant="outline" size="sm">
Zu Mitarbeitern
</UButton>
</NuxtLink>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="w-10 h-10 rounded-xl bg-violet-50 flex items-center justify-center mb-4">
<UIcon name="i-lucide-sliders" class="w-5 h-5 text-violet-600" />
</div>
<h3 class="font-bold text-gray-900 text-lg mb-2">Bedingungen eingeben</h3>
<p class="text-gray-500 text-sm mb-4">Fügen Sie Regeln und Präferenzen in Textform hinzu</p>
<NuxtLink to="/constraints/new">
<UButton color="neutral" variant="outline" size="sm">
Bedingung hinzufügen
</UButton>
</NuxtLink>
</div>
</div>
<!-- Recent schedule runs -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
<h2 class="font-bold text-gray-900">Letzte Schichtpläne</h2>
<NuxtLink to="/schedules" class="text-sm text-indigo-600 hover:text-indigo-700 font-medium">Alle ansehen </NuxtLink>
</div>
<div v-if="loadingRuns" class="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 Schichtpläne...
</div>
<div v-else-if="recentRuns.length === 0" class="p-8 text-center">
<UIcon name="i-lucide-calendar" class="w-10 h-10 text-gray-300 mx-auto mb-3" />
<p class="text-gray-500 font-medium">Noch keine Schichtpläne</p>
<p class="text-gray-400 text-sm mt-1">Erstellen Sie Ihren ersten Schichtplan</p>
<NuxtLink to="/schedules/new">
<UButton color="primary" size="sm" class="mt-4">Plan erstellen</UButton>
</NuxtLink>
</div>
<div v-else class="divide-y divide-gray-50">
<div v-for="run in recentRuns" :key="run.id" class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
<div class="flex items-center gap-3">
<div class="w-9 h-9 rounded-xl flex items-center justify-center" :class="statusBg(run.status)">
<UIcon :name="statusIcon(run.status)" class="w-4 h-4" :class="statusIconColor(run.status)" />
</div>
<div>
<p class="font-medium text-gray-900 text-sm">{{ run.name }}</p>
<p class="text-xs text-gray-400">{{ formatDate(run.period_start) }} {{ formatDate(run.period_end) }}</p>
</div>
</div>
<div class="flex items-center gap-3">
<UBadge :color="statusColor(run.status)" variant="subtle" size="sm">{{ statusLabel(run.status) }}</UBadge>
<NuxtLink v-if="run.status === 'solved'" :to="`/schedules/${run.id}`">
<UButton color="neutral" variant="ghost" size="xs" icon="i-lucide-arrow-right" />
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { formatDate } from '~/app/utils/dateHelpers'
import type { PBScheduleRun } from '~/shared/types/pocketbase'
definePageMeta({ layout: 'default', middleware: 'auth' })
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const loadingRuns = ref(true)
const loadingStats = ref(true)
const recentRuns = ref<PBScheduleRun[]>([])
const employeeCount = ref(0)
const constraintCount = ref(0)
const now = new Date()
const hour = now.getHours()
const greeting = computed(() => {
if (hour < 12) return 'Morgen'
if (hour < 17) return 'Tag'
return 'Abend'
})
const firstName = computed(() => {
const name = authStore.value.record?.name as string || 'da'
return name.split(' ')[0]
})
const stats = computed(() => [
{
label: 'Mitarbeiter',
value: employeeCount.value,
icon: 'i-lucide-users',
iconBg: 'bg-indigo-50',
iconColor: 'text-indigo-600',
loading: loadingStats.value,
sub: `von ${orgStore.org?.plan_employee_limit ?? 5} erlaubt`,
},
{
label: 'Aktive Bedingungen',
value: constraintCount.value,
icon: 'i-lucide-sliders',
iconBg: 'bg-violet-50',
iconColor: 'text-violet-600',
loading: loadingStats.value,
sub: null,
},
{
label: 'Schichtpläne gesamt',
value: recentRuns.value.length,
icon: 'i-lucide-calendar',
iconBg: 'bg-emerald-50',
iconColor: 'text-emerald-600',
loading: loadingRuns.value,
sub: null,
},
{
label: 'Plan',
value: (orgStore.org?.plan ?? 'free').toUpperCase(),
icon: 'i-lucide-zap',
iconBg: 'bg-amber-50',
iconColor: 'text-amber-600',
loading: false,
sub: null,
},
])
function statusLabel(status: string) {
const map: Record<string, string> = { solved: 'Gelöst', pending: 'Ausstehend', solving: 'Läuft...', infeasible: 'Unlösbar', error: 'Fehler' }
return map[status] ?? status
}
function statusColor(status: string) {
const map: Record<string, 'success' | 'warning' | 'error' | 'neutral' | 'primary'> = { solved: 'success', pending: 'neutral', solving: 'primary', infeasible: 'warning', error: 'error' }
return map[status] ?? 'neutral'
}
function statusBg(status: string) {
const map: Record<string, string> = { solved: 'bg-green-50', pending: 'bg-gray-50', solving: 'bg-blue-50', infeasible: 'bg-amber-50', error: 'bg-red-50' }
return map[status] ?? 'bg-gray-50'
}
function statusIcon(status: string) {
const map: Record<string, string> = { solved: 'i-lucide-check-circle', pending: 'i-lucide-clock', solving: 'i-lucide-loader-2', infeasible: 'i-lucide-alert-triangle', error: 'i-lucide-x-circle' }
return map[status] ?? 'i-lucide-circle'
}
function statusIconColor(status: string) {
const map: Record<string, string> = { solved: 'text-green-600', pending: 'text-gray-500', solving: 'text-blue-600', infeasible: 'text-amber-600', error: 'text-red-600' }
return map[status] ?? 'text-gray-500'
}
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
if (!orgStore.org) await orgStore.fetchOrg(orgId)
try {
const [employees, constraints, runs] = await Promise.all([
pb.collection('employees').getList(1, 1, { filter: `org_id = "${orgId}" && active = true` }),
pb.collection('constraints').getList(1, 1, { filter: `org_id = "${orgId}" && active = true` }),
pb.collection('schedule_runs').getList(1, 5, { filter: `org_id = "${orgId}"`, sort: '-created' }),
])
employeeCount.value = employees.totalItems
constraintCount.value = constraints.totalItems
recentRuns.value = runs.items as unknown as PBScheduleRun[]
} catch (err) {
console.error('Dashboard load error', err)
} finally {
loadingStats.value = false
loadingRuns.value = false
}
})
</script>