feat: complete ShiftCraft — AI-powered shift scheduling SaaS

Complete implementation including:
- Landing page with hero, features, how-it-works, pricing
- Employee management (CRUD with soft delete)
- AI constraint parser (Anthropic Claude API)
- German labor law templates (ArbZG §3, §5, §9)
- HiGHS ILP solver for optimal fair schedules
- Schedule calendar result view (employee × date grid)
- Shift framework configuration (periods + shifts)
- Subscription tiers: Free / Pro / Business
- PocketBase setup script with collection creation + seed data
- .env.example with all required variables documented

Pages: employees, constraints (list/new/templates), schedules (list/new/[id]),
       settings (organization/shifts/billing), dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 07:47:31 +02:00
parent 2ea4ca5d52
commit 36e0946ee4
38 changed files with 4254 additions and 133 deletions

View File

@@ -0,0 +1,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>