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

@@ -1,19 +1,30 @@
## General
APP_NAME="Nuxt Pocketbase Starter"
APP_ID="com.pocketbase.nuxt"
APP_NAME="ShiftCraft"
APP_ID="app.shiftcraft"
NODE_ENV="development"
## Frontend
NUXT_PUBLIC_APP_URL="http://localhost:3000"
NUXT_PUBLIC_PB_URL="http://127.0.0.1:8090"
NUXT_PUBLIC_POCKETBASE_URL="http://127.0.0.1:8090"
NUXT_PUBLIC_RYBBIT_URL="" # e.g. https://app.rybbit.io
NUXT_PUBLIC_RYBBIT_SITE_ID="" # Your Rybbit site ID
## Backend
SUPERUSER_EMAIL="mail@example.com"
SUPERUSER_PW="!234Qwer"
## Backend (PocketBase admin)
PB_ADMIN_EMAIL="admin@shiftcraft.app"
PB_ADMIN_PASSWORD="changeme123"
SUPERUSER_EMAIL="admin@shiftcraft.app"
SUPERUSER_PW="changeme123"
## AI (Anthropic Claude — required for constraint parsing)
ANTHROPIC_API_KEY="sk-ant-..."
## Payments (Stripe — optional)
STRIPE_SECRET_KEY=""
STRIPE_PUBLISHABLE_KEY=""
STRIPE_WEBHOOK_SECRET=""
AUTH_GOOGLE_CLIENT_ID="<client-id>"
AUTH_GOOGLE_CLIENT_SECRET="<client-secret>"

View File

@@ -1,7 +1,7 @@
export default defineAppConfig({
ui: {
colors: {
primary: 'teal',
primary: 'indigo',
neutral: 'slate'
}
}

37
app/composables/useOrg.ts Normal file
View File

@@ -0,0 +1,37 @@
import type { Organization } from '~/shared/types/pocketbase'
export const useOrg = defineStore('org', {
state: () => ({
org: null as Organization | null,
loading: false,
}),
getters: {
plan: (state) => state.org?.plan ?? 'free',
orgId: (state) => state.org?.id ?? null,
},
actions: {
async fetchOrg(orgId: string) {
const { pb } = usePocketBase()
this.loading = true
try {
this.org = await pb.collection('organizations').getOne(orgId) as unknown as Organization
} catch (err) {
console.error('Failed to fetch org', err)
} finally {
this.loading = false
}
},
async updateOrg(data: Partial<Organization>) {
if (!this.org) return
const { pb } = usePocketBase()
this.org = await pb.collection('organizations').update(this.org.id, data) as unknown as Organization
},
clearOrg() {
this.org = null
}
}
})

View File

@@ -0,0 +1,20 @@
import { PLAN_LIMITS } from '~/app/utils/planLimits'
import type { PlanTier } from '~/app/utils/planLimits'
export const useSubscription = () => {
const orgStore = useOrg()
const plan = computed<PlanTier>(() => (orgStore.plan as PlanTier) || 'free')
const limits = computed(() => PLAN_LIMITS[plan.value])
const canAddEmployee = (currentCount: number) => {
const limit = limits.value.employee_limit
return limit === Infinity || currentCount < limit
}
const canExportPDF = computed(() => limits.value.pdf_export)
const canExportExcel = computed(() => limits.value.excel_export)
const isFreePlan = computed(() => plan.value === 'free')
return { plan, limits, canAddEmployee, canExportPDF, canExportExcel, isFreePlan }
}

15
app/layouts/auth.vue Normal file
View File

@@ -0,0 +1,15 @@
<template>
<div class="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-violet-50 flex items-center justify-center px-4">
<div class="w-full max-w-md">
<!-- Logo -->
<div class="text-center mb-8">
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center mx-auto mb-4">
<span class="text-white font-bold text-xl">S</span>
</div>
<h1 class="text-2xl font-bold text-gray-900">ShiftCraft</h1>
<p class="text-gray-500 text-sm mt-1">Schichtplanung, die Ihre Sprache spricht</p>
</div>
<slot />
</div>
</div>
</template>

View File

@@ -1,11 +1,138 @@
<template>
<div>
<AppHeader />
<div class="min-h-screen bg-gray-50 flex">
<!-- Sidebar -->
<aside class="hidden lg:flex flex-col w-64 bg-white border-r border-gray-100 fixed inset-y-0 z-30">
<!-- Logo -->
<div class="h-16 flex items-center gap-3 px-6 border-b border-gray-100">
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center shrink-0">
<span class="text-white font-bold text-sm">S</span>
</div>
<span class="font-bold text-gray-900 text-lg">ShiftCraft</span>
</div>
<UMain>
<UContainer class="py-8">
<!-- Nav links -->
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
<NuxtLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
:class="isActive(item.to) ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
>
<UIcon :name="item.icon" class="w-5 h-5 shrink-0" />
{{ item.label }}
</NuxtLink>
</nav>
<!-- Bottom: plan badge + user -->
<div class="px-3 py-4 border-t border-gray-100 space-y-3">
<div class="px-3 py-2 rounded-xl bg-indigo-50 border border-indigo-100 flex items-center gap-2">
<span class="text-indigo-600 text-xs font-semibold uppercase tracking-wide">{{ planLabel }}</span>
<span class="ml-auto text-indigo-400">
<UIcon v-if="plan !== 'free'" name="i-lucide-zap" class="w-4 h-4" />
</span>
</div>
<NuxtLink to="/settings/billing" class="block px-3 py-2 rounded-xl text-xs text-gray-500 hover:bg-gray-50 transition-colors" v-if="plan === 'free'">
Auf Pro upgraden
</NuxtLink>
<div class="flex items-center gap-3 px-3 py-2">
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
<span class="text-indigo-700 text-sm font-medium">{{ userInitial }}</span>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p>
</div>
<UButton icon="i-lucide-log-out" color="neutral" variant="ghost" size="xs" @click="handleSignOut" />
</div>
</div>
</aside>
<!-- Mobile header -->
<div class="lg:hidden fixed top-0 inset-x-0 z-30 h-14 bg-white border-b border-gray-100 flex items-center justify-between px-4">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
<span class="text-white font-bold text-xs">S</span>
</div>
<span class="font-bold text-gray-900">ShiftCraft</span>
</div>
<UButton icon="i-lucide-menu" color="neutral" variant="ghost" @click="mobileOpen = true" />
</div>
<!-- Mobile drawer -->
<USlideover v-model:open="mobileOpen" side="left" class="lg:hidden">
<div class="flex flex-col h-full bg-white">
<div class="h-14 flex items-center gap-3 px-6 border-b border-gray-100">
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
<span class="text-white font-bold text-xs">S</span>
</div>
<span class="font-bold text-gray-900">ShiftCraft</span>
<UButton class="ml-auto" icon="i-lucide-x" color="neutral" variant="ghost" @click="mobileOpen = false" />
</div>
<nav class="flex-1 px-3 py-4 space-y-1">
<NuxtLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all"
:class="isActive(item.to) ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50'"
@click="mobileOpen = false"
>
<UIcon :name="item.icon" class="w-5 h-5 shrink-0" />
{{ item.label }}
</NuxtLink>
</nav>
</div>
</USlideover>
<!-- Main content -->
<main class="flex-1 lg:ml-64 pt-14 lg:pt-0">
<div class="p-6 lg:p-8 max-w-7xl mx-auto">
<slot />
</UContainer>
</UMain>
</div>
</main>
</div>
</template>
<script setup lang="ts">
const route = useRoute()
const { signOut, user } = useUser()
const orgStore = useOrg()
const { plan } = useSubscription()
const mobileOpen = ref(false)
const navItems = [
{ to: '/dashboard', label: 'Dashboard', icon: 'i-lucide-layout-dashboard' },
{ to: '/employees', label: 'Mitarbeiter', icon: 'i-lucide-users' },
{ to: '/constraints', label: 'Bedingungen', icon: 'i-lucide-sliders' },
{ to: '/schedules', label: 'Schichtpläne', icon: 'i-lucide-calendar' },
{ to: '/settings/organization', label: 'Einstellungen', icon: 'i-lucide-settings' },
]
const planLabel = computed(() => {
if (plan.value === 'free') return 'Free Plan'
if (plan.value === 'pro') return 'Pro Plan'
return 'Business Plan'
})
const userName = computed(() => user?.name || user?.email || 'Nutzer')
const userInitial = computed(() => (userName.value || 'N').charAt(0).toUpperCase())
function isActive(path: string) {
return route.path.startsWith(path)
}
async function handleSignOut() {
signOut()
await navigateTo('/login')
}
// Load org on mount
onMounted(async () => {
const { authStore } = usePocketBase()
const orgId = authStore.value.record?.org_id as string | undefined
if (orgId && !orgStore.org) {
await orgStore.fetchOrg(orgId)
}
})
</script>

View File

@@ -0,0 +1,181 @@
<template>
<div>
<!-- Header -->
<div class="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<div>
<h1 class="text-2xl font-bold text-gray-900">Bedingungen</h1>
<p class="text-gray-500 mt-1 text-sm">Regeln und Präferenzen für Ihre Schichtplanung</p>
</div>
<div class="flex gap-2">
<NuxtLink to="/constraints/templates">
<UButton color="neutral" variant="outline" icon="i-lucide-book-open">
Gesetzliche Vorlagen
</UButton>
</NuxtLink>
<NuxtLink to="/constraints/new">
<UButton color="primary" icon="i-lucide-plus">
Neue Bedingung
</UButton>
</NuxtLink>
</div>
</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 Bedingungen...
</div>
<!-- Empty state -->
<div v-else-if="constraints.length === 0" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-12 text-center">
<UIcon name="i-lucide-sliders" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 class="font-semibold text-gray-700 mb-1">Noch keine Bedingungen</h3>
<p class="text-gray-400 text-sm mb-4">Fügen Sie Regeln hinzu oder importieren Sie gesetzliche Vorlagen.</p>
<div class="flex justify-center gap-3">
<NuxtLink to="/constraints/templates">
<UButton color="neutral" variant="outline" icon="i-lucide-book-open" size="sm">Vorlagen</UButton>
</NuxtLink>
<NuxtLink to="/constraints/new">
<UButton color="primary" icon="i-lucide-plus" size="sm">Neue Bedingung</UButton>
</NuxtLink>
</div>
</div>
<!-- Grouped constraints -->
<div v-else class="space-y-6">
<div v-for="(group, category) in grouped" :key="category">
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
{{ categoryLabel(category as string) }}
</h2>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
<div
v-for="c in group"
:key="c.id"
class="px-6 py-4 flex items-start gap-4"
:class="!c.active ? 'opacity-50' : ''"
>
<!-- Hard/Soft indicator -->
<div class="mt-0.5 shrink-0">
<UBadge :color="c.hard ? 'error' : 'warning'" variant="subtle" size="sm">
{{ c.hard ? 'Pflicht' : 'Wunsch' }}
</UBadge>
</div>
<!-- Content -->
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 text-sm">{{ c.label }}</p>
<p class="text-xs text-gray-500 mt-0.5">
{{ c.constraint_json?.natural_language_summary || c.label }}
</p>
<div class="flex items-center gap-2 mt-1">
<span v-if="c.source === 'legal'" class="text-xs text-blue-600 font-medium">
<UIcon name="i-lucide-landmark" class="w-3 h-3 inline" /> Gesetzlich
</span>
<span v-else-if="c.source === 'ai'" class="text-xs text-violet-600 font-medium">
<UIcon name="i-lucide-sparkles" class="w-3 h-3 inline" /> KI-erkannt
</span>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<UToggle
:model-value="c.active"
@update:model-value="toggleConstraint(c.id, $event)"
size="sm"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
:loading="deletingId === c.id"
@click="deleteConstraint(c.id)"
/>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PBConstraint } from '~/shared/types/pocketbase'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const loading = ref(true)
const deletingId = ref<string | null>(null)
const constraints = ref<PBConstraint[]>([])
const grouped = computed(() => {
const groups: Record<string, PBConstraint[]> = {}
for (const c of constraints.value) {
const cat = c.category || 'other'
if (!groups[cat]) groups[cat] = []
groups[cat].push(c)
}
return groups
})
function categoryLabel(cat: string) {
const map: Record<string, string> = {
legal: 'Gesetzliche Vorgaben',
preference: 'Präferenzen',
operational: 'Betriebliche Regeln',
other: 'Sonstiges',
}
return map[cat] ?? cat
}
async function loadConstraints() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
loading.value = true
try {
const result = await pb.collection('constraints').getFullList({
filter: `org_id = "${orgId}"`,
sort: 'category,label',
})
constraints.value = result as unknown as PBConstraint[]
} catch (err) {
console.error('Failed to load constraints', err)
} finally {
loading.value = false
}
}
async function toggleConstraint(id: string, active: boolean) {
try {
await pb.collection('constraints').update(id, { active })
const idx = constraints.value.findIndex(c => c.id === id)
if (idx !== -1) constraints.value[idx].active = active
} catch (err) {
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
}
}
async function deleteConstraint(id: string) {
deletingId.value = id
try {
await pb.collection('constraints').delete(id)
constraints.value = constraints.value.filter(c => c.id !== id)
toast.add({ color: 'success', title: 'Bedingung gelöscht' })
} catch (err) {
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
} finally {
deletingId.value = null
}
}
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
await loadConstraints()
})
</script>

View File

