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>
182 lines
6.3 KiB
Vue
182 lines
6.3 KiB
Vue
<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>
|