Files
shiftcraft/app/pages/schedules/[id]/index.vue
Clanker 36e0946ee4 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>
2026-04-18 07:47:31 +02:00

260 lines
9.9 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>