@@ -0,0 +1,209 @@
<template>
<div class="max-w-2xl mx-auto">
<!-- Header -->
<div class="mb-8">
<div class="flex items-center gap-3 mb-2">
<NuxtLink to="/constraints">
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
</NuxtLink>
<h1 class="text-2xl font-bold text-gray-900">Neue Bedingung</h1>
</div>
<p class="text-gray-500 text-sm ml-10">
Beschreiben Sie Ihre Regel in eigenen Worten die KI erkennt die Bedingung automatisch.
</p>
</div>
<!-- Input card -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6">
<UFormField label="Bedingung beschreiben" class="mb-4">
<UTextarea
v-model="inputText"
:rows="4"
placeholder="Beschreiben Sie Ihre Bedingung in eigenen Worten..."
class="w-full"
:disabled="parsing"
/>
</UFormField>
<!-- Example hints -->
<div class="mb-4">
<p class="text-xs text-gray-400 mb-2 font-medium">Beispiele:</p>
<div class="flex flex-wrap gap-2">
<button
v-for="hint in exampleHints"
:key="hint"
class="px-3 py-1 rounded-full bg-gray-50 border border-gray-200 text-xs text-gray-600 hover:bg-indigo-50 hover:border-indigo-200 hover:text-indigo-700 transition-colors"
@click="inputText = hint"
>
{{ hint }}
</button>
</div>
</div>
<UButton
color="primary"
icon="i-lucide-sparkles"
:loading="parsing"
:disabled="!inputText.trim()"
@click="parseConstraint"
>
Bedingung erkennen
</UButton>
</div>
<!-- Error state -->
<div v-if="parseError" class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
<div class="flex items-start gap-3">
<UIcon name="i-lucide-alert-circle" class="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
<div>
<p class="font-medium text-red-800 text-sm">Fehler bei der Erkennung</p>
<p class="text-red-600 text-sm mt-1">{{ parseError }}</p>
<p v-if="parseError.includes('API')" class="text-red-500 text-xs mt-2">
Stellen Sie sicher, dass ANTHROPIC_API_KEY korrekt konfiguriert ist.
</p>
</div>
</div>
</div>
<!-- Ambiguities -->
<div v-if="ambiguities.length > 0" class="bg-amber-50 border border-amber-200 rounded-2xl p-4 mb-6">
<div class="flex items-start gap-3">
<UIcon name="i-lucide-alert-triangle" class="w-5 h-5 text-amber-600 mt-0.5 shrink-0" />
<div>
<p class="font-medium text-amber-800 text-sm">Hinweise</p>
<ul class="mt-1 space-y-1">
<li v-for="a in ambiguities" :key="a" class="text-amber-700 text-sm">{{ a }}</li>
</ul>
</div>
</div>
</div>
<!-- Parsed results -->
<div v-if="parsedConstraints.length > 0" class="space-y-4 mb-6">
<h2 class="font-bold text-gray-900">Erkannte Bedingungen</h2>
<div
v-for="(c, idx) in parsedConstraints"
:key="idx"
class="bg-white rounded-2xl border border-gray-100 shadow-sm p-5"
>
<div class="flex items-start justify-between gap-3 mb-3">
<p class="font-medium text-gray-900">{{ c.natural_language_summary }}</p>
<UBadge :color="c.hard ? 'error' : 'warning'" variant="subtle" size="sm" class="shrink-0">
{{ c.hard ? 'Pflicht' : 'Wunsch' }}
</UBadge>
</div>
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
<span class="bg-gray-50 rounded-lg px-2 py-1 font-mono">{{ c.type }}</span>
<span v-if="c.scope.type !== 'global'" class="bg-violet-50 text-violet-700 rounded-lg px-2 py-1">
{{ scopeLabel(c) }}
</span>
<span v-if="c.weight" class="bg-amber-50 text-amber-700 rounded-lg px-2 py-1">
Gewicht: {{ c.weight }}
</span>
</div>
</div>
<UButton
color="primary"
icon="i-lucide-check"
:loading="saving"
@click="saveConstraints"
>
{{ parsedConstraints.length > 1 ? `${parsedConstraints.length} Bedingungen hinzufügen` : 'Bedingung hinzufügen' }}
</UButton>
</div>
</div>
</template>
<script setup lang="ts">
import type { ConstraintJSON } from '~/shared/types/constraint'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const inputText = ref('')
const parsing = ref(false)
const saving = ref(false)
const parseError = ref('')
const parsedConstraints = ref<ConstraintJSON[]>([])
const ambiguities = ref<string[]>([])
const exampleHints = [
'Sabine mag keine Nachtschichten',
'Maximal 10 Stunden pro Tag',
'Mindestens 11 Stunden Ruhezeit zwischen Schichten',
'Nicht mehr als 5 Schichten am Stück',
'Keine Nachtschicht direkt nach Frühschicht',
'Faire Verteilung der Wochenendschichten',
]
function scopeLabel(c: ConstraintJSON) {
if (c.scope.type === 'employee') return `Mitarbeiter: ${(c.scope as { employee_id: string }).employee_id}`
if (c.scope.type === 'role') return `Rolle: ${(c.scope as { role: string }).role}`
return 'Global'
}
async function parseConstraint() {
parseError.value = ''
parsedConstraints.value = []
ambiguities.value = []
parsing.value = true
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
try {
const result = await $fetch('/api/constraints/parse', {
method: 'POST',
body: { text: inputText.value, org_id: orgId },
}) as { constraints: ConstraintJSON[]; ambiguities: string[] }
parsedConstraints.value = result.constraints
ambiguities.value = result.ambiguities
} catch (err: unknown) {
const msg = (err as { data?: { message?: string } })?.data?.message || String(err)
parseError.value = msg
} finally {
parsing.value = false
}
}
async function saveConstraints() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
saving.value = true
try {
for (const c of parsedConstraints.value) {
await pb.collection('constraints').create({
org_id: orgId,
label: c.natural_language_summary,
source_text: inputText.value,
constraint_json: c,
scope: c.scope.type,
scope_ref: (c.scope as Record<string, string>).employee_id || (c.scope as Record<string, string>).role || '',
category: inferCategory(c),
hard: c.hard,
weight: c.weight ?? 50,
active: true,
source: 'ai',
template_id: '',
})
}
toast.add({ color: 'success', title: `${parsedConstraints.value.length} Bedingung(en) gespeichert` })
await navigateTo('/constraints')
} catch (err) {
toast.add({ color: 'error', title: 'Fehler beim Speichern', description: String(err) })
} finally {
saving.value = false
}
}
function inferCategory(c: ConstraintJSON): string {
if (['max_hours_per_day', 'max_hours_per_week', 'min_rest_between_shifts'].includes(c.type)) return 'legal'
if (['employee_avoids_period', 'employee_prefers_period'].includes(c.type)) return 'preference'
return 'operational'
}
</script>

View File

@@ -0,0 +1,270 @@
<template>
<div>
<!-- Header -->
<div class="mb-8 flex items-center gap-3">
<NuxtLink to="/constraints">
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
</NuxtLink>
<div>
<h1 class="text-2xl font-bold text-gray-900">Gesetzliche Vorlagen</h1>
<p class="text-gray-500 mt-1 text-sm">Vorgefertigte Bedingungen aus dem deutschen Arbeitszeitgesetz (ArbZG)</p>
</div>
</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 Vorlagen...
</div>
<div v-else class="space-y-4">
<div
v-for="tmpl in templates"
:key="tmpl.id"
class="bg-white rounded-2xl border shadow-sm p-5 transition-colors"
:class="activeIds.has(tmpl.id) ? 'border-indigo-200' : 'border-gray-100'"
>
<div class="flex items-start justify-between gap-4">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<p class="font-semibold text-gray-900">{{ tmpl.label }}</p>
<UBadge v-if="tmpl.mandatory" color="error" variant="subtle" size="sm">Pflicht</UBadge>
<UBadge v-else color="warning" variant="subtle" size="sm">Empfehlung</UBadge>
</div>
<p class="text-sm text-gray-500 mb-2">{{ tmpl.description }}</p>
<div class="flex items-center gap-2">
<span class="text-xs text-blue-600 font-medium bg-blue-50 px-2 py-0.5 rounded-full">
<UIcon name="i-lucide-landmark" class="w-3 h-3 inline mr-1" />{{ tmpl.law_name }}
</span>
<span class="text-xs text-gray-400">{{ tmpl.region }}</span>
</div>
</div>
<div class="shrink-0">
<UButton
v-if="activeIds.has(tmpl.id)"
color="success"
variant="soft"
icon="i-lucide-check"
size="sm"
:loading="togglingId === tmpl.id"
@click="removeTemplate(tmpl)"
>
Aktiv
</UButton>
<UButton
v-else
color="primary"
variant="outline"
icon="i-lucide-plus"
size="sm"
:loading="togglingId === tmpl.id"
@click="addTemplate(tmpl)"
>
Hinzufügen
</UButton>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { LegalTemplate } from '~/shared/types/pocketbase'
import type { ConstraintJSON } from '~/shared/types/constraint'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const loading = ref(true)
const togglingId = ref<string | null>(null)
const templates = ref<LegalTemplate[]>([])
const activeIds = ref<Set<string>>(new Set())
// Map from template_id → constraint record id
const constraintByTemplateId = ref<Record<string, string>>({})
// Hardcoded ArbZG defaults
const DEFAULT_TEMPLATES: LegalTemplate[] = [
{
id: 'arbzg-3',
region: 'Deutschland',
law_name: 'ArbZG §3',
label: 'Maximale Arbeitszeit 8 Stunden/Tag',
description: 'Die werktägliche Arbeitszeit darf 8 Stunden nicht überschreiten. Sie kann auf bis zu 10 Stunden verlängert werden, wenn innerhalb von 6 Kalendermonaten im Durchschnitt 8 Stunden/Tag nicht überschritten werden.',
constraint_json: {
type: 'max_hours_per_day',
scope: { type: 'global' },
params: { max_hours: 10 },
hard: true,
natural_language_summary: 'Maximal 10 Stunden Arbeitszeit pro Tag (ArbZG §3)',
} as ConstraintJSON,
category: 'legal',
mandatory: true,
sort_order: 1,
},
{
id: 'arbzg-5',
region: 'Deutschland',
law_name: 'ArbZG §5',
label: 'Mindestruhezeit 11 Stunden',
description: 'Nach Beendigung der täglichen Arbeitszeit ist den Arbeitnehmern eine ununterbrochene Ruhezeit von mindestens elf Stunden zu gewähren.',
constraint_json: {
type: 'min_rest_between_shifts',
scope: { type: 'global' },
params: { min_hours: 11 },
hard: true,
natural_language_summary: 'Mindestens 11 Stunden Ruhezeit zwischen Schichten (ArbZG §5)',
} as ConstraintJSON,
category: 'legal',
mandatory: true,
sort_order: 2,
},
{
id: 'arbzg-9',
region: 'Deutschland',
law_name: 'ArbZG §9',
label: 'Sonntagsruhe',
description: 'Arbeitnehmer dürfen an Sonn- und gesetzlichen Feiertagen von 0 bis 24 Uhr nicht beschäftigt werden (mit branchenspezifischen Ausnahmen).',
constraint_json: {
type: 'max_weekend_shifts_per_month',
scope: { type: 'global' },
params: { max_count: 4 },
hard: false,
weight: 80,
natural_language_summary: 'Maximale Sonntagsschichten nach ArbZG §9 beachten',
} as ConstraintJSON,
category: 'legal',
mandatory: false,
sort_order: 3,
},
{
id: 'arbzg-consecutive',
region: 'Deutschland',
law_name: 'Empfehlung',
label: 'Maximal 6 Schichten am Stück',
description: 'Empfohlene Begrenzung aufeinanderfolgender Arbeitstage für Mitarbeiterwohlbefinden.',
constraint_json: {
type: 'max_consecutive_shifts',
scope: { type: 'global' },
params: { max_count: 6 },
hard: false,
weight: 70,
natural_language_summary: 'Empfehlung: Nicht mehr als 6 Schichten in Folge',
} as ConstraintJSON,
category: 'legal',
mandatory: false,
sort_order: 4,
},
{
id: 'arbzg-fair',
region: 'Deutschland',
law_name: 'Empfehlung',
label: 'Faire Schichtverteilung',
description: 'Nachtschichten und Wochenendschichten werden gleichmäßig auf alle Mitarbeiter verteilt.',
constraint_json: {
type: 'fair_distribution',
scope: { type: 'global' },
params: { metric: 'night_shifts', max_deviation_percent: 20 },
hard: false,
weight: 60,
natural_language_summary: 'Faire Verteilung der Nachtschichten (max. 20% Abweichung)',
} as ConstraintJSON,
category: 'preference',
mandatory: false,
sort_order: 5,
},
]
async function loadTemplates() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
loading.value = true
try {
// Try PocketBase first
let pbTemplates: LegalTemplate[] = []
try {
const result = await pb.collection('legal_templates').getFullList({ sort: 'sort_order' })
pbTemplates = result as unknown as LegalTemplate[]
} catch {
// Collection may not exist yet — fall back to hardcoded
}
templates.value = pbTemplates.length > 0 ? pbTemplates : DEFAULT_TEMPLATES
if (orgId) {
// Load which templates are already active for this org
const existing = await pb.collection('constraints').getFullList({
filter: `org_id = "${orgId}" && source = "legal"`,
fields: 'id,template_id',
})
for (const e of existing) {
const tid = (e as { template_id?: string }).template_id
if (tid) {
activeIds.value.add(tid)
constraintByTemplateId.value[tid] = e.id
}
}
}
} catch (err) {
console.error('Failed to load templates', err)
} finally {
loading.value = false
}
}
async function addTemplate(tmpl: LegalTemplate) {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
togglingId.value = tmpl.id
try {
const created = await pb.collection('constraints').create({
org_id: orgId,
label: tmpl.label,
source_text: tmpl.description,
constraint_json: tmpl.constraint_json,
scope: 'global',
scope_ref: '',
category: tmpl.category,
hard: tmpl.constraint_json.hard,
weight: tmpl.constraint_json.weight ?? 100,
active: true,
source: 'legal',
template_id: tmpl.id,
})
activeIds.value.add(tmpl.id)
constraintByTemplateId.value[tmpl.id] = created.id
toast.add({ color: 'success', title: `"${tmpl.label}" hinzugefügt` })
} catch (err) {
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
} finally {
togglingId.value = null
}
}
async function removeTemplate(tmpl: LegalTemplate) {
const constraintId = constraintByTemplateId.value[tmpl.id]
if (!constraintId) return
togglingId.value = tmpl.id
try {
await pb.collection('constraints').delete(constraintId)
activeIds.value.delete(tmpl.id)
delete constraintByTemplateId.value[tmpl.id]
toast.add({ color: 'success', title: `"${tmpl.label}" entfernt` })
} catch (err) {
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
} finally {
togglingId.value = null
}
}
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
await loadTemplates()
})
</script>

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>

View File

