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:
259
app/pages/schedules/[id]/index.vue
Normal file
259
app/pages/schedules/[id]/index.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<NuxtLink to="/schedules">
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
|
||||
</NuxtLink>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-gray-900 truncate">{{ run?.name ?? 'Schichtplan' }}</h1>
|
||||
<UBadge v-if="run" :color="statusColor(run.status)" variant="subtle">
|
||||
{{ statusLabel(run.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p v-if="run" class="text-gray-500 text-sm mt-1">
|
||||
{{ formatDate(run.period_start) }} – {{ formatDate(run.period_end) }}
|
||||
<span v-if="run.solver_duration_ms" class="ml-2 text-gray-400">
|
||||
· {{ (run.solver_duration_ms / 1000).toFixed(1) }}s Berechnungszeit
|
||||
</span>
|
||||
</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 Schichtplan...
|
||||
</div>
|
||||
|
||||
<!-- Infeasible notice -->
|
||||
<div v-else-if="run?.status === 'infeasible'" class="space-y-4">
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-2xl p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-alert-triangle" class="w-6 h-6 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h2 class="font-bold text-amber-900 mb-2">Kein gültiger Schichtplan gefunden</h2>
|
||||
<p class="text-amber-700 text-sm mb-4">
|
||||
Die aktiven Bedingungen sind möglicherweise zu restriktiv oder es gibt nicht genügend Mitarbeiter.
|
||||
</p>
|
||||
<div v-if="hints.length > 0">
|
||||
<p class="font-medium text-amber-800 text-sm mb-2">Hinweise:</p>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="hint in hints" :key="hint.description" class="text-amber-700 text-sm flex items-start gap-2">
|
||||
<UIcon name="i-lucide-chevron-right" class="w-4 h-4 mt-0.5 shrink-0" />
|
||||
{{ hint.description }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<NuxtLink to="/constraints">
|
||||
<UButton color="warning" variant="soft" size="sm" icon="i-lucide-sliders">
|
||||
Bedingungen prüfen
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/schedules/new">
|
||||
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-refresh-cw">
|
||||
Neu berechnen
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error notice -->
|
||||
<div v-else-if="run?.status === 'error'" class="bg-red-50 border border-red-200 rounded-2xl p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-x-circle" class="w-6 h-6 text-red-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h2 class="font-bold text-red-900 mb-1">Fehler bei der Berechnung</h2>
|
||||
<p class="text-red-600 text-sm">{{ hints[0]?.description ?? 'Unbekannter Fehler' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule table -->
|
||||
<div v-else-if="run?.status === 'solved' && assignments.length > 0">
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ employeeNames.length }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Mitarbeiter</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ assignments.length }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Schichten gesamt</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ periodDays }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Tage</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{ run.objective_value !== undefined ? run.objective_value.toFixed(1) : '—' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Obj. Wert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-100">
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600 whitespace-nowrap sticky left-0 bg-gray-50 z-10 min-w-32">
|
||||
Mitarbeiter
|
||||
</th>
|
||||
<th
|
||||
v-for="date in allDates"
|
||||
:key="date"
|
||||
class="px-2 py-3 text-center font-medium text-gray-600 whitespace-nowrap min-w-16"
|
||||
:class="isWeekend(date) ? 'bg-amber-50' : ''"
|
||||
>
|
||||
<div>{{ dayOfWeek(date) }}</div>
|
||||
<div class="text-xs text-gray-400">{{ shortDate(date) }}</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="emp in employeeNames" :key="emp" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-2.5 font-medium text-gray-900 whitespace-nowrap sticky left-0 bg-white z-10">
|
||||
{{ emp }}
|
||||
</td>
|
||||
<td
|
||||
v-for="date in allDates"
|
||||
:key="date"
|
||||
class="px-1 py-2 text-center"
|
||||
:class="isWeekend(date) ? 'bg-amber-50/50' : ''"
|
||||
>
|
||||
<div
|
||||
v-for="a in getAssignments(emp, date)"
|
||||
:key="a.shift_id"
|
||||
class="px-1.5 py-0.5 rounded-md text-xs font-medium whitespace-nowrap"
|
||||
:style="{ backgroundColor: getPeriodColor(a.period_id) + '20', color: getPeriodColor(a.period_id) }"
|
||||
>
|
||||
{{ a.shift_name }}
|
||||
</div>
|
||||
<span v-if="getAssignments(emp, date).length === 0" class="text-gray-300 text-xs">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div v-if="periods.length > 0" class="mt-4 flex flex-wrap gap-3">
|
||||
<div v-for="p in periods" :key="p.id" class="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: p.color }" />
|
||||
{{ p.name }} ({{ p.start_time }}–{{ p.end_time }})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatDate } from '~/app/utils/dateHelpers'
|
||||
import type { PBScheduleRun } from '~/shared/types/pocketbase'
|
||||
import type { ShiftAssignment, Period } from '~/shared/types/schedule'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const route = useRoute()
|
||||
const { pb } = usePocketBase()
|
||||
|
||||
const loading = ref(true)
|
||||
const run = ref<PBScheduleRun | null>(null)
|
||||
const assignments = ref<ShiftAssignment[]>([])
|
||||
const periods = ref<Period[]>([])
|
||||
|
||||
const hints = computed(() => {
|
||||
const h = run.value?.infeasibility_hints
|
||||
if (!h) return []
|
||||
if (Array.isArray(h)) return h as Array<{ description: string }>
|
||||
return []
|
||||
})
|
||||
|
||||
const employeeNames = computed(() => {
|
||||
const names = new Set(assignments.value.map(a => a.employee_name))
|
||||
return Array.from(names).sort()
|
||||
})
|
||||
|
||||
const allDates = computed(() => {
|
||||
if (!run.value) return []
|
||||
const dates: string[] = []
|
||||
const start = new Date(run.value.period_start)
|
||||
const end = new Date(run.value.period_end)
|
||||
const cur = new Date(start)
|
||||
while (cur <= end) {
|
||||
dates.push(cur.toISOString().slice(0, 10))
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
return dates
|
||||
})
|
||||
|
||||
const periodDays = computed(() => allDates.value.length)
|
||||
|
||||
function getAssignments(empName: string, date: string) {
|
||||
return assignments.value.filter(a => a.employee_name === empName && a.date === date)
|
||||
}
|
||||
|
||||
function getPeriodColor(periodId: string) {
|
||||
const p = periods.value.find(p => p.id === periodId)
|
||||
return p?.color || '#6366f1'
|
||||
}
|
||||
|
||||
function dayOfWeek(date: string) {
|
||||
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||||
return days[new Date(date + 'T12:00:00').getDay()]
|
||||
}
|
||||
|
||||
function shortDate(date: string) {
|
||||
const d = new Date(date + 'T12:00:00')
|
||||
return `${d.getDate()}.${d.getMonth() + 1}`
|
||||
}
|
||||
|
||||
function isWeekend(date: string) {
|
||||
const day = new Date(date + 'T12:00:00').getDay()
|
||||
return day === 0 || day === 6
|
||||
}
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = { solved: 'Gelöst', pending: 'Ausstehend', solving: 'Berechnet...', infeasible: 'Nicht lösbar', error: 'Fehler' }
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function statusColor(status: string): 'success' | 'warning' | 'error' | 'neutral' | 'primary' {
|
||||
const map: Record<string, 'success' | 'warning' | 'error' | 'neutral' | 'primary'> = {
|
||||
solved: 'success', pending: 'neutral', solving: 'primary', infeasible: 'warning', error: 'error',
|
||||
}
|
||||
return map[status] ?? 'neutral'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string
|
||||
try {
|
||||
const result = await pb.collection('schedule_runs').getOne(id)
|
||||
run.value = result as unknown as PBScheduleRun
|
||||
|
||||
if (result.result) {
|
||||
assignments.value = Array.isArray(result.result)
|
||||
? result.result as ShiftAssignment[]
|
||||
: []
|
||||
}
|
||||
|
||||
const snapshot = result.framework_snapshot as { periods?: Period[] }
|
||||
if (snapshot?.periods) {
|
||||
periods.value = snapshot.periods
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load schedule run', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
126
app/pages/schedules/index.vue
Normal file
126
app/pages/schedules/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Schichtpläne</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">Ihre optimierten Schichtplan-Berechnungen</p>
|
||||
</div>
|
||||
<NuxtLink to="/schedules/new">
|
||||
<UButton color="primary" icon="i-lucide-calendar-plus">
|
||||
Neuen Schichtplan erstellen
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</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 Schichtpläne...
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="runs.length === 0" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-12 text-center">
|
||||
<UIcon name="i-lucide-calendar" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="font-semibold text-gray-700 mb-1">Noch keine Schichtpläne</h3>
|
||||
<p class="text-gray-400 text-sm mb-4">Berechnen Sie Ihren ersten optimierten Schichtplan.</p>
|
||||
<NuxtLink to="/schedules/new">
|
||||
<UButton color="primary" icon="i-lucide-calendar-plus">Ersten Plan erstellen</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- Run list -->
|
||||
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
|
||||
<div
|
||||
v-for="run in runs"
|
||||
:key="run.id"
|
||||
class="px-6 py-4 flex items-center gap-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<!-- Status icon -->
|
||||
<div class="w-10 h-10 rounded-xl flex items-center justify-center shrink-0" :class="statusBg(run.status)">
|
||||
<UIcon
|
||||
:name="statusIcon(run.status)"
|
||||
class="w-5 h-5"
|
||||
:class="[statusIconColor(run.status), run.status === 'solving' ? 'animate-spin' : '']"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-900">{{ run.name }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">
|
||||
{{ formatDate(run.period_start) }} – {{ formatDate(run.period_end) }}
|
||||
<span v-if="run.solver_duration_ms" class="ml-2">· {{ (run.solver_duration_ms / 1000).toFixed(1) }}s</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Status badge -->
|
||||
<UBadge :color="statusColor(run.status)" variant="subtle" size="sm">
|
||||
{{ statusLabel(run.status) }}
|
||||
</UBadge>
|
||||
|
||||
<!-- Action -->
|
||||
<NuxtLink v-if="run.status === 'solved'" :to="`/schedules/${run.id}`">
|
||||
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-eye">
|
||||
Ansehen
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<span v-else class="w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatDate } from '~/app/utils/dateHelpers'
|
||||
import type { PBScheduleRun } from '~/shared/types/pocketbase'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
|
||||
const loading = ref(true)
|
||||
const runs = ref<PBScheduleRun[]>([])
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = { solved: 'Gelöst', pending: 'Ausstehend', solving: 'Berechnet...', infeasible: 'Nicht lösbar', error: 'Fehler' }
|
||||
return map[status] ?? status
|
||||
}
|
||||
function statusColor(status: string): 'success' | 'warning' | 'error' | 'neutral' | 'primary' {
|
||||
const map: Record<string, 'success' | 'warning' | 'error' | 'neutral' | 'primary'> = {
|
||||
solved: 'success', pending: 'neutral', solving: 'primary', infeasible: 'warning', error: 'error',
|
||||
}
|
||||
return map[status] ?? 'neutral'
|
||||
}
|
||||
function statusBg(status: string) {
|
||||
const map: Record<string, string> = { solved: 'bg-green-50', pending: 'bg-gray-50', solving: 'bg-blue-50', infeasible: 'bg-amber-50', error: 'bg-red-50' }
|
||||
return map[status] ?? 'bg-gray-50'
|
||||
}
|
||||
function statusIcon(status: string) {
|
||||
const map: Record<string, string> = { solved: 'i-lucide-check-circle', pending: 'i-lucide-clock', solving: 'i-lucide-loader-2', infeasible: 'i-lucide-alert-triangle', error: 'i-lucide-x-circle' }
|
||||
return map[status] ?? 'i-lucide-circle'
|
||||
}
|
||||
function statusIconColor(status: string) {
|
||||
const map: Record<string, string> = { solved: 'text-green-600', pending: 'text-gray-500', solving: 'text-blue-600', infeasible: 'text-amber-600', error: 'text-red-600' }
|
||||
return map[status] ?? 'text-gray-500'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
|
||||
if (!orgId) { loading.value = false; return }
|
||||
|
||||
try {
|
||||
const result = await pb.collection('schedule_runs').getFullList({
|
||||
filter: `org_id = "${orgId}"`,
|
||||
sort: '-created',
|
||||
})
|
||||
runs.value = result as unknown as PBScheduleRun[]
|
||||
} catch (err) {
|
||||
console.error('Failed to load schedule runs', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
241
app/pages/schedules/new.vue
Normal file
241
app/pages/schedules/new.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="max-w-xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<NuxtLink to="/schedules">
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Neuen Schichtplan erstellen</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">KI-gestützter Optimierer berechnet den besten Plan</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solving progress -->
|
||||
<div v-if="solvingRunId" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mx-auto mb-4">
|
||||
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-indigo-600 animate-spin" />
|
||||
</div>
|
||||
<h2 class="font-bold text-gray-900 mb-2">Berechnung läuft...</h2>
|
||||
<p class="text-gray-500 text-sm mb-4">Der Schichtplan-Optimierer arbeitet. Dies kann einige Sekunden dauern.</p>
|
||||
<UProgress v-model="solveProgress" :max="100" class="mb-4" />
|
||||
<p class="text-xs text-gray-400">Status: {{ solveStatusLabel }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Input form -->
|
||||
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div class="space-y-5">
|
||||
<UFormField label="Name des Schichtplans" required>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="z.B. Schichtplan Juni 2026"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Start-Datum" required>
|
||||
<UInput v-model="form.period_start" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="End-Datum" required>
|
||||
<UInput v-model="form.period_end" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Mitarbeiter</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="loadingStats" class="text-gray-400">...</span>
|
||||
<span v-else>{{ employeeCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Aktive Bedingungen</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="loadingStats" class="text-gray-400">...</span>
|
||||
<span v-else>{{ constraintCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Schichtrahmen</span>
|
||||
<span class="font-medium" :class="hasFramework ? 'text-green-600' : 'text-red-600'">
|
||||
<span v-if="loadingStats" class="text-gray-400">...</span>
|
||||
<span v-else>{{ hasFramework ? 'Konfiguriert' : 'Fehlt!' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="!loadingStats && !hasFramework" class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-alert-triangle" class="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<p class="text-amber-700">
|
||||
Kein Schichtrahmen konfiguriert.
|
||||
<NuxtLink to="/settings/shifts" class="font-medium underline">Schichten einrichten →</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loadingStats && employeeCount === 0" class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-alert-triangle" class="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<p class="text-amber-700">
|
||||
Keine Mitarbeiter gefunden.
|
||||
<NuxtLink to="/employees" class="font-medium underline">Mitarbeiter hinzufügen →</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-lucide-calculator"
|
||||
class="w-full justify-center"
|
||||
size="lg"
|
||||
:loading="creating"
|
||||
:disabled="!canCreate"
|
||||
@click="createAndSolve"
|
||||
>
|
||||
Schichtplan berechnen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ShiftFramework } from '~/shared/types/pocketbase'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
|
||||
const creating = ref(false)
|
||||
const loadingStats = ref(true)
|
||||
const employeeCount = ref(0)
|
||||
const constraintCount = ref(0)
|
||||
const hasFramework = ref(false)
|
||||
const solvingRunId = ref<string | null>(null)
|
||||
const solveProgress = ref(10)
|
||||
const solveStatus = ref('pending')
|
||||
|
||||
const today = new Date()
|
||||
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1)
|
||||
const lastDayNextMonth = new Date(today.getFullYear(), today.getMonth() + 2, 0)
|
||||
|
||||
const form = reactive({
|
||||
name: `Schichtplan ${nextMonth.toLocaleString('de', { month: 'long', year: 'numeric' })}`,
|
||||
period_start: nextMonth.toISOString().slice(0, 10),
|
||||
period_end: lastDayNextMonth.toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
const canCreate = computed(() =>
|
||||
!!form.name && !!form.period_start && !!form.period_end && !loadingStats.value && employeeCount.value > 0 && hasFramework.value
|
||||
)
|
||||
|
||||
const solveStatusLabel = computed(() => {
|
||||
const map: Record<string, string> = { pending: 'Wird vorbereitet...', solving: 'Löser läuft...', solved: 'Abgeschlossen', infeasible: 'Keine Lösung gefunden', error: 'Fehler' }
|
||||
return map[solveStatus.value] ?? solveStatus.value
|
||||
})
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
|
||||
if (!orgId) { loadingStats.value = false; return }
|
||||
|
||||
try {
|
||||
const [employees, constraints, frameworks] = await Promise.all([
|
||||
pb.collection('employees').getList(1, 1, { filter: `org_id = "${orgId}" && active = true` }),
|
||||
pb.collection('constraints').getList(1, 1, { filter: `org_id = "${orgId}" && active = true` }),
|
||||
pb.collection('shift_frameworks').getList(1, 1, { filter: `org_id = "${orgId}"` }),
|
||||
])
|
||||
employeeCount.value = employees.totalItems
|
||||
constraintCount.value = constraints.totalItems
|
||||
hasFramework.value = frameworks.totalItems > 0
|
||||
} catch (err) {
|
||||
console.error('Stats load error', err)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
async function createAndSolve() {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (!orgId) return
|
||||
creating.value = true
|
||||
|
||||
try {
|
||||
// Fetch snapshot data
|
||||
const [employees, constraints, frameworkResult] = await Promise.all([
|
||||
pb.collection('employees').getFullList({ filter: `org_id = "${orgId}" && active = true` }),
|
||||
pb.collection('constraints').getFullList({ filter: `org_id = "${orgId}" && active = true` }),
|
||||
pb.collection('shift_frameworks').getFirstListItem(`org_id = "${orgId}"`),
|
||||
])
|
||||
|
||||
const framework = frameworkResult as unknown as ShiftFramework
|
||||
|
||||
// Create schedule run record
|
||||
const run = await pb.collection('schedule_runs').create({
|
||||
org_id: orgId,
|
||||
name: form.name,
|
||||
period_start: form.period_start,
|
||||
period_end: form.period_end,
|
||||
status: 'pending',
|
||||
framework_snapshot: { periods: framework.periods, shifts: framework.shifts },
|
||||
employees_snapshot: employees,
|
||||
constraints_snapshot: constraints.map((c: Record<string, unknown>) => ({
|
||||
id: c.id,
|
||||
constraint_json: c.constraint_json,
|
||||
hard: c.hard,
|
||||
weight: c.weight,
|
||||
})),
|
||||
created_by: authStore.value.record?.id,
|
||||
})
|
||||
|
||||
solvingRunId.value = run.id
|
||||
solveProgress.value = 20
|
||||
|
||||
// Call solve API
|
||||
$fetch('/api/schedules/solve', {
|
||||
method: 'POST',
|
||||
body: { run_id: run.id },
|
||||
}).catch((err) => {
|
||||
console.error('Solve error', err)
|
||||
})
|
||||
|
||||
// Poll status
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const updated = await pb.collection('schedule_runs').getOne(run.id)
|
||||
solveStatus.value = updated.status as string
|
||||
if (solveProgress.value < 80) solveProgress.value += 10
|
||||
|
||||
if (updated.status === 'solved') {
|
||||
solveProgress.value = 100
|
||||
clearInterval(pollInterval!)
|
||||
await navigateTo(`/schedules/${run.id}`)
|
||||
} else if (updated.status === 'infeasible' || updated.status === 'error') {
|
||||
clearInterval(pollInterval!)
|
||||
toast.add({ color: 'warning', title: 'Berechnung abgeschlossen', description: updated.status === 'infeasible' ? 'Kein gültiger Plan gefunden.' : 'Fehler bei der Berechnung.' })
|
||||
await navigateTo(`/schedules/${run.id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Poll error', e)
|
||||
}
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler beim Erstellen', description: String(err) })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user