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:
240
app/pages/employees/index.vue
Normal file
240
app/pages/employees/index.vue
Normal 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>
|
||||
Reference in New Issue
Block a user