@@ -0,0 +1,240 @@
<template>
<div>
<!-- Header -->
<div class="mb-8 flex items-center justify-between">
<div>
<h1 class="text-2xl font-bold text-gray-900">Mitarbeiter</h1>
<p class="text-gray-500 mt-1 text-sm">
{{ employees.length }} von {{ employeeLimit === Infinity ? '∞' : employeeLimit }} Mitarbeitern
</p>
</div>
<UButton
color="primary"
icon="i-lucide-user-plus"
@click="showAddForm = true"
:disabled="!canAdd"
>
Mitarbeiter hinzufügen
</UButton>
</div>
<!-- Add employee form -->
<div v-if="showAddForm" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6">
<h2 class="font-bold text-gray-900 mb-4">Neuer Mitarbeiter</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
<UFormField label="Name" required>
<UInput v-model="form.name" placeholder="Max Mustermann" class="w-full" />
</UFormField>
<UFormField label="E-Mail (optional)">
<UInput v-model="form.email" type="email" placeholder="max@firma.de" class="w-full" />
</UFormField>
<UFormField label="Anstellungsart">
<USelect
v-model="form.employment_type"
:options="employmentTypeOptions"
class="w-full"
/>
</UFormField>
<UFormField label="Wochenstunden (Ziel)">
<UInput v-model.number="form.weekly_hours_target" type="number" min="0" max="60" class="w-full" />
</UFormField>
<UFormField label="Rollen/Tags (kommagetrennt)" class="sm:col-span-2">
<UInput v-model="rolesInput" placeholder="z.B. Kassierer, Lager, Schichtleiter" class="w-full" />
</UFormField>
</div>
<div class="mt-4 flex gap-3">
<UButton color="primary" :loading="saving" @click="addEmployee">Hinzufügen</UButton>
<UButton color="neutral" variant="ghost" @click="cancelAdd">Abbrechen</UButton>
</div>
</div>
<!-- Employee list -->
<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 Mitarbeiter...
</div>
<div v-else-if="employees.length === 0 && !showAddForm" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-12 text-center">
<UIcon name="i-lucide-users" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 class="font-semibold text-gray-700 mb-1">Noch keine Mitarbeiter</h3>
<p class="text-gray-400 text-sm mb-4">Fügen Sie Ihren ersten Mitarbeiter hinzu, um loszulegen.</p>
<UButton color="primary" icon="i-lucide-user-plus" @click="showAddForm = true">
Mitarbeiter hinzufügen
</UButton>
</div>
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
<div
v-for="emp in employees"
:key="emp.id"
class="px-6 py-4 flex items-center gap-4 hover:bg-gray-50 transition-colors"
>
<!-- Avatar -->
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
<span class="text-indigo-700 font-semibold text-sm">{{ initials(emp.name) }}</span>
</div>
<!-- Info -->
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900">{{ emp.name }}</p>
<p v-if="emp.email" class="text-xs text-gray-400">{{ emp.email }}</p>
<div class="flex flex-wrap gap-1 mt-1">
<span
v-for="role in emp.roles"
:key="role"
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-violet-50 text-violet-700"
>{{ role }}</span>
</div>
</div>
<!-- Employment type badge -->
<UBadge :color="employmentColor(emp.employment_type)" variant="subtle" size="sm" class="shrink-0">
{{ employmentLabel(emp.employment_type) }}
</UBadge>
<!-- Hours -->
<span class="text-sm text-gray-500 shrink-0">{{ emp.weekly_hours_target }}h/Woche</span>
<!-- Delete -->
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
:loading="deletingId === emp.id"
@click="deleteEmployee(emp.id)"
/>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { PBEmployee } from '~/shared/types/pocketbase'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const { canAddEmployee, limits } = useSubscription()
const loading = ref(true)
const saving = ref(false)
const showAddForm = ref(false)
const deletingId = ref<string | null>(null)
const employees = ref<PBEmployee[]>([])
const rolesInput = ref('')
const employeeLimit = computed(() => limits.value.employee_limit)
const canAdd = computed(() => canAddEmployee(employees.value.length))
const form = reactive({
name: '',
email: '',
employment_type: 'full_time',
weekly_hours_target: 40,
})
const employmentTypeOptions = [
{ label: 'Vollzeit', value: 'full_time' },
{ label: 'Teilzeit', value: 'part_time' },
{ label: 'Minijob', value: 'mini_job' },
]
function employmentLabel(type: string) {
const map: Record<string, string> = { full_time: 'Vollzeit', part_time: 'Teilzeit', mini_job: 'Minijob' }
return map[type] ?? type
}
function employmentColor(type: string): 'primary' | 'success' | 'neutral' {
if (type === 'full_time') return 'primary'
if (type === 'part_time') return 'success'
return 'neutral'
}
function initials(name: string) {
return name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()
}
function cancelAdd() {
showAddForm.value = false
form.name = ''
form.email = ''
form.employment_type = 'full_time'
form.weekly_hours_target = 40
rolesInput.value = ''
}
async function loadEmployees() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
loading.value = true
try {
const result = await pb.collection('employees').getFullList({
filter: `org_id = "${orgId}" && active = true`,
sort: 'name',
})
employees.value = result as unknown as PBEmployee[]
} catch (err) {
console.error('Failed to load employees', err)
} finally {
loading.value = false
}
}
async function addEmployee() {
if (!form.name.trim()) {
toast.add({ color: 'error', title: 'Name ist erforderlich' })
return
}
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
saving.value = true
try {
const roles = rolesInput.value
? rolesInput.value.split(',').map(r => r.trim()).filter(Boolean)
: []
const created = await pb.collection('employees').create({
org_id: orgId,
name: form.name.trim(),
email: form.email.trim() || '',
employment_type: form.employment_type,
weekly_hours_target: form.weekly_hours_target,
max_weekly_hours: form.weekly_hours_target + 10,
roles,
skills: [],
available_periods: [],
unavailable_dates: [],
active: true,
})
employees.value.push(created as unknown as PBEmployee)
toast.add({ color: 'success', title: `${form.name} hinzugefügt` })
cancelAdd()
} catch (err) {
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
} finally {
saving.value = false
}
}
async function deleteEmployee(id: string) {
deletingId.value = id
try {
await pb.collection('employees').update(id, { active: false })
employees.value = employees.value.filter(e => e.id !== id)
toast.add({ color: 'success', title: 'Mitarbeiter entfernt' })
} catch (err) {
toast.add({ color: 'error', title: 'Fehler beim Löschen', description: String(err) })
} finally {
deletingId.value = null
}
}
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
await loadEmployees()
})
</script>

View File

@@ -1,41 +1,272 @@
<template>
<div>
<UPageHero
:title="$t('hero.title')"
:description="$t('hero.description')"
:links="links"
/>
<div class="flex justify-center mt-8">
<CounterWidget v-if="isAuthenticated" />
<UEmpty
v-else
icon="i-lucide-user-x"
:title="$t('counter.notAuthenticated')"
:description="$t('counter.signInToUse')"
/>
</div>
<div class="min-h-screen bg-white">
<!-- Navigation -->
<nav class="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-xl border-b border-gray-100">
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
<span class="text-white font-bold text-sm">S</span>
</div>
<span class="font-bold text-gray-900 text-lg">ShiftCraft</span>
</div>
<div class="hidden md:flex items-center gap-8">
<a href="#features" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">Features</a>
<a href="#how-it-works" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">So funktioniert's</a>
<a href="#pricing" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">Preise</a>
</div>
<div class="flex items-center gap-3">
<NuxtLink to="/login" class="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">Anmelden</NuxtLink>
<NuxtLink to="/register">
<UButton color="primary" size="sm">Kostenlos starten</UButton>
</NuxtLink>
</div>
</div>
</nav>
<!-- Hero -->
<section class="pt-32 pb-20 px-6 relative overflow-hidden">
<div class="absolute inset-0 bg-gradient-to-br from-indigo-50 via-violet-50 to-emerald-50 -z-10" />
<div class="absolute top-20 right-0 w-96 h-96 bg-violet-200 rounded-full filter blur-3xl opacity-30 -z-10" />
<div class="absolute bottom-0 left-20 w-64 h-64 bg-indigo-200 rounded-full filter blur-3xl opacity-30 -z-10" />
<div class="max-w-4xl mx-auto text-center">
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-indigo-50 text-indigo-700 rounded-full text-sm font-medium mb-8 border border-indigo-100">
<span class="w-1.5 h-1.5 rounded-full bg-indigo-500 animate-pulse" />
Schichtplanung neu gedacht
</div>
<h1 class="text-5xl md:text-7xl font-extrabold text-gray-900 leading-tight mb-6">
Schichtplanung,<br>
<span class="bg-gradient-to-r from-indigo-600 to-violet-600 bg-clip-text text-transparent">
die Ihre Sprache<br>spricht
</span>
</h1>
<p class="text-xl text-gray-600 max-w-2xl mx-auto mb-10 leading-relaxed">
Einfach Ihre Wünsche und Regeln eingeben — wie in einer E-Mail.
ShiftCraft erstellt automatisch den optimalen, fairen Schichtplan.
</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-16">
<NuxtLink to="/register">
<UButton color="primary" size="xl" trailing-icon="i-lucide-arrow-right" class="shadow-lg shadow-indigo-200">
Kostenlos starten
</UButton>
</NuxtLink>
<a href="#how-it-works">
<UButton color="neutral" variant="outline" size="xl">
Demo ansehen
</UButton>
</a>
</div>
<!-- Mock UI Preview -->
<div class="relative max-w-3xl mx-auto">
<div class="bg-white rounded-3xl shadow-2xl border border-gray-100 overflow-hidden">
<div class="bg-gray-50 px-6 py-4 border-b border-gray-100 flex items-center gap-2">
<div class="w-3 h-3 rounded-full bg-red-400" />
<div class="w-3 h-3 rounded-full bg-yellow-400" />
<div class="w-3 h-3 rounded-full bg-green-400" />
<span class="ml-4 text-xs text-gray-400 font-medium">ShiftCraft — Neue Bedingung</span>
</div>
<div class="p-6 text-left">
<div class="mb-4">
<label class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2 block">Bedingung eingeben</label>
<div class="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-700 text-sm min-h-[80px]">
Sabine mag keine Nachtschichten und sollte maximal 4 Tage am Stück arbeiten. Tom darf nicht mehr als 3 Nachtschichten hintereinander machen.
</div>
</div>
<div class="space-y-2">
<div v-for="preview in constraintPreviews" :key="preview.text" class="bg-indigo-50 border border-indigo-100 rounded-xl p-3 flex items-start gap-3">
<UIcon name="i-lucide-check-circle" class="text-indigo-600 mt-0.5 w-4 h-4 shrink-0" />
<div>
<p class="text-sm font-medium text-indigo-900">{{ preview.text }}</p>
<p class="text-xs text-indigo-500">{{ preview.meta }}</p>
</div>
</div>
</div>
</div>
</div>
<div class="absolute -bottom-4 -right-4 w-32 h-32 bg-violet-100 rounded-full -z-10" />
<div class="absolute -top-4 -left-4 w-24 h-24 bg-emerald-100 rounded-full -z-10" />
</div>
</div>
</section>
<!-- Features -->
<section id="features" class="py-24 px-6">
<div class="max-w-6xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-gray-900 mb-4">Alles was Sie brauchen</h2>
<p class="text-xl text-gray-600 max-w-2xl mx-auto">Keine komplizierte Software mehr. Einfach eingeben, was Sie wollen.</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<div v-for="feature in features" :key="feature.title" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 hover:shadow-md transition-shadow">
<div class="w-12 h-12 rounded-2xl flex items-center justify-center text-2xl mb-6" :class="feature.bgColor">
{{ feature.icon }}
</div>
<h3 class="text-xl font-bold text-gray-900 mb-3">{{ feature.title }}</h3>
<p class="text-gray-600 leading-relaxed">{{ feature.description }}</p>
</div>
</div>
</div>
</section>
<!-- How it works -->
<section id="how-it-works" class="py-24 px-6 bg-gradient-to-br from-gray-50 to-indigo-50/30">
<div class="max-w-5xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-gray-900 mb-4">So einfach geht's</h2>
<p class="text-xl text-gray-600">In drei Schritten zum perfekten Schichtplan</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<div v-for="(step, i) in steps" :key="step.title" class="text-center relative">
<div class="w-20 h-20 rounded-3xl bg-white shadow-lg border border-gray-100 flex items-center justify-center text-4xl mx-auto mb-6 relative">
{{ step.icon }}
<span class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-indigo-600 text-white text-xs font-bold flex items-center justify-center">{{ i + 1 }}</span>
</div>
<h3 class="text-xl font-bold text-gray-900 mb-3">{{ step.title }}</h3>
<p class="text-gray-600 leading-relaxed">{{ step.description }}</p>
</div>
</div>
</div>
</section>
<!-- Legal highlight -->
<section class="py-24 px-6">
<div class="max-w-6xl mx-auto">
<div class="bg-gradient-to-r from-indigo-600 to-violet-700 rounded-3xl p-12 text-white">
<div class="grid md:grid-cols-2 gap-12 items-center">
<div>
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-white/20 rounded-full text-sm font-medium mb-6">
Rechtlich abgesichert
</div>
<h2 class="text-3xl font-bold mb-4">Gesetzliche Vorgaben inklusive</h2>
<p class="text-indigo-100 text-lg leading-relaxed mb-6">
Alle relevanten Vorschriften aus dem Arbeitszeitgesetz sind bereits als Vorlagen hinterlegt.
Einfach aktivieren fertig.
</p>
<ul class="space-y-3">
<li v-for="rule in legalRules" :key="rule" class="flex items-center gap-3">
<span class="text-emerald-300"></span>
<span class="text-indigo-100">{{ rule }}</span>
</li>
</ul>
</div>
<div class="space-y-3">
<div v-for="template in legalTemplates" :key="template.label" class="bg-white/10 backdrop-blur rounded-2xl p-4 border border-white/20">
<div class="flex items-center justify-between mb-1">
<span class="font-medium">{{ template.label }}</span>
<span class="text-xs bg-red-400/30 text-red-200 px-2 py-0.5 rounded-full">Pflicht</span>
</div>
<p class="text-sm text-indigo-200">{{ template.description }}</p>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="py-24 px-6 bg-gray-50">
<div class="max-w-6xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl font-bold text-gray-900 mb-4">Einfache Preise</h2>
<p class="text-xl text-gray-600">Kein verstecktes Kleingedrucktes. Starten Sie kostenlos.</p>
</div>
<div class="grid md:grid-cols-3 gap-8">
<div
v-for="(tier, key) in PLAN_LIMITS"
:key="key"
class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 relative"
:class="key === 'pro' ? 'ring-2 ring-indigo-500 shadow-xl shadow-indigo-100' : ''"
>
<div v-if="key === 'pro'" class="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-indigo-600 text-white text-sm font-medium rounded-full whitespace-nowrap">
Beliebteste Wahl
</div>
<h3 class="text-2xl font-bold text-gray-900 mb-2">{{ tier.name }}</h3>
<p class="text-gray-500 mb-6 text-sm">{{ tier.description }}</p>
<div class="mb-8">
<span class="text-5xl font-extrabold text-gray-900">{{ tier.price_eur_month }}</span>
<span class="text-gray-500 ml-2">/Monat</span>
</div>
<ul class="space-y-3 mb-8">
<li v-for="f in tier.features" :key="f" class="flex items-center gap-3 text-gray-600 text-sm">
<UIcon name="i-lucide-check" class="text-emerald-500 w-4 h-4 shrink-0" />
{{ f }}
</li>
</ul>
<NuxtLink :to="tier.price_eur_month === 0 ? '/register' : '/register'">
<UButton
:color="key === 'pro' ? 'primary' : 'neutral'"
:variant="key === 'pro' ? 'solid' : 'outline'"
class="w-full justify-center"
size="lg"
>
{{ tier.price_eur_month === 0 ? 'Kostenlos starten' : 'Jetzt upgraden' }}
</UButton>
</NuxtLink>
</div>
</div>
</div>
</section>
<!-- Footer -->
<footer class="py-12 px-6 border-t border-gray-100">
<div class="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
<div class="flex items-center gap-2">
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
<span class="text-white font-bold text-xs">S</span>
</div>
<span class="font-bold text-gray-900">ShiftCraft</span>
</div>
<p class="text-gray-500 text-sm">© 2025 ShiftCraft. Alle Rechte vorbehalten.</p>
<div class="flex gap-6">
<a href="#" class="text-gray-500 hover:text-gray-700 text-sm transition-colors">Datenschutz</a>
<a href="#" class="text-gray-500 hover:text-gray-700 text-sm transition-colors">Impressum</a>
<a href="#" class="text-gray-500 hover:text-gray-700 text-sm transition-colors">Kontakt</a>
</div>
</div>
</footer>
</div>
</template>
<script setup lang="ts">
import type { ButtonProps } from '@nuxt/ui'
import { PLAN_LIMITS } from '~/app/utils/planLimits'
const { isAuthenticated } = storeToRefs(useUser())
definePageMeta({ layout: false })
const { t } = useI18n()
const constraintPreviews = [
{ text: 'Sabine bevorzugt keine Nachtschichten', meta: 'Soft-Bedingung · Gewicht 65' },
{ text: 'Max. 4 aufeinanderfolgende Schichten (Sabine)', meta: 'Harte Bedingung' },
{ text: 'Max. 3 Nachtschichten am Stück (Tom)', meta: 'Harte Bedingung' },
]
const links = computed(() => [
{
label: t('hero.signUp'),
to: '/login',
icon: 'i-lucide-log-in'
},
{
label: t('hero.profile'),
to: '/profile',
color: 'neutral',
variant: 'subtle',
trailingIcon: 'i-lucide-arrow-right'
}
] as ButtonProps[])
const features = [
{ icon: '💬', title: 'Einfach eingeben', description: 'Keine komplizierten Formulare. Schreiben Sie einfach, was Sie wollen — wie in einer Nachricht.', bgColor: 'bg-indigo-50' },
{ icon: '⚡', title: 'Automatisch optimiert', description: 'Unser intelligentes System erstellt den bestmöglichen, fairen Plan in Sekunden — nicht Stunden.', bgColor: 'bg-violet-50' },
{ icon: '⚖️', title: 'Rechtlich sicher', description: 'Gesetzliche Vorlagen für Deutschland, Österreich und die Schweiz sind bereits eingebaut.', bgColor: 'bg-emerald-50' },
{ icon: '🤝', title: 'Mitarbeiterwünsche', description: 'Individuelle Präferenzen werden berücksichtigt — für mehr Zufriedenheit im Team.', bgColor: 'bg-amber-50' },
{ icon: '📊', title: 'Faire Verteilung', description: 'Automatisch faire Verteilung von Nacht-, Wochenend- und Feiertagsschichten.', bgColor: 'bg-pink-50' },
{ icon: '📤', title: 'Export & Teilen', description: 'Den fertigen Plan als PDF oder Excel exportieren und direkt teilen.', bgColor: 'bg-cyan-50' },
]
const steps = [
{ icon: '🗓️', title: 'Rahmen festlegen', description: 'Definieren Sie Ihre Schichtzeiten und wie viele Mitarbeiter pro Schicht benötigt werden.' },
{ icon: '✏️', title: 'Regeln eingeben', description: 'Schreiben Sie Ihre Wünsche und Regeln in normaler Sprache — so einfach wie eine E-Mail.' },
{ icon: '✨', title: 'Plan erhalten', description: 'ShiftCraft berechnet automatisch den optimalen, fairen Schichtplan für Ihr Team.' },
]
const legalRules = [
'Max. 10 Stunden Arbeitszeit pro Tag',
'Min. 11 Stunden Ruhezeit zwischen Schichten',
'Max. 48 Stunden Arbeitszeit pro Woche',
'Keine Frühschicht nach Nachtschicht',
]
const legalTemplates = [
{ label: 'Max. 10 Stunden/Tag', description: 'Arbeitszeitgesetz §3 — Pflicht für alle Unternehmen' },
{ label: 'Min. 11h Ruhezeit', description: 'Arbeitszeitgesetz §5 — Zwischen zwei Schichten' },
{ label: 'Max. 6 Tage am Stück', description: 'Arbeitszeitgesetz §9 — Sonntagsruhe' },
]
</script>

