Files
shiftcraft/app/pages/dashboard/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

219 lines
8.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-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>