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>
219 lines
8.9 KiB
Vue
219 lines
8.9 KiB
Vue
<template>
|
||
<div>
|
||
<!-- Header -->
|
||
<div class="mb-8">
|
||
<h1 class="text-2xl font-bold text-gray-900">
|
||
Guten {{ greeting }}, {{ firstName }}! 👋
|
||
</h1>
|
||
<p class="text-gray-500 mt-1">Hier ist ein Überblick über Ihr ShiftCraft-Konto</p>
|
||
</div>
|
||
|
||
<!-- Stats grid -->
|
||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||
<div v-for="stat in stats" :key="stat.label" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-5">
|
||
<div class="flex items-center justify-between mb-3">
|
||
<p class="text-sm text-gray-500 font-medium">{{ stat.label }}</p>
|
||
<div class="w-9 h-9 rounded-xl flex items-center justify-center" :class="stat.iconBg">
|
||
<UIcon :name="stat.icon" class="w-5 h-5" :class="stat.iconColor" />
|
||
</div>
|
||
</div>
|
||
<p class="text-3xl font-bold text-gray-900">{{ stat.loading ? '...' : stat.value }}</p>
|
||
<p v-if="stat.sub" class="text-xs text-gray-400 mt-1">{{ stat.sub }}</p>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Quick actions -->
|
||
<div class="grid md:grid-cols-3 gap-4 mb-8">
|
||
<div class="bg-gradient-to-br from-indigo-600 to-violet-600 rounded-2xl p-6 text-white">
|
||
<div class="w-10 h-10 rounded-xl bg-white/20 flex items-center justify-center mb-4">
|
||
<UIcon name="i-lucide-calendar-plus" class="w-5 h-5" />
|
||
</div>
|
||
<h3 class="font-bold text-lg mb-2">Neuer Schichtplan</h3>
|
||
<p class="text-indigo-100 text-sm mb-4">Erstellen Sie jetzt einen optimierten Schichtplan</p>
|
||
<NuxtLink to="/schedules/new">
|
||
<UButton color="white" variant="solid" size="sm">
|
||
Plan erstellen
|
||
</UButton>
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||
<div class="w-10 h-10 rounded-xl bg-emerald-50 flex items-center justify-center mb-4">
|
||
<UIcon name="i-lucide-user-plus" class="w-5 h-5 text-emerald-600" />
|
||
</div>
|
||
<h3 class="font-bold text-gray-900 text-lg mb-2">Mitarbeiter hinzufügen</h3>
|
||
<p class="text-gray-500 text-sm mb-4">Verwalten Sie Ihr Team und deren Verfügbarkeiten</p>
|
||
<NuxtLink to="/employees">
|
||
<UButton color="neutral" variant="outline" size="sm">
|
||
Zu Mitarbeitern
|
||
</UButton>
|
||
</NuxtLink>
|
||
</div>
|
||
|
||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||
<div class="w-10 h-10 rounded-xl bg-violet-50 flex items-center justify-center mb-4">
|
||
<UIcon name="i-lucide-sliders" class="w-5 h-5 text-violet-600" />
|
||
</div>
|
||
<h3 class="font-bold text-gray-900 text-lg mb-2">Bedingungen eingeben</h3>
|
||
<p class="text-gray-500 text-sm mb-4">Fügen Sie Regeln und Präferenzen in Textform hinzu</p>
|
||
<NuxtLink to="/constraints/new">
|
||
<UButton color="neutral" variant="outline" size="sm">
|
||
Bedingung hinzufügen
|
||
</UButton>
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Recent schedule runs -->
|
||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm">
|
||
<div class="px-6 py-4 border-b border-gray-100 flex items-center justify-between">
|
||
<h2 class="font-bold text-gray-900">Letzte Schichtpläne</h2>
|
||
<NuxtLink to="/schedules" class="text-sm text-indigo-600 hover:text-indigo-700 font-medium">Alle ansehen →</NuxtLink>
|
||
</div>
|
||
<div v-if="loadingRuns" class="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>
|
||
<div v-else-if="recentRuns.length === 0" class="p-8 text-center">
|
||
<UIcon name="i-lucide-calendar" class="w-10 h-10 text-gray-300 mx-auto mb-3" />
|
||
<p class="text-gray-500 font-medium">Noch keine Schichtpläne</p>
|
||
<p class="text-gray-400 text-sm mt-1">Erstellen Sie Ihren ersten Schichtplan</p>
|
||
<NuxtLink to="/schedules/new">
|
||
<UButton color="primary" size="sm" class="mt-4">Plan erstellen</UButton>
|
||
</NuxtLink>
|
||
</div>
|
||
<div v-else class="divide-y divide-gray-50">
|
||
<div v-for="run in recentRuns" :key="run.id" class="px-6 py-4 flex items-center justify-between hover:bg-gray-50 transition-colors">
|
||
<div class="flex items-center gap-3">
|
||
<div class="w-9 h-9 rounded-xl flex items-center justify-center" :class="statusBg(run.status)">
|
||
<UIcon :name="statusIcon(run.status)" class="w-4 h-4" :class="statusIconColor(run.status)" />
|
||
</div>
|
||
<div>
|
||
<p class="font-medium text-gray-900 text-sm">{{ run.name }}</p>
|
||
<p class="text-xs text-gray-400">{{ formatDate(run.period_start) }} – {{ formatDate(run.period_end) }}</p>
|
||
</div>
|
||
</div>
|
||
<div class="flex items-center gap-3">
|
||
<UBadge :color="statusColor(run.status)" variant="subtle" size="sm">{{ statusLabel(run.status) }}</UBadge>
|
||
<NuxtLink v-if="run.status === 'solved'" :to="`/schedules/${run.id}`">
|
||
<UButton color="neutral" variant="ghost" size="xs" icon="i-lucide-arrow-right" />
|
||
</NuxtLink>
|
||
</div>
|
||
</div>
|
||
</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 loadingRuns = ref(true)
|
||
const loadingStats = ref(true)
|
||
const recentRuns = ref<PBScheduleRun[]>([])
|
||
const employeeCount = ref(0)
|
||
const constraintCount = ref(0)
|
||
|
||
const now = new Date()
|
||
const hour = now.getHours()
|
||
const greeting = computed(() => {
|
||
if (hour < 12) return 'Morgen'
|
||
if (hour < 17) return 'Tag'
|
||
return 'Abend'
|
||
})
|
||
|
||
const firstName = computed(() => {
|
||
const name = authStore.value.record?.name as string || 'da'
|
||
return name.split(' ')[0]
|
||
})
|
||
|
||
const stats = computed(() => [
|
||
{
|
||
label: 'Mitarbeiter',
|
||
value: employeeCount.value,
|
||
icon: 'i-lucide-users',
|
||
iconBg: 'bg-indigo-50',
|
||
iconColor: 'text-indigo-600',
|
||
loading: loadingStats.value,
|
||
sub: `von ${orgStore.org?.plan_employee_limit ?? 5} erlaubt`,
|
||
},
|
||
{
|
||
label: 'Aktive Bedingungen',
|
||
value: constraintCount.value,
|
||
icon: 'i-lucide-sliders',
|
||
iconBg: 'bg-violet-50',
|
||
iconColor: 'text-violet-600',
|
||
loading: loadingStats.value,
|
||
sub: null,
|
||
},
|
||
{
|
||
label: 'Schichtpläne gesamt',
|
||
value: recentRuns.value.length,
|
||
icon: 'i-lucide-calendar',
|
||
iconBg: 'bg-emerald-50',
|
||
iconColor: 'text-emerald-600',
|
||
loading: loadingRuns.value,
|
||
sub: null,
|
||
},
|
||
{
|
||
label: 'Plan',
|
||
value: (orgStore.org?.plan ?? 'free').toUpperCase(),
|
||
icon: 'i-lucide-zap',
|
||
iconBg: 'bg-amber-50',
|
||
iconColor: 'text-amber-600',
|
||
loading: false,
|
||
sub: null,
|
||
},
|
||
])
|
||
|
||
function statusLabel(status: string) {
|
||
const map: Record<string, string> = { solved: 'Gelöst', pending: 'Ausstehend', solving: 'Läuft...', infeasible: 'Unlösbar', error: 'Fehler' }
|
||
return map[status] ?? status
|
||
}
|
||
function statusColor(status: string) {
|
||
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) return
|
||
|
||
if (!orgStore.org) await orgStore.fetchOrg(orgId)
|
||
|
||
try {
|
||
const [employees, constraints, runs] = 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('schedule_runs').getList(1, 5, { filter: `org_id = "${orgId}"`, sort: '-created' }),
|
||
])
|
||
employeeCount.value = employees.totalItems
|
||
constraintCount.value = constraints.totalItems
|
||
recentRuns.value = runs.items as unknown as PBScheduleRun[]
|
||
} catch (err) {
|
||
console.error('Dashboard load error', err)
|
||
} finally {
|
||
loadingStats.value = false
|
||
loadingRuns.value = false
|
||
}
|
||
})
|
||
</script>
|