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,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