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>
210 lines
7.2 KiB
Vue
210 lines
7.2 KiB
Vue
<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>
|