View File

@@ -1,110 +1,65 @@
<template>
<UAuthForm
:fields="fields"
:schema="schema"
:providers="providers"
:title="$t('login.title')"
icon="i-lucide-lock"
@submit="onSubmit($event)"
>
<template #footer>
{{ $t('login.agree') }}
<ULink
to="/"
class="text-primary font-medium"
>
{{ $t('login.terms') }}
</ULink>.
</template>
</UAuthForm>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Willkommen zurück</h2>
<p class="text-gray-500 mb-6 text-sm">Melden Sie sich in Ihrem ShiftCraft-Konto an</p>
<UForm :schema="schema" :state="formState" @submit="onSubmit" class="space-y-4">
<UFormField label="E-Mail" name="email">
<UInput v-model="formState.email" type="email" placeholder="name@firma.de" class="w-full" />
</UFormField>
<UFormField label="Passwort" name="password">
<UInput v-model="formState.password" type="password" placeholder="••••••••" class="w-full" />
</UFormField>
<UButton type="submit" color="primary" class="w-full justify-center" :loading="loading" size="lg">
Anmelden
</UButton>
</UForm>
<div class="mt-6 text-center">
<p class="text-sm text-gray-500">
Noch kein Konto?
<NuxtLink to="/register" class="text-indigo-600 hover:text-indigo-700 font-medium">Kostenlos registrieren</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import type { AuthFormField } from '@nuxt/ui/runtime/components/AuthForm.vue.js'
definePageMeta({
layout: 'empty'
})
definePageMeta({ layout: 'auth', middleware: 'guest' })
const { t } = useI18n()
const toast = useToast()
const loading = ref(false)
// The otpId is used to track the OTP request and verify the user
// null if no request was sent yet
const otpId = ref<string | null>(null)
const fields = computed(() => {
const fields: AuthFormField[] = [{
name: 'email',
type: 'text',
label: t('login.email'),
placeholder: t('login.emailPlaceholder'),
required: true
}]
if (otpId.value !== null) {
fields.push({
name: 'otp',
type: 'otp',
label: t('login.otp'),
length: 6,
size: 'xl'
})
}
return fields
const formState = reactive({
email: '',
password: '',
})
const { signInWithEmail, signInWithOAuth, signInWithOtp } = useUser()
const oAuthAndRedirect = async (provider: 'apple' | 'google') => {
try {
await signInWithOAuth(provider)
navigateTo('/confirm')
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
}
}
const providers = [
{
label: 'Google',
icon: 'i-simple-icons-google',
onClick: () => oAuthAndRedirect('google')
},
{
label: 'Apple',
icon: 'i-simple-icons-apple',
onClick: () => oAuthAndRedirect('apple')
}
]
const schema = z.object({
email: z.email(t('login.invalidEmail')),
otp: z.array(z.string()).optional().transform(val => val?.join(''))
email: z.email('Ungültige E-Mail-Adresse'),
password: z.string().min(1, 'Passwort ist erforderlich'),
})
type Schema = z.output<typeof schema>
const { locale } = useI18n()
async function onSubmit(payload: FormSubmitEvent<Schema>) {
loading.value = true
try {
if (otpId.value !== null && payload.data.otp?.length) {
// If OTP is provided, sign in with OTP
await signInWithOtp(otpId.value, payload.data.otp)
navigateTo('/confirm')
} else {
// If OTP is not provided, sign in with email (send OTP)
otpId.value = await signInWithEmail(payload.data.email, locale.value)
toast.add({
title: t('login.emailSent'),
description: t('login.emailSentDescription'),
icon: 'i-lucide-check',
color: 'success'
})
}
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
const { pb } = usePocketBase()
const authData = await pb.collection('users').authWithPassword(payload.data.email, payload.data.password)
// Load org
const orgStore = useOrg()
const orgId = (authData.record as { org_id?: string }).org_id
if (orgId) await orgStore.fetchOrg(orgId)
await navigateTo('/dashboard')
} catch {
toast.add({ color: 'error', title: 'Anmeldung fehlgeschlagen', description: 'E-Mail oder Passwort ist falsch.' })
} finally {
loading.value = false
}
}
</script>

107
app/pages/register.vue Normal file
View File

@@ -0,0 +1,107 @@
<template>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Konto erstellen</h2>
<p class="text-gray-500 mb-6 text-sm">Starten Sie kostenlos keine Kreditkarte erforderlich</p>
<UForm :schema="schema" :state="formState" @submit="onSubmit" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<UFormField label="Ihr Name" name="name">
<UInput v-model="formState.name" placeholder="Max Mustermann" class="w-full" />
</UFormField>
<UFormField label="Firma / Organisation" name="orgName">
<UInput v-model="formState.orgName" placeholder="Mein Betrieb GmbH" class="w-full" />
</UFormField>
</div>
<UFormField label="E-Mail" name="email">
<UInput v-model="formState.email" type="email" placeholder="name@firma.de" class="w-full" />
</UFormField>
<UFormField label="Passwort" name="password">
<UInput v-model="formState.password" type="password" placeholder="Mindestens 8 Zeichen" class="w-full" />
</UFormField>
<UButton type="submit" color="primary" class="w-full justify-center" :loading="loading" size="lg">
Kostenlos registrieren
</UButton>
</UForm>
<div class="mt-6 text-center">
<p class="text-sm text-gray-500">
Bereits ein Konto?
<NuxtLink to="/login" class="text-indigo-600 hover:text-indigo-700 font-medium">Anmelden</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
definePageMeta({ layout: 'auth', middleware: 'guest' })
const toast = useToast()
const loading = ref(false)
const formState = reactive({
name: '',
orgName: '',
email: '',
password: '',
})
const schema = z.object({
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
orgName: z.string().min(2, 'Organisationsname muss mindestens 2 Zeichen lang sein'),
email: z.email('Ungültige E-Mail-Adresse'),
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
})
type Schema = z.output<typeof schema>
async function onSubmit(payload: FormSubmitEvent<Schema>) {
loading.value = true
try {
const { pb } = usePocketBase()
// Create org first
const slugBase = payload.data.orgName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
const slug = `${slugBase}-${Math.random().toString(36).slice(2, 7)}`
const newOrg = await pb.collection('organizations').create({
name: payload.data.orgName,
slug,
timezone: 'Europe/Berlin',
industry: 'general',
plan: 'free',
plan_employee_limit: 5,
plan_history_months: 1,
})
// Create user
const newUser = await pb.collection('users').create({
name: payload.data.name,
email: payload.data.email,
password: payload.data.password,
passwordConfirm: payload.data.password,
org_id: newOrg.id,
role: 'owner',
emailVisibility: true,
})
// Update org with owner
await pb.collection('organizations').update(newOrg.id, { owner: newUser.id })
// Login
await pb.collection('users').authWithPassword(payload.data.email, payload.data.password)
const orgStore = useOrg()
await orgStore.fetchOrg(newOrg.id)
toast.add({ color: 'success', title: 'Willkommen bei ShiftCraft!', description: 'Ihr Konto wurde erfolgreich erstellt.' })
await navigateTo('/dashboard')
} catch (err) {
toast.add({ color: 'error', title: 'Registrierung fehlgeschlagen', description: (err as Error).message })
} finally {
loading.value = false
}
}
</script>

View File

@@ -0,0 +1,259 @@
<template>
<div>
<!-- Header -->
<div class="mb-6 flex items-center gap-3">
<NuxtLink to="/schedules">
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
</NuxtLink>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3">
<h1 class="text-2xl font-bold text-gray-900 truncate">{{ run?.name ?? 'Schichtplan' }}</h1>
<UBadge v-if="run" :color="statusColor(run.status)" variant="subtle">
{{ statusLabel(run.status) }}
</UBadge>
</div>
<p v-if="run" class="text-gray-500 text-sm mt-1">
{{ formatDate(run.period_start) }} {{ formatDate(run.period_end) }}
<span v-if="run.solver_duration_ms" class="ml-2 text-gray-400">
· {{ (run.solver_duration_ms / 1000).toFixed(1) }}s Berechnungszeit
</span>
</p>
</div>
</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 Schichtplan...
</div>
<!-- Infeasible notice -->
<div v-else-if="run?.status === 'infeasible'" class="space-y-4">
<div class="bg-amber-50 border border-amber-200 rounded-2xl p-6">
<div class="flex items-start gap-3">
<UIcon name="i-lucide-alert-triangle" class="w-6 h-6 text-amber-600 mt-0.5 shrink-0" />
<div>
<h2 class="font-bold text-amber-900 mb-2">Kein gültiger Schichtplan gefunden</h2>
<p class="text-amber-700 text-sm mb-4">
Die aktiven Bedingungen sind möglicherweise zu restriktiv oder es gibt nicht genügend Mitarbeiter.
</p>
<div v-if="hints.length > 0">
<p class="font-medium text-amber-800 text-sm mb-2">Hinweise:</p>
<ul class="space-y-1">
<li v-for="hint in hints" :key="hint.description" class="text-amber-700 text-sm flex items-start gap-2">
<UIcon name="i-lucide-chevron-right" class="w-4 h-4 mt-0.5 shrink-0" />
{{ hint.description }}
</li>
</ul>
</div>
<div class="mt-4 flex gap-3">
<NuxtLink to="/constraints">
<UButton color="warning" variant="soft" size="sm" icon="i-lucide-sliders">
Bedingungen prüfen
</UButton>
</NuxtLink>
<NuxtLink to="/schedules/new">
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-refresh-cw">
Neu berechnen
</UButton>
</NuxtLink>
</div>
</div>
</div>
</div>
</div>
<!-- Error notice -->
<div v-else-if="run?.status === 'error'" class="bg-red-50 border border-red-200 rounded-2xl p-6">
<div class="flex items-start gap-3">
<UIcon name="i-lucide-x-circle" class="w-6 h-6 text-red-600 mt-0.5 shrink-0" />
<div>
<h2 class="font-bold text-red-900 mb-1">Fehler bei der Berechnung</h2>
<p class="text-red-600 text-sm">{{ hints[0]?.description ?? 'Unbekannter Fehler' }}</p>
</div>
</div>
</div>
<!-- Schedule table -->
<div v-else-if="run?.status === 'solved' && assignments.length > 0">
<!-- Stats row -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
<p class="text-2xl font-bold text-gray-900">{{ employeeNames.length }}</p>
<p class="text-xs text-gray-500 mt-1">Mitarbeiter</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
<p class="text-2xl font-bold text-gray-900">{{ assignments.length }}</p>
<p class="text-xs text-gray-500 mt-1">Schichten gesamt</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
<p class="text-2xl font-bold text-gray-900">{{ periodDays }}</p>
<p class="text-xs text-gray-500 mt-1">Tage</p>
</div>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
<p class="text-2xl font-bold text-gray-900">
{{ run.objective_value !== undefined ? run.objective_value.toFixed(1) : '—' }}
</p>
<p class="text-xs text-gray-500 mt-1">Obj. Wert</p>
</div>
</div>
<!-- Table -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead>
<tr class="bg-gray-50 border-b border-gray-100">
<th class="px-4 py-3 text-left font-medium text-gray-600 whitespace-nowrap sticky left-0 bg-gray-50 z-10 min-w-32">
Mitarbeiter
</th>
<th
v-for="date in allDates"
:key="date"
class="px-2 py-3 text-center font-medium text-gray-600 whitespace-nowrap min-w-16"
:class="isWeekend(date) ? 'bg-amber-50' : ''"
>
<div>{{ dayOfWeek(date) }}</div>
<div class="text-xs text-gray-400">{{ shortDate(date) }}</div>
</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-50">
<tr v-for="emp in employeeNames" :key="emp" class="hover:bg-gray-50 transition-colors">
<td class="px-4 py-2.5 font-medium text-gray-900 whitespace-nowrap sticky left-0 bg-white z-10">
{{ emp }}
</td>
<td
v-for="date in allDates"
:key="date"
class="px-1 py-2 text-center"
:class="isWeekend(date) ? 'bg-amber-50/50' : ''"
>
<div
v-for="a in getAssignments(emp, date)"
:key="a.shift_id"
class="px-1.5 py-0.5 rounded-md text-xs font-medium whitespace-nowrap"
:style="{ backgroundColor: getPeriodColor(a.period_id) + '20', color: getPeriodColor(a.period_id) }"
>
{{ a.shift_name }}
</div>
<span v-if="getAssignments(emp, date).length === 0" class="text-gray-300 text-xs"></span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Legend -->
<div v-if="periods.length > 0" class="mt-4 flex flex-wrap gap-3">
<div v-for="p in periods" :key="p.id" class="flex items-center gap-1.5 text-xs text-gray-600">
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: p.color }" />
{{ p.name }} ({{ p.start_time }}{{ p.end_time }})
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { formatDate } from '~/app/utils/dateHelpers'
import type { PBScheduleRun } from '~/shared/types/pocketbase'
import type { ShiftAssignment, Period } from '~/shared/types/schedule'
definePageMeta({ layout: 'default', middleware: 'auth' })
const route = useRoute()
const { pb } = usePocketBase()
const loading = ref(true)
const run = ref<PBScheduleRun | null>(null)
const assignments = ref<ShiftAssignment[]>([])
const periods = ref<Period[]>([])
const hints = computed(() => {
const h = run.value?.infeasibility_hints
if (!h) return []
if (Array.isArray(h)) return h as Array<{ description: string }>
return []
})
const employeeNames = computed(() => {
const names = new Set(assignments.value.map(a => a.employee_name))
return Array.from(names).sort()
})
const allDates = computed(() => {
if (!run.value) return []
const dates: string[] = []
const start = new Date(run.value.period_start)
const end = new Date(run.value.period_end)
const cur = new Date(start)
while (cur <= end) {
dates.push(cur.toISOString().slice(0, 10))
cur.setDate(cur.getDate() + 1)
}
return dates
})
const periodDays = computed(() => allDates.value.length)
function getAssignments(empName: string, date: string) {
return assignments.value.filter(a => a.employee_name === empName && a.date === date)
}
function getPeriodColor(periodId: string) {
const p = periods.value.find(p => p.id === periodId)
return p?.color || '#6366f1'
}
function dayOfWeek(date: string) {
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
return days[new Date(date + 'T12:00:00').getDay()]
}
function shortDate(date: string) {
const d = new Date(date + 'T12:00:00')
return `${d.getDate()}.${d.getMonth() + 1}`
}
function isWeekend(date: string) {
const day = new Date(date + 'T12:00:00').getDay()
return day === 0 || day === 6
}
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'
}
onMounted(async () => {
const id = route.params.id as string
try {
const result = await pb.collection('schedule_runs').getOne(id)
run.value = result as unknown as PBScheduleRun
if (result.result) {
assignments.value = Array.isArray(result.result)
? result.result as ShiftAssignment[]
: []
}
const snapshot = result.framework_snapshot as { periods?: Period[] }
if (snapshot?.periods) {
periods.value = snapshot.periods
}
} catch (err) {
console.error('Failed to load schedule run', err)
} finally {
loading.value = false
}
})
</script>

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>

