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

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