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,126 @@
<template>
<div>
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Schichtpläne</h1>
<p class="text-gray-500 mt-1 text-sm">Ihre optimierten Schichtplan-Berechnungen</p>
</div>
<NuxtLink to="/schedules/new">
<UButton color="primary" icon="i-lucide-calendar-plus">
Neuen Schichtplan erstellen
</UButton>
</NuxtLink>
</div>
<!-- Loading -->
<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 Schichtpläne...
</div>
<!-- Empty state -->
<div v-else-if="runs.length === 0" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-12 text-center">
<UIcon name="i-lucide-calendar" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 class="font-semibold text-gray-700 mb-1">Noch keine Schichtpläne</h3>
<p class="text-gray-400 text-sm mb-4">Berechnen Sie Ihren ersten optimierten Schichtplan.</p>
<NuxtLink to="/schedules/new">
<UButton color="primary" icon="i-lucide-calendar-plus">Ersten Plan erstellen</UButton>
</NuxtLink>
</div>
<!-- Run list -->
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
<div
v-for="run in runs"
:key="run.id"
class="px-6 py-4 flex items-center gap-4 hover:bg-gray-50 transition-colors"
>
<!-- Status icon -->
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" :class="statusBg(run.status)">
<UIcon
:name="statusIcon(run.status)"
class="w-5 h-5"
:class="[statusIconColor(run.status), run.status === 'solving' ? 'animate-spin' : '']"
/>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900">{{ run.name }}</p>
<p class="text-xs text-gray-400 mt-0.5">
{{ formatDate(run.period_start) }} {{ formatDate(run.period_end) }}
<span v-if="run.solver_duration_ms" class="ml-2">· {{ (run.solver_duration_ms / 1000).toFixed(1) }}s</span>
</p>
</div>
<!-- Status badge -->
<UBadge :color="statusColor(run.status)" variant="subtle" size="sm">
{{ statusLabel(run.status) }}
</UBadge>
<!-- Action -->
<NuxtLink v-if="run.status === 'solved'" :to="`/schedules/${run.id}`">
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-eye">
Ansehen
</UButton>
</NuxtLink>
<span v-else class="w-24" />
</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 loading = ref(true)
const runs = ref<PBScheduleRun[]>([])
function statusLabel(status: string) {
const map: Record<string, string> = { solved: 'Gelöst', pending: 'Ausstehend', solving: 'Berechnet...', infeasible: 'Nicht lösbar', error: 'Fehler' }
return map[status] ?? status
}
function statusColor(status: string): 'success' | 'warning' | 'error' | 'neutral' | 'primary' {
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 && !orgStore.org) await orgStore.fetchOrg(orgId)
if (!orgId) { loading.value = false; return }
try {
const result = await pb.collection('schedule_runs').getFullList({
filter: `org_id = "${orgId}"`,
sort: '-created',
})
runs.value = result as unknown as PBScheduleRun[]
} catch (err) {
console.error('Failed to load schedule runs', err)
} finally {
loading.value = false
}
})
</script>