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>
127 lines
4.9 KiB
Vue
127 lines
4.9 KiB
Vue
<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>
|