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:
181
app/pages/constraints/index.vue
Normal file
181
app/pages/constraints/index.vue
Normal 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>
|
||||
209
app/pages/constraints/new.vue
Normal file
209
app/pages/constraints/new.vue
Normal 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>
|
||||
270
app/pages/constraints/templates.vue
Normal file
270
app/pages/constraints/templates.vue
Normal 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>
|
||||
Reference in New Issue
Block a user