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:
218
app/pages/dashboard/index.vue
Normal file
218
app/pages/dashboard/index.vue
Normal file
@@ -0,0 +1,218 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user