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>
271 lines
9.0 KiB
Vue
271 lines
9.0 KiB
Vue
<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>
|