241
app/pages/schedules/new.vue Normal file
View File

@@ -0,0 +1,241 @@
<template>
<div class="max-w-xl mx-auto">
<!-- Header -->
<div class="mb-8 flex items-center gap-3">
<NuxtLink to="/schedules">
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
</NuxtLink>
<div>
<h1 class="text-2xl font-bold text-gray-900">Neuen Schichtplan erstellen</h1>
<p class="text-gray-500 mt-1 text-sm">KI-gestützter Optimierer berechnet den besten Plan</p>
</div>
</div>
<!-- Solving progress -->
<div v-if="solvingRunId" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 text-center">
<div class="w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mx-auto mb-4">
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-indigo-600 animate-spin" />
</div>
<h2 class="font-bold text-gray-900 mb-2">Berechnung läuft...</h2>
<p class="text-gray-500 text-sm mb-4">Der Schichtplan-Optimierer arbeitet. Dies kann einige Sekunden dauern.</p>
<UProgress v-model="solveProgress" :max="100" class="mb-4" />
<p class="text-xs text-gray-400">Status: {{ solveStatusLabel }}</p>
</div>
<!-- Input form -->
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="space-y-5">
<UFormField label="Name des Schichtplans" required>
<UInput
v-model="form.name"
placeholder="z.B. Schichtplan Juni 2026"
class="w-full"
/>
</UFormField>
<div class="grid grid-cols-2 gap-4">
<UFormField label="Start-Datum" required>
<UInput v-model="form.period_start" type="date" class="w-full" />
</UFormField>
<UFormField label="End-Datum" required>
<UInput v-model="form.period_end" type="date" class="w-full" />
</UFormField>
</div>
<!-- Summary -->
<div class="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
<div class="flex items-center justify-between">
<span class="text-gray-500">Mitarbeiter</span>
<span class="font-medium text-gray-900">
<span v-if="loadingStats" class="text-gray-400">...</span>
<span v-else>{{ employeeCount }}</span>
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500">Aktive Bedingungen</span>
<span class="font-medium text-gray-900">
<span v-if="loadingStats" class="text-gray-400">...</span>
<span v-else>{{ constraintCount }}</span>
</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-500">Schichtrahmen</span>
<span class="font-medium" :class="hasFramework ? 'text-green-600' : 'text-red-600'">
<span v-if="loadingStats" class="text-gray-400">...</span>
<span v-else>{{ hasFramework ? 'Konfiguriert' : 'Fehlt!' }}</span>
</span>
</div>
</div>
<!-- Warnings -->
<div v-if="!loadingStats && !hasFramework" class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-alert-triangle" class="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
<p class="text-amber-700">
Kein Schichtrahmen konfiguriert.
<NuxtLink to="/settings/shifts" class="font-medium underline">Schichten einrichten </NuxtLink>
</p>
</div>
</div>
<div v-if="!loadingStats && employeeCount === 0" class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm">
<div class="flex items-start gap-2">
<UIcon name="i-lucide-alert-triangle" class="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
<p class="text-amber-700">
Keine Mitarbeiter gefunden.
<NuxtLink to="/employees" class="font-medium underline">Mitarbeiter hinzufügen </NuxtLink>
</p>
</div>
</div>
<UButton
color="primary"
icon="i-lucide-calculator"
class="w-full justify-center"
size="lg"
:loading="creating"
:disabled="!canCreate"
@click="createAndSolve"
>
Schichtplan berechnen
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ShiftFramework } from '~/shared/types/pocketbase'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const creating = ref(false)
const loadingStats = ref(true)
const employeeCount = ref(0)
const constraintCount = ref(0)
const hasFramework = ref(false)
const solvingRunId = ref<string | null>(null)
const solveProgress = ref(10)
const solveStatus = ref('pending')
const today = new Date()
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1)
const lastDayNextMonth = new Date(today.getFullYear(), today.getMonth() + 2, 0)
const form = reactive({
name: `Schichtplan ${nextMonth.toLocaleString('de', { month: 'long', year: 'numeric' })}`,
period_start: nextMonth.toISOString().slice(0, 10),
period_end: lastDayNextMonth.toISOString().slice(0, 10),
})
const canCreate = computed(() =>
!!form.name && !!form.period_start && !!form.period_end && !loadingStats.value && employeeCount.value > 0 && hasFramework.value
)
const solveStatusLabel = computed(() => {
const map: Record<string, string> = { pending: 'Wird vorbereitet...', solving: 'Löser läuft...', solved: 'Abgeschlossen', infeasible: 'Keine Lösung gefunden', error: 'Fehler' }
return map[solveStatus.value] ?? solveStatus.value
})
let pollInterval: ReturnType<typeof setInterval> | null = null
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
if (!orgId) { loadingStats.value = false; return }
try {
const [employees, constraints, frameworks] = 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('shift_frameworks').getList(1, 1, { filter: `org_id = "${orgId}"` }),
])
employeeCount.value = employees.totalItems
constraintCount.value = constraints.totalItems
hasFramework.value = frameworks.totalItems > 0
} catch (err) {
console.error('Stats load error', err)
} finally {
loadingStats.value = false
}
})
onUnmounted(() => {
if (pollInterval) clearInterval(pollInterval)
})
async function createAndSolve() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
creating.value = true
try {
// Fetch snapshot data
const [employees, constraints, frameworkResult] = await Promise.all([
pb.collection('employees').getFullList({ filter: `org_id = "${orgId}" && active = true` }),
pb.collection('constraints').getFullList({ filter: `org_id = "${orgId}" && active = true` }),
pb.collection('shift_frameworks').getFirstListItem(`org_id = "${orgId}"`),
])
const framework = frameworkResult as unknown as ShiftFramework
// Create schedule run record
const run = await pb.collection('schedule_runs').create({
org_id: orgId,
name: form.name,
period_start: form.period_start,
period_end: form.period_end,
status: 'pending',
framework_snapshot: { periods: framework.periods, shifts: framework.shifts },
employees_snapshot: employees,
constraints_snapshot: constraints.map((c: Record<string, unknown>) => ({
id: c.id,
constraint_json: c.constraint_json,
hard: c.hard,
weight: c.weight,
})),
created_by: authStore.value.record?.id,
})
solvingRunId.value = run.id
solveProgress.value = 20
// Call solve API
$fetch('/api/schedules/solve', {
method: 'POST',
body: { run_id: run.id },
}).catch((err) => {
console.error('Solve error', err)
})
// Poll status
pollInterval = setInterval(async () => {
try {
const updated = await pb.collection('schedule_runs').getOne(run.id)
solveStatus.value = updated.status as string
if (solveProgress.value < 80) solveProgress.value += 10
if (updated.status === 'solved') {
solveProgress.value = 100
clearInterval(pollInterval!)
await navigateTo(`/schedules/${run.id}`)
} else if (updated.status === 'infeasible' || updated.status === 'error') {
clearInterval(pollInterval!)
toast.add({ color: 'warning', title: 'Berechnung abgeschlossen', description: updated.status === 'infeasible' ? 'Kein gültiger Plan gefunden.' : 'Fehler bei der Berechnung.' })
await navigateTo(`/schedules/${run.id}`)
}
} catch (e) {
console.error('Poll error', e)
}
}, 2000)
} catch (err) {
toast.add({ color: 'error', title: 'Fehler beim Erstellen', description: String(err) })
} finally {
creating.value = false
}
}
</script>

View File

@@ -0,0 +1,172 @@
<template>
<div class="max-w-3xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Abonnement</h1>
<p class="text-gray-500 mt-1 text-sm">Verwalten Sie Ihr ShiftCraft-Abonnement</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>
<!-- Current plan card -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6">
<div class="flex items-start justify-between mb-4">
<div>
<p class="text-sm text-gray-500 mb-1">Aktueller Plan</p>
<h2 class="text-2xl font-bold text-gray-900">{{ currentPlanDetails.name }}</h2>
<p class="text-gray-500 text-sm mt-1">{{ currentPlanDetails.description }}</p>
</div>
<div class="text-right">
<span class="text-3xl font-bold text-gray-900">{{ currentPlanDetails.price_eur_month }}</span>
<span class="text-gray-400 text-sm">/Monat</span>
</div>
</div>
<!-- Usage stats -->
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-gray-50 rounded-xl">
<div class="text-center">
<p class="text-lg font-bold text-gray-900">
{{ employeeCount }}<span class="text-gray-400 text-sm font-normal"> / {{ employeeLimit === Infinity ? '∞' : employeeLimit }}</span>
</p>
<p class="text-xs text-gray-500">Mitarbeiter</p>
</div>
<div class="text-center">
<p class="text-lg font-bold text-gray-900">
{{ currentPlanDetails.solve_runs_per_month === Infinity ? '∞' : currentPlanDetails.solve_runs_per_month }}
</p>
<p class="text-xs text-gray-500">Schichtpläne/Monat</p>
</div>
<div class="text-center">
<p class="text-lg font-bold text-gray-900">
{{ currentPlanDetails.history_months === Infinity ? '∞' : currentPlanDetails.history_months }}
</p>
<p class="text-xs text-gray-500">Monate Verlauf</p>
</div>
<div class="text-center">
<p class="text-lg font-bold" :class="currentPlanDetails.pdf_export ? 'text-green-600' : 'text-gray-400'">
{{ currentPlanDetails.pdf_export ? 'Ja' : 'Nein' }}
</p>
<p class="text-xs text-gray-500">PDF/Excel Export</p>
</div>
</div>
</div>
<!-- Upgrade plans -->
<div v-if="plan !== 'business'" class="space-y-4">
<h3 class="font-bold text-gray-900">Upgraden Sie Ihren Plan</h3>
<div class="grid sm:grid-cols-2 gap-4">
<!-- Pro plan -->
<div
v-if="plan === 'free'"
class="bg-gradient-to-br from-indigo-50 to-violet-50 rounded-2xl border-2 border-indigo-200 p-6"
>
<div class="flex items-center justify-between mb-3">
<h4 class="font-bold text-indigo-900 text-lg">Pro</h4>
<div class="text-right">
<span class="text-2xl font-bold text-indigo-900">29</span>
<span class="text-indigo-600 text-sm">/Monat</span>
</div>
</div>
<p class="text-indigo-700 text-sm mb-4">{{ PLAN_LIMITS.pro.description }}</p>
<ul class="space-y-1.5 mb-5">
<li v-for="f in PLAN_LIMITS.pro.features" :key="f" class="flex items-center gap-2 text-sm text-indigo-800">
<UIcon name="i-lucide-check" class="w-4 h-4 text-indigo-600 shrink-0" />
{{ f }}
</li>
</ul>
<UButton color="primary" class="w-full justify-center" @click="upgrade('pro')">
Auf Pro upgraden
</UButton>
</div>
<!-- Business plan -->
<div class="bg-gradient-to-br from-violet-50 to-purple-50 rounded-2xl border-2 border-violet-200 p-6">
<div class="flex items-center justify-between mb-3">
<h4 class="font-bold text-violet-900 text-lg">Business</h4>
<div class="text-right">
<span class="text-2xl font-bold text-violet-900">99</span>
<span class="text-violet-600 text-sm">/Monat</span>
</div>
</div>
<p class="text-violet-700 text-sm mb-4">{{ PLAN_LIMITS.business.description }}</p>
<ul class="space-y-1.5 mb-5">
<li v-for="f in PLAN_LIMITS.business.features" :key="f" class="flex items-center gap-2 text-sm text-violet-800">
<UIcon name="i-lucide-check" class="w-4 h-4 text-violet-600 shrink-0" />
{{ f }}
</li>
</ul>
<UButton color="secondary" class="w-full justify-center" style="background: #7c3aed; color: white" @click="upgrade('business')">
Auf Business upgraden
</UButton>
</div>
</div>
<p class="text-xs text-gray-400 text-center">
Upgrade-Funktion via Stripe noch nicht vollständig integriert. Kontaktieren Sie uns für Enterprise-Pricing.
</p>
</div>
<div v-else class="bg-green-50 border border-green-200 rounded-2xl p-4 flex items-center gap-3">
<UIcon name="i-lucide-check-circle" class="w-5 h-5 text-green-600 shrink-0" />
<p class="text-green-800 text-sm font-medium">Sie haben den Business-Plan vollen Funktionsumfang freigeschaltet!</p>
</div>
</div>
</template>
<script setup lang="ts">
import { PLAN_LIMITS } from '~/app/utils/planLimits'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const { plan, limits } = useSubscription()
const employeeCount = ref(0)
const employeeLimit = computed(() => limits.value.employee_limit)
const settingsTabs = [
{ to: '/settings/organization', label: 'Organisation' },
{ to: '/settings/shifts', label: 'Schichten' },
{ to: '/settings/billing', label: 'Abonnement' },
]
const currentPlanDetails = computed(() => PLAN_LIMITS[plan.value])
function upgrade(_planName: string) {
toast.add({
color: 'primary',
title: 'Stripe-Integration',
description: 'Upgrade-Link kommt bald. Kontaktieren Sie uns unter billing@shiftcraft.app',
})
}
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
if (!orgId) return
try {
const result = await pb.collection('employees').getList(1, 1, {
filter: `org_id = "${orgId}" && active = true`,
})
employeeCount.value = result.totalItems
} catch (err) {
console.error('Failed to load employee count', err)
}
})
</script>

View File

@@ -0,0 +1,128 @@
<template>
<div class="max-w-2xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Organisationseinstellungen</h1>
<p class="text-gray-500 mt-1 text-sm">Verwalten Sie die Grundeinstellungen Ihrer 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 Einstellungen...
</div>
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="space-y-5">
<UFormField label="Organisationsname" required>
<UInput v-model="form.name" placeholder="Mein Unternehmen GmbH" class="w-full" />
</UFormField>
<UFormField label="Zeitzone">
<USelect v-model="form.timezone" :options="timezoneOptions" class="w-full" />
</UFormField>
<UFormField label="Branche">
<USelect v-model="form.industry" :options="industryOptions" class="w-full" />
</UFormField>
</div>
<div class="mt-6 flex gap-3">
<UButton color="primary" :loading="saving" @click="save">
Speichern
</UButton>
<UButton color="neutral" variant="ghost" @click="reset">
Zurücksetzen
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const orgStore = useOrg()
const { authStore } = usePocketBase()
const loading = ref(true)
const saving = ref(false)
const form = reactive({
name: '',
timezone: 'Europe/Berlin',
industry: 'retail',
})
const settingsTabs = [
{ to: '/settings/organization', label: 'Organisation' },
{ to: '/settings/shifts', label: 'Schichten' },
{ to: '/settings/billing', label: 'Abonnement' },
]
const timezoneOptions = [
{ label: 'Europa/Berlin (CET)', value: 'Europe/Berlin' },
{ label: 'Europa/Wien (CET)', value: 'Europe/Vienna' },
{ label: 'Europa/Zürich (CET)', value: 'Europe/Zurich' },
{ label: 'UTC', value: 'UTC' },
]
const industryOptions = [
{ label: 'Einzelhandel', value: 'retail' },
{ label: 'Gastronomie', value: 'hospitality' },
{ label: 'Gesundheitswesen', value: 'healthcare' },
{ label: 'Logistik', value: 'logistics' },
{ label: 'Produktion', value: 'manufacturing' },
{ label: 'Sonstiges', value: 'other' },
]
function loadFormFromOrg() {
if (orgStore.org) {
form.name = orgStore.org.name || ''
form.timezone = orgStore.org.timezone || 'Europe/Berlin'
form.industry = orgStore.org.industry || 'retail'
}
}
function reset() {
loadFormFromOrg()
}
async function save() {
saving.value = true
try {
await orgStore.updateOrg({
name: form.name,
timezone: form.timezone,
industry: form.industry,
})
toast.add({ color: 'success', title: 'Einstellungen 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)
loadFormFromOrg()
loading.value = false
})
</script>

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>

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

View File

@@ -36,9 +36,15 @@ export default defineNuxtConfig({
css: ['~/assets/css/main.css'],
runtimeConfig: {
pbAdminEmail: process.env.PB_ADMIN_EMAIL || '',
pbAdminPassword: process.env.PB_ADMIN_PASSWORD || '',
anthropicApiKey: process.env.ANTHROPIC_API_KEY || '',
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
public: {
appUrl: 'http://localhost:3000',
pocketbaseUrl: 'http://127.0.0.1:8090',
pocketbaseUrl: process.env.NUXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090',
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
rybbitScriptUrl: '',
rybbitSiteId: ''
}

View File

@@ -20,6 +20,7 @@
"capacitor:copy:after": "cp creds/fcm-google-services.json android/app/google-services.json"
},
"dependencies": {
"@anthropic-ai/sdk": "^0.90.0",
"@capacitor/android": "^8.3.1",
"@capacitor/app": "^8.1.0",
"@capacitor/core": "^8.3.1",
@@ -32,6 +33,7 @@
"@nuxtjs/i18n": "10.2.4",
"@pinia/nuxt": "0.11.3",
"dotenv": "^17.4.2",
"highs": "^1.8.0",
"nuxt": "^4.4.2",
"pocketbase": "^0.26.8",
"tailwindcss": "^4.2.2",

46
pnpm-lock.yaml generated
View File

@@ -8,6 +8,9 @@ importers:
.:
dependencies:
'@anthropic-ai/sdk':
specifier: ^0.90.0
version: 0.90.0(zod@4.3.6)
'@capacitor/android':
specifier: ^8.3.1
version: 8.3.1(@capacitor/core@8.3.1)
@@ -44,6 +47,9 @@ importers:
dotenv:
specifier: ^17.4.2
version: 17.4.2
highs:
specifier: ^1.8.0
version: 1.8.0
nuxt:
specifier: ^4.4.2
version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.32)(cac@6.7.14)(db0@0.3.4(sqlite3@5.1.7))(encoding@0.1.13)(eslint@10.2.0(jiti@2.6.1))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.32(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.1))(rollup@4.60.1)(sqlite3@5.1.7)(srvx@0.11.15)(terser@5.46.1)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@6.0.3))(xml2js@0.6.2)(yaml@2.8.3)
@@ -106,6 +112,15 @@ packages:
'@antfu/install-pkg@1.1.0':
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
'@anthropic-ai/sdk@0.90.0':
resolution: {integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==}
hasBin: true
peerDependencies:
zod: ^3.25.0 || ^4.0.0
peerDependenciesMeta:
zod:
optional: true
'@apidevtools/json-schema-ref-parser@14.2.1':
resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==}
engines: {node: '>= 20'}
@@ -217,6 +232,10 @@ packages:
peerDependencies:
'@babel/core': ^7.0.0-0
'@babel/runtime@7.29.2':
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
engines: {node: '>=6.9.0'}
'@babel/template@7.28.6':
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
engines: {node: '>=6.9.0'}
@@ -4383,6 +4402,9 @@ packages:
hey-listen@1.0.8:
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
highs@1.8.0:
resolution: {integrity: sha512-0QFXXjU/mxU1y3Ec44QpESq6STkPauNDfIPf0welcUGjilpVYnfH9RepBvr0t9YDQPUniIcZTX9xyxqxF668ag==}
hookable@5.5.3:
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
@@ -4625,6 +4647,10 @@ packages:
json-buffer@3.0.1:
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
json-schema-to-ts@3.1.1:
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
engines: {node: '>=16'}
json-schema-to-typescript-lite@15.0.0:
resolution: {integrity: sha512-5mMORSQm9oTLyjM4mWnyNBi2T042Fhg1/0gCIB6X8U/LVpM2A+Nmj2yEyArqVouDmFThDxpEXcnTgSrjkGJRFA==}
@@ -6116,6 +6142,9 @@ packages:
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
hasBin: true
ts-algebra@2.0.0:
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
ts-api-utils@2.5.0:
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
engines: {node: '>=18.12'}
@@ -6726,6 +6755,12 @@ snapshots:
package-manager-detector: 1.6.0
tinyexec: 1.1.1
'@anthropic-ai/sdk@0.90.0(zod@4.3.6)':
dependencies:
json-schema-to-ts: 3.1.1
optionalDependencies:
zod: 4.3.6
'@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)':
dependencies:
'@types/json-schema': 7.0.15
@@ -6875,6 +6910,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
'@babel/runtime@7.29.2': {}
'@babel/template@7.28.6':
dependencies:
'@babel/code-frame': 7.29.0
@@ -11150,6 +11187,8 @@ snapshots:
hey-listen@1.0.8: {}
highs@1.8.0: {}
hookable@5.5.3: {}
hookable@6.1.0: {}
@@ -11405,6 +11444,11 @@ snapshots:
json-buffer@3.0.1: {}
json-schema-to-ts@3.1.1:
dependencies:
'@babel/runtime': 7.29.2
ts-algebra: 2.0.0
json-schema-to-typescript-lite@15.0.0:
dependencies:
'@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15)
@@ -13345,6 +13389,8 @@ snapshots:
tree-kill@1.2.2: {}
ts-algebra@2.0.0: {}
ts-api-utils@2.5.0(typescript@6.0.3):
dependencies:
typescript: 6.0.3

248
scripts/setup-pb.ts Normal file
View File

@@ -0,0 +1,248 @@
#!/usr/bin/env npx tsx
/**
* ShiftCraft PocketBase Setup Script
*
* Creates all required collections and seeds legal templates.
* Run after starting PocketBase:
* pnpm pocketbase:start
* npx tsx scripts/setup-pb.ts
*/
import PocketBase from 'pocketbase'
const PB_URL = process.env.NUXT_PUBLIC_PB_URL || process.env.NUXT_PUBLIC_POCKETBASE_URL || 'http://localhost:8090'
const PB_ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL || process.env.SUPERUSER_EMAIL || 'admin@shiftcraft.app'
const PB_ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD || process.env.SUPERUSER_PW || 'changeme123'
async function main() {
const pb = new PocketBase(PB_URL)
console.log(`Connecting to PocketBase at ${PB_URL}...`)
await pb.admins.authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
console.log('Connected to PocketBase admin')
const collections = [
{
name: 'organizations',
type: 'base',
schema: [
{ name: 'name', type: 'text', required: true },
{ name: 'slug', type: 'text', required: true },
{ name: 'timezone', type: 'text' },
{ name: 'industry', type: 'text' },
{ name: 'owner', type: 'text' },
{ name: 'plan', type: 'select', options: { values: ['free', 'pro', 'business'], maxSelect: 1 } },
{ name: 'plan_employee_limit', type: 'number' },
{ name: 'plan_history_months', type: 'number' },
{ name: 'stripe_customer_id', type: 'text' },
{ name: 'stripe_subscription_id', type: 'text' },
{ name: 'stripe_subscription_status', type: 'text' },
{ name: 'trial_ends_at', type: 'date' },
],
},
{
name: 'employees',
type: 'base',
schema: [
{ name: 'org_id', type: 'text', required: true },
{ name: 'name', type: 'text', required: true },
{ name: 'email', type: 'email' },
{ name: 'employee_number', type: 'text' },
{ name: 'roles', type: 'json' },
{ name: 'skills', type: 'json' },
{ name: 'employment_type', type: 'select', options: { values: ['full_time', 'part_time', 'mini_job'], maxSelect: 1 } },
{ name: 'weekly_hours_target', type: 'number' },
{ name: 'max_weekly_hours', type: 'number' },
{ name: 'available_periods', type: 'json' },
{ name: 'unavailable_dates', type: 'json' },
{ name: 'notes', type: 'text' },
{ name: 'active', type: 'bool' },
],
},
{
name: 'shift_frameworks',
type: 'base',
schema: [
{ name: 'org_id', type: 'text', required: true },
{ name: 'periods', type: 'json' },
{ name: 'shifts', type: 'json' },
{ name: 'scheduling_horizon_days', type: 'number' },
],
},
{
name: 'constraints',
type: 'base',
schema: [
{ name: 'org_id', type: 'text', required: true },
{ name: 'label', type: 'text', required: true },
{ name: 'source_text', type: 'text' },
{ name: 'constraint_json', type: 'json' },
{ name: 'scope', type: 'text' },
{ name: 'scope_ref', type: 'text' },
{ name: 'category', type: 'select', options: { values: ['legal', 'preference', 'operational', 'other'], maxSelect: 1 } },
{ name: 'hard', type: 'bool' },
{ name: 'weight', type: 'number' },
{ name: 'active', type: 'bool' },
{ name: 'source', type: 'select', options: { values: ['ai', 'legal', 'manual'], maxSelect: 1 } },
{ name: 'template_id', type: 'text' },
],
},
{
name: 'legal_templates',
type: 'base',
schema: [
{ name: 'region', type: 'text' },
{ name: 'law_name', type: 'text' },
{ name: 'label', type: 'text', required: true },
{ name: 'description', type: 'text' },
{ name: 'constraint_json', type: 'json' },
{ name: 'category', type: 'text' },
{ name: 'mandatory', type: 'bool' },
{ name: 'sort_order', type: 'number' },
],
},
{
name: 'schedule_runs',
type: 'base',
schema: [
{ name: 'org_id', type: 'text', required: true },
{ name: 'name', type: 'text', required: true },
{ name: 'period_start', type: 'date', required: true },
{ name: 'period_end', type: 'date', required: true },
{ name: 'framework_snapshot', type: 'json' },
{ name: 'constraints_snapshot', type: 'json' },
{ name: 'employees_snapshot', type: 'json' },
{ name: 'status', type: 'select', options: { values: ['pending', 'solving', 'solved', 'infeasible', 'error'], maxSelect: 1 } },
{ name: 'solver_duration_ms', type: 'number' },
{ name: 'objective_value', type: 'number' },
{ name: 'infeasibility_hints', type: 'json' },
{ name: 'result', type: 'json' },
{ name: 'created_by', type: 'text' },
],
},
]
for (const col of collections) {
try {
await pb.collections.getOne(col.name)
console.log(` Collection '${col.name}' already exists — skipping`)
} catch {
try {
await pb.collections.create(col)
console.log(` Created collection '${col.name}'`)
} catch (err) {
console.error(` Failed to create '${col.name}':`, err)
}
}
}
// Seed legal templates
console.log('\nSeeding legal templates...')
const templates = [
{
region: 'Deutschland',
law_name: 'ArbZG §3',
label: 'Maximale Arbeitszeit 10 Stunden/Tag',
description: 'Die werktägliche Arbeitszeit darf 8 Stunden nicht überschreiten. Sie kann auf bis zu 10 Stunden verlängert werden, wenn innerhalb von 6 Kalendermonaten im Durchschnitt 8 Stunden/Tag nicht überschritten werden.',
constraint_json: {
type: 'max_hours_per_day',
scope: { type: 'global' },
params: { max_hours: 10 },
hard: true,
natural_language_summary: 'Maximal 10 Stunden Arbeitszeit pro Tag (ArbZG §3)',
},
category: 'legal',
mandatory: true,
sort_order: 1,
},
{
region: 'Deutschland',
law_name: 'ArbZG §5',
label: 'Mindestruhezeit 11 Stunden',
description: 'Nach Beendigung der täglichen Arbeitszeit ist den Arbeitnehmern eine ununterbrochene Ruhezeit von mindestens elf Stunden zu gewähren.',
constraint_json: {
type: 'min_rest_between_shifts',
scope: { type: 'global' },
params: { min_hours: 11 },
hard: true,
natural_language_summary: 'Mindestens 11 Stunden Ruhezeit zwischen Schichten (ArbZG §5)',
},
category: 'legal',
mandatory: true,
sort_order: 2,
},
{
region: 'Deutschland',
law_name: 'ArbZG §9',
label: 'Sonntagsruhe',
description: 'Arbeitnehmer dürfen an Sonn- und gesetzlichen Feiertagen von 0 bis 24 Uhr nicht beschäftigt werden (mit branchenspezifischen Ausnahmen).',
constraint_json: {
type: 'max_weekend_shifts_per_month',
scope: { type: 'global' },
params: { max_count: 4 },
hard: false,
weight: 80,
natural_language_summary: 'Maximale Sonntagsschichten nach ArbZG §9 beachten',
},
category: 'legal',
mandatory: false,
sort_order: 3,
},
{
region: 'Deutschland',
law_name: 'Empfehlung',
label: 'Maximal 6 Schichten am Stück',
description: 'Empfohlene Begrenzung aufeinanderfolgender Arbeitstage.',
constraint_json: {
type: 'max_consecutive_shifts',
scope: { type: 'global' },
params: { max_count: 6 },
hard: false,
weight: 70,
natural_language_summary: 'Nicht mehr als 6 Schichten in Folge',
},
category: 'legal',
mandatory: false,
sort_order: 4,
},
{
region: 'Deutschland',
law_name: 'Empfehlung',
label: 'Faire Schichtverteilung',
description: 'Nachtschichten und Wochenendschichten werden gleichmäßig auf alle Mitarbeiter verteilt.',
constraint_json: {
type: 'fair_distribution',
scope: { type: 'global' },
params: { metric: 'night_shifts', max_deviation_percent: 20 },
hard: false,
weight: 60,
natural_language_summary: 'Faire Verteilung der Nachtschichten (max. 20% Abweichung)',
},
category: 'preference',
mandatory: false,
sort_order: 5,
},
]
for (const tmpl of templates) {
try {
await pb.collection('legal_templates').getFirstListItem(`label = "${tmpl.label}"`)
console.log(` Template '${tmpl.label}' already exists — skipping`)
} catch {
try {
await pb.collection('legal_templates').create(tmpl)
console.log(` Created template '${tmpl.label}'`)
} catch (err) {
console.error(` Failed to create template '${tmpl.label}':`, err)
}
}
}
console.log('\nSetup complete!')
console.log('Next steps:')
console.log(' 1. Open PocketBase admin at http://localhost:8090/_/')
console.log(' 2. Configure the users collection to include org_id and role fields')
console.log(' 3. Run: pnpm dev')
}
main().catch(console.error)

View File

@@ -0,0 +1,98 @@
import { getAnthropicClient } from '~/server/utils/anthropic'
import { getPBAdminClient } from '~/server/utils/pb-admin'
import type { ParsedConstraintResult, ConstraintJSON } from '~/shared/types/constraint'
export default defineEventHandler(async (event): Promise<ParsedConstraintResult> => {
const body = await readBody(event)
const { text, org_id } = body
if (!text || !org_id) {
throw createError({ statusCode: 400, message: 'text and org_id are required' })
}
const pb = await getPBAdminClient()
// Fetch employees and framework for context
const [employeesResult, frameworkResult] = await Promise.allSettled([
pb.collection('employees').getFullList({ filter: `org_id = "${org_id}" && active = true`, fields: 'id,name,roles' }),
pb.collection('shift_frameworks').getFirstListItem(`org_id = "${org_id}"`),
])
const employees = employeesResult.status === 'fulfilled'
? employeesResult.value.map((e: { id: string; name: string; roles: string[] }) => ({ id: e.id, name: e.name, roles: e.roles }))
: []
const periods = frameworkResult.status === 'fulfilled'
? ((frameworkResult.value as { periods?: Array<{ id: string; name: string }> }).periods || [])
: []
const SYSTEM_PROMPT = `You are a scheduling constraint parser for a workforce management application called ShiftCraft.
Given free-text input from a manager, extract one or more scheduling constraints and output them as a JSON array.
AVAILABLE EMPLOYEES: ${JSON.stringify(employees)}
AVAILABLE SHIFT PERIODS: ${JSON.stringify(periods)}
CONSTRAINT TYPES YOU CAN USE:
- max_hours_per_day: params: {max_hours: number}
- max_hours_per_week: params: {max_hours: number}
- min_rest_between_shifts: params: {min_hours: number}
- max_consecutive_shifts: params: {max_count: number}
- max_consecutive_shift_type: params: {period_id: string, max_count: number}
- min_consecutive_days_off: params: {min_days: number}
- forbidden_shift_sequence: params: {first_period_id: string, second_period_id: string}
- employee_avoids_period: params: {period_id: string}
- employee_prefers_period: params: {period_id: string, prefer_count_per_week?: number}
- max_weekend_shifts_per_month: params: {max_count: number}
- fair_distribution: params: {metric: "total_shifts"|"night_shifts"|"weekend_shifts", max_deviation_percent: number}
RULES:
- Preference language ("doesn't like", "prefers", "mag keine", "bevorzugt") → hard: false, weight: 65
- Obligation language ("must not", "cannot", "never", "darf nicht") → hard: true
- Resolve employee names to their IDs from the list above. If ambiguous, use scope: {type: "global"}.
- Always include natural_language_summary in the same language as the input.
- Output ONLY a valid JSON array. No commentary.
OUTPUT FORMAT:
[
{
"type": "constraint_type",
"scope": {"type": "global"} | {"type": "employee", "employee_id": "..."} | {"type": "role", "role": "..."},
"params": {...},
"hard": true|false,
"weight": 1-100,
"natural_language_summary": "..."
}
]`
try {
const client = getAnthropicClient()
const response = await client.messages.create({
model: 'claude-opus-4-5',
max_tokens: 2048,
system: SYSTEM_PROMPT,
messages: [{ role: 'user', content: text }],
})
const content = response.content[0]
if (content.type !== 'text') throw new Error('Unexpected response type')
// Extract JSON from response
const jsonMatch = content.text.match(/\[[\s\S]*\]/)
if (!jsonMatch) throw new Error('No JSON array found in response')
const constraints: ConstraintJSON[] = JSON.parse(jsonMatch[0])
const ambiguities: string[] = []
// Validate basic structure
for (const c of constraints) {
if (!c.type || !c.scope || !c.params) {
ambiguities.push(`Unvollständige Bedingung erkannt: ${JSON.stringify(c)}`)
}
}
return { constraints, ambiguities }
} catch (err) {
console.error('Constraint parse error:', err)
throw createError({ statusCode: 500, message: `KI-Fehler: ${String(err)}` })
}
})

View File

@@ -0,0 +1,47 @@
import { getPBAdminClient } from '~/server/utils/pb-admin'
import { solveSchedule } from '~/server/utils/solver'
import type { SolveInput } from '~/shared/types/schedule'
export default defineEventHandler(async (event) => {
const body = await readBody(event)
const { run_id } = body
if (!run_id) throw createError({ statusCode: 400, message: 'run_id required' })
const pb = await getPBAdminClient()
// Fetch the schedule run
const run = await pb.collection('schedule_runs').getOne(run_id)
// Update status to solving
await pb.collection('schedule_runs').update(run_id, { status: 'solving' })
try {
const input: SolveInput = {
organization_id: run.org_id as string,
period_start: run.period_start as string,
period_end: run.period_end as string,
framework: run.framework_snapshot as SolveInput['framework'],
employees: run.employees_snapshot as SolveInput['employees'],
constraints: (run.constraints_snapshot as SolveInput['constraints']) || [],
}
const result = await solveSchedule(input)
await pb.collection('schedule_runs').update(run_id, {
status: result.status,
result: result.assignments,
objective_value: result.objective_value,
solver_duration_ms: result.duration_ms,
infeasibility_hints: result.infeasibility_hints || [],
})
return result
} catch (err) {
await pb.collection('schedule_runs').update(run_id, {
status: 'error',
infeasibility_hints: [{ description: String(err) }],
})
throw err
}
})

14
server/utils/anthropic.ts Normal file
View File

@@ -0,0 +1,14 @@
import Anthropic from '@anthropic-ai/sdk'
let _client: Anthropic | null = null
export function getAnthropicClient(): Anthropic {
const config = useRuntimeConfig()
if (!config.anthropicApiKey) {
throw new Error('ANTHROPIC_API_KEY is not configured')
}
if (!_client) {
_client = new Anthropic({ apiKey: config.anthropicApiKey as string })
}
return _client
}

24
server/utils/pb-admin.ts Normal file
View File

@@ -0,0 +1,24 @@
import PocketBase from 'pocketbase'
let _adminClient: PocketBase | null = null
let _tokenExpiry: number = 0
export async function getPBAdminClient(): Promise<PocketBase> {
const config = useRuntimeConfig()
const pbUrl = config.public.pocketbaseUrl as string
if (!_adminClient) {
_adminClient = new PocketBase(pbUrl)
}
// Re-authenticate if token expired (PB tokens last 7 days but refresh at 1h)
if (!_adminClient.authStore.isValid || Date.now() > _tokenExpiry) {
await _adminClient.collection('_superusers').authWithPassword(
config.pbAdminEmail as string,
config.pbAdminPassword as string
)
_tokenExpiry = Date.now() + 3600 * 1000 // 1 hour
}
return _adminClient
}

View File

@@ -0,0 +1,32 @@
import type { SolveInput } from '~/shared/types/schedule'
interface FeasibilityIssue {
message: string
blocking: boolean
}
export function checkFeasibility(input: SolveInput): FeasibilityIssue[] {
const issues: FeasibilityIssue[] = []
const { employees, framework } = input
if (employees.length === 0) {
issues.push({ message: 'Keine Mitarbeiter vorhanden', blocking: true })
return issues
}
if (framework.shifts.length === 0) {
issues.push({ message: 'Keine Schichten definiert', blocking: true })
return issues
}
// Check if enough employees for the highest shift requirement
const maxRequired = Math.max(...framework.shifts.map(s => s.workers_required))
if (employees.length < maxRequired) {
issues.push({
message: `Zu wenige Mitarbeiter: ${maxRequired} benötigt, nur ${employees.length} vorhanden`,
blocking: true
})
}
return issues
}

View File

@@ -0,0 +1,69 @@
import type { SolveInput, SolveResult } from '~/shared/types/schedule'
import { buildModel } from './modelBuilder'
import { parseAssignments } from './resultParser'
import { checkFeasibility } from './feasibilityCheck'
export async function solveSchedule(input: SolveInput): Promise<SolveResult> {
const startTime = Date.now()
// Pre-solve sanity check
const issues = checkFeasibility(input)
if (issues.length > 0 && issues.some(i => i.blocking)) {
return {
status: 'infeasible',
assignments: [],
infeasibility_hints: issues.map(i => ({ description: i.message }))
}
}
try {
// Dynamic import — HiGHS WASM is large
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const HiGHS = (await import('highs' as any)).default
const highs = await HiGHS()
const model = buildModel(input)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const result = highs.solve(model, {}) as Record<string, unknown>
const duration_ms = Date.now() - startTime
const status = result.Status as string
if (status === 'Infeasible' || status === 'Infeasible or Unbounded') {
return {
status: 'infeasible',
assignments: [],
duration_ms,
infeasibility_hints: [
{ description: 'Das Optimierungsproblem hat keine Lösung. Bitte überprüfen Sie Ihre Bedingungen, insbesondere ob genügend Mitarbeiter für alle Schichten verfügbar sind.' },
...issues.map(i => ({ description: i.message }))
]
}
}
if (status !== 'Optimal' && status !== 'Feasible') {
return {
status: 'error',
assignments: [],
duration_ms,
infeasibility_hints: [{ description: `Solver Status: ${status}` }]
}
}
const assignments = parseAssignments(result, input)
return {
status: 'solved',
assignments,
objective_value: result.ObjectiveValue as number,
duration_ms,
}
} catch (err) {
console.error('Solver error:', err)
return {
status: 'error',
assignments: [],
infeasibility_hints: [{ description: String(err) }]
}
}
}

View File

@@ -0,0 +1,341 @@
import type { SolveInput } from '~/shared/types/schedule'
import { getDaysInRange, getWeekday } from '~/app/utils/dateHelpers'
import type { ConstraintJSON } from '~/shared/types/constraint'
export function buildModel(input: SolveInput) {
const { employees, framework, period_start, period_end, constraints } = input
const days = getDaysInRange(period_start, period_end)
const E = employees.length
const S = framework.shifts.length
const D = days.length
// Variable naming: x_e_s_d = 1 if employee e works shift s on day d
const varNames: string[] = []
const varIndex: Record<string, number> = {}
for (let e = 0; e < E; e++) {
for (let s = 0; s < S; s++) {
for (let d = 0; d < D; d++) {
const name = `x_${e}_${s}_${d}`
varIndex[name] = varNames.length
varNames.push(name)
}
}
}
// Penalty variables for soft constraints
const penaltyVarStart = varNames.length
let penaltyVarCount = 0
const softConstraintPenalties: Array<{ varIdx: number; weight: number }> = []
function addPenaltyVar(weight: number): number {
const idx = penaltyVarStart + penaltyVarCount++
varNames.push(`penalty_${idx}`)
softConstraintPenalties.push({ varIdx: idx, weight })
return idx
}
// Objective: minimize sum of soft penalties
const objectiveCoeffs: Record<string, number> = {}
for (const p of softConstraintPenalties) {
objectiveCoeffs[varNames[p.varIdx]] = p.weight
}
const bounds: Record<string, [number, number]> = {}
const binaries = new Set<string>()
for (const name of varNames) {
bounds[name] = [0, 1]
if (name.startsWith('x_')) binaries.add(name)
}
const rows: Array<{
name: string
vars: Record<string, number>
lb: number
ub: number
}> = []
let rowIdx = 0
function addRow(vars: Record<string, number>, lb: number, ub: number) {
rows.push({ name: `r${rowIdx++}`, vars, lb, ub })
}
// Structural constraint 1: at most 1 shift per employee per day
for (let e = 0; e < E; e++) {
for (let d = 0; d < D; d++) {
const vars: Record<string, number> = {}
for (let s = 0; s < S; s++) {
vars[`x_${e}_${s}_${d}`] = 1
}
addRow(vars, 0, 1)
}
}
// Structural constraint 2: coverage requirements
for (let s = 0; s < S; s++) {
const shift = framework.shifts[s]
for (let d = 0; d < D; d++) {
const weekday = getWeekday(days[d])
const applicable = shift.days_applicable.length === 0 || shift.days_applicable.includes(weekday)
if (!applicable) {
// Force all x_e_s_d = 0 for this day
for (let e = 0; e < E; e++) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
continue
}
const coverageVars: Record<string, number> = {}
for (let e = 0; e < E; e++) {
coverageVars[`x_${e}_${s}_${d}`] = 1
}
addRow(coverageVars, shift.workers_required, E)
}
}
// Structural constraint 3: availability
for (let e = 0; e < E; e++) {
const emp = employees[e]
for (let d = 0; d < D; d++) {
const date = days[d]
// Block unavailable dates
if (emp.unavailable_dates.includes(date)) {
for (let s = 0; s < S; s++) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
continue
}
// Block periods not available
for (let s = 0; s < S; s++) {
const shift = framework.shifts[s]
const period = framework.periods.find(p => p.id === shift.period_id)
if (period && emp.available_periods.length > 0 && !emp.available_periods.includes(period.id)) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
}
}
}
// Process constraints from ConstraintJSON
for (const c of constraints) {
const cj = c.constraint_json as ConstraintJSON
const params = cj.params as Record<string, unknown>
// Determine affected employees
let affectedEmployees: number[] = Array.from({ length: E }, (_, i) => i)
if (cj.scope.type === 'employee') {
const empIdx = employees.findIndex(e => e.id === (cj.scope as { type: 'employee'; employee_id: string }).employee_id)
if (empIdx >= 0) affectedEmployees = [empIdx]
else continue
} else if (cj.scope.type === 'role') {
const role = (cj.scope as { type: 'role'; role: string }).role
affectedEmployees = employees
.map((e, i) => ({ e, i }))
.filter(({ e }) => e.roles.includes(role))
.map(({ i }) => i)
}
switch (cj.type) {
case 'max_consecutive_shifts': {
const max = params.max_count as number
for (const e of affectedEmployees) {
for (let d = 0; d <= D - (max + 1); d++) {
const vars: Record<string, number> = {}
for (let dd = d; dd <= d + max; dd++) {
for (let s = 0; s < S; s++) {
vars[`x_${e}_${s}_${dd}`] = 1
}
}
addRow(vars, 0, max)
}
}
break
}
case 'max_consecutive_shift_type': {
const max = params.max_count as number
const periodId = params.period_id as string
const shiftIndices = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === periodId)
.map(({ i }) => i)
if (shiftIndices.length === 0) break
for (const e of affectedEmployees) {
for (let d = 0; d <= D - (max + 1); d++) {
const vars: Record<string, number> = {}
for (let dd = d; dd <= d + max; dd++) {
for (const s of shiftIndices) {
vars[`x_${e}_${s}_${dd}`] = 1
}
}
addRow(vars, 0, max)
}
}
break
}
case 'forbidden_shift_sequence': {
const firstPeriod = params.first_period_id as string
const secondPeriod = params.second_period_id as string
const firstShifts = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === firstPeriod)
.map(({ i }) => i)
const secondShifts = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === secondPeriod)
.map(({ i }) => i)
for (const e of affectedEmployees) {
for (let d = 0; d < D - 1; d++) {
for (const s1 of firstShifts) {
for (const s2 of secondShifts) {
addRow({
[`x_${e}_${s1}_${d}`]: 1,
[`x_${e}_${s2}_${d + 1}`]: 1
}, 0, 1)
}
}
}
}
break
}
case 'employee_avoids_period': {
const periodId = params.period_id as string
const shiftIndices = framework.shifts
.map((sh, i) => ({ sh, i }))
.filter(({ sh }) => sh.period_id === periodId)
.map(({ i }) => i)
if (cj.hard) {
for (const e of affectedEmployees) {
for (let d = 0; d < D; d++) {
for (const s of shiftIndices) {
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
}
}
}
} else {
// Soft: penalty per assignment
const weight = (c.weight || 70) * 10
for (const e of affectedEmployees) {
for (let d = 0; d < D; d++) {
for (const s of shiftIndices) {
const pv = addPenaltyVar(weight)
objectiveCoeffs[varNames[pv]] = weight
bounds[varNames[pv]] = [0, 1]
addRow({
[varNames[pv]]: 1,
[`x_${e}_${s}_${d}`]: -1
}, 0, 1)
}
}
}
}
break
}
case 'max_weekend_shifts_per_month': {
const max = params.max_count as number
const weekendDays = days
.map((day, i) => ({ day, i }))
.filter(({ day }) => {
const wd = getWeekday(day)
return wd === 5 || wd === 6
})
.map(({ i }) => i)
for (const e of affectedEmployees) {
const vars: Record<string, number> = {}
for (const d of weekendDays) {
for (let s = 0; s < S; s++) {
vars[`x_${e}_${s}_${d}`] = 1
}
}
if (Object.keys(vars).length > 0) {
addRow(vars, 0, max)
}
}
break
}
case 'min_rest_between_shifts':
// Already handled implicitly by "max 1 shift per day"
break
default:
break
}
}
// Rebuild objective with finalized penalty vars
for (const p of softConstraintPenalties) {
objectiveCoeffs[varNames[p.varIdx]] = p.weight
}
// Initialize bounds for penalty vars
for (let i = penaltyVarStart; i < varNames.length; i++) {
if (!bounds[varNames[i]]) {
bounds[varNames[i]] = [0, 1]
}
}
return {
name: 'ShiftCraft',
sense: 'minimize' as const,
offset: 0,
col_lower: varNames.map(n => bounds[n]?.[0] ?? 0),
col_upper: varNames.map(n => bounds[n]?.[1] ?? 1),
col_cost: varNames.map(n => objectiveCoeffs[n] ?? 0),
col_names: varNames,
row_lower: rows.map(r => r.lb),
row_upper: rows.map(r => r.ub),
row_names: rows.map(r => r.name),
a_matrix: {
format: 'colwise' as const,
start: buildColwiseStart(varNames, rows),
index: buildColwiseIndex(varNames, rows),
value: buildColwiseValues(varNames, rows),
},
integrality: varNames.map(n => binaries.has(n) ? 1 : 0),
}
}
function buildColwiseStart(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
const starts: number[] = [0]
let count = 0
for (const name of varNames) {
for (const row of rows) {
if (name in row.vars) count++
}
starts.push(count)
}
return starts
}
function buildColwiseIndex(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
const indices: number[] = []
for (const name of varNames) {
rows.forEach((row, i) => {
if (name in row.vars) indices.push(i)
})
}
return indices
}
function buildColwiseValues(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
const values: number[] = []
for (const name of varNames) {
for (const row of rows) {
if (name in row.vars) values.push(row.vars[name])
}
}
return values
}

View File

@@ -0,0 +1,38 @@
import type { SolveInput, ShiftAssignment } from '~/shared/types/schedule'
import { getDaysInRange } from '~/app/utils/dateHelpers'
export function parseAssignments(result: Record<string, unknown>, input: SolveInput): ShiftAssignment[] {
const { employees, framework, period_start, period_end } = input
const days = getDaysInRange(period_start, period_end)
const E = employees.length
const S = framework.shifts.length
const D = days.length
const assignments: ShiftAssignment[] = []
const solution = result.Columns as Record<string, { Primal: number }>
for (let e = 0; e < E; e++) {
for (let s = 0; s < S; s++) {
for (let d = 0; d < D; d++) {
const varName = `x_${e}_${s}_${d}`
const value = solution[varName]?.Primal ?? 0
if (value > 0.5) {
const shift = framework.shifts[s]
const period = framework.periods.find(p => p.id === shift.period_id)
assignments.push({
employee_id: employees[e].id,
employee_name: employees[e].name,
shift_id: shift.id,
shift_name: shift.name,
period_id: shift.period_id,
date: days[d],
start_time: period?.start_time ?? '00:00',
end_time: period?.end_time ?? '00:00',
})
}
}
}
}
return assignments
}

View File

@@ -0,0 +1,35 @@
export type ConstraintType =
| 'max_hours_per_day'
| 'max_hours_per_week'
| 'min_rest_between_shifts'
| 'max_consecutive_shifts'
| 'max_consecutive_shift_type'
| 'min_consecutive_days_off'
| 'max_shifts_per_period_per_week'
| 'forbidden_shift_sequence'
| 'employee_unavailable'
| 'employee_prefers_period'
| 'employee_avoids_period'
| 'require_role_per_shift'
| 'max_weekend_shifts_per_month'
| 'fair_distribution'
export type ConstraintScope =
| { type: 'global' }
| { type: 'employee'; employee_id: string }
| { type: 'role'; role: string }
| { type: 'period'; period_id: string }
export interface ConstraintJSON {
type: ConstraintType
scope: ConstraintScope
params: Record<string, unknown>
hard: boolean
weight?: number
natural_language_summary: string
}
export interface ParsedConstraintResult {
constraints: ConstraintJSON[]
ambiguities: string[]
}

View File

@@ -0,0 +1,95 @@
export interface Organization {
id: string
name: string
slug: string
timezone: string
industry: string
owner: string
plan: 'free' | 'pro' | 'business'
plan_employee_limit: number
plan_history_months: number
stripe_customer_id: string
stripe_subscription_id: string
stripe_subscription_status: string
trial_ends_at: string
created: string
updated: string
}
export interface PBEmployee {
id: string
org_id: string
name: string
email: string
employee_number: string
roles: string[]
skills: string[]
employment_type: string
weekly_hours_target: number
max_weekly_hours: number
available_periods: string[]
unavailable_dates: string[]
notes: string
active: boolean
created: string
updated: string
}
export interface PBConstraint {
id: string
org_id: string
label: string
source_text: string
constraint_json: import('./constraint').ConstraintJSON
scope: string
scope_ref: string
category: string
hard: boolean
weight: number
active: boolean
source: string
template_id: string
created: string
updated: string
}
export interface PBScheduleRun {
id: string
org_id: string
name: string
period_start: string
period_end: string
framework_snapshot: unknown
constraints_snapshot: unknown
employees_snapshot: unknown
status: 'pending' | 'solving' | 'solved' | 'infeasible' | 'error'
solver_duration_ms: number
objective_value: number
infeasibility_hints: unknown
result: unknown
created_by: string
created: string
updated: string
}
export interface LegalTemplate {
id: string
region: string
law_name: string
label: string
description: string
constraint_json: import('./constraint').ConstraintJSON
category: string
mandatory: boolean
sort_order: number
}
export interface ShiftFramework {
id: string
org_id: string
periods: import('./schedule').Period[]
shifts: import('./schedule').Shift[]
scheduling_horizon_days: number
created: string
updated: string
}

59
shared/types/schedule.ts Normal file
View File

@@ -0,0 +1,59 @@
export interface ShiftAssignment {
employee_id: string
employee_name: string
shift_id: string
shift_name: string
period_id: string
date: string // ISO date
start_time: string
end_time: string
}
export interface SolveResult {
status: 'solved' | 'infeasible' | 'error'
assignments: ShiftAssignment[]
objective_value?: number
duration_ms?: number
infeasibility_hints?: Array<{ constraint_id?: string; description: string }>
}
export interface SolveInput {
organization_id: string
period_start: string
period_end: string
framework: {
periods: Period[]
shifts: Shift[]
}
employees: Employee[]
constraints: Array<{ id: string; constraint_json: import('./constraint').ConstraintJSON; hard: boolean; weight?: number }>
}
export interface Period {
id: string
name: string
start_time: string
end_time: string
color: string
}
export interface Shift {
id: string
period_id: string
name: string
duration_hours: number
workers_required: number
days_applicable: number[] // 0=Mon...6=Sun, empty = all days
}
export interface Employee {
id: string
name: string
roles: string[]
skills: string[]
employment_type: 'full_time' | 'part_time' | 'mini_job'
weekly_hours_target: number
max_weekly_hours: number
available_periods: string[]
unavailable_dates: string[]
}