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>
241 lines
8.1 KiB
Vue
241 lines
8.1 KiB
Vue
<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>
|