Files
shiftcraft/app/pages/settings/shifts.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

281 lines
9.5 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 class="max-w-3xl">
<!-- Header -->
<div class="mb-8">
<h1 class="text-2xl font-bold text-gray-900">Schichtrahmen</h1>
<p class="text-gray-500 mt-1 text-sm">Definieren Sie die Schichten und Zeiträume für Ihre Organisation</p>
</div>
<!-- Settings nav -->
<div class="flex gap-1 mb-6 border-b border-gray-100">
<NuxtLink
v-for="tab in settingsTabs"
:key="tab.to"
:to="tab.to"
class="px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px"
:class="$route.path === tab.to
? 'border-indigo-600 text-indigo-700'
: 'border-transparent text-gray-500 hover:text-gray-700'"
>
{{ tab.label }}
</NuxtLink>
</div>
<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 Schichtrahmen...
</div>
<div v-else class="space-y-6">
<!-- Periods section -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-gray-900">Schichtzeiträume</h2>
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-plus" @click="addPeriod">
Zeitraum hinzufügen
</UButton>
</div>
<div class="space-y-3">
<div
v-for="(period, idx) in periods"
:key="period.id"
class="flex items-center gap-3 p-3 bg-gray-50 rounded-xl"
>
<input
type="color"
v-model="period.color"
class="w-8 h-8 rounded-lg cursor-pointer border border-gray-200 p-0.5"
/>
<UInput v-model="period.name" placeholder="Name" size="sm" class="w-32" />
<UInput v-model="period.start_time" type="time" size="sm" class="w-28" />
<span class="text-gray-400 text-sm"></span>
<UInput v-model="period.end_time" type="time" size="sm" class="w-28" />
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
class="ml-auto"
@click="periods.splice(idx, 1)"
/>
</div>
<div v-if="periods.length === 0" class="text-center py-6 text-gray-400 text-sm">
Noch keine Schichtzeiträume. Fügen Sie z.B. Früh-, Spät- und Nachtschicht hinzu.
</div>
</div>
</div>
<!-- Shifts section -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<h2 class="font-bold text-gray-900">Schichten</h2>
<UButton
color="neutral"
variant="outline"
size="sm"
icon="i-lucide-plus"
:disabled="periods.length === 0"
@click="addShift"
>
Schicht hinzufügen
</UButton>
</div>
<div class="space-y-3">
<div
v-for="(shift, idx) in shifts"
:key="shift.id"
class="p-4 bg-gray-50 rounded-xl"
>
<div class="flex items-center gap-3 mb-3">
<UInput v-model="shift.name" placeholder="Schichtname" size="sm" class="flex-1" />
<USelect
v-model="shift.period_id"
:options="periodOptions"
size="sm"
class="w-36"
/>
<UButton
icon="i-lucide-trash-2"
color="error"
variant="ghost"
size="xs"
@click="shifts.splice(idx, 1)"
/>
</div>
<div class="grid grid-cols-3 gap-3">
<UFormField label="Stunden/Schicht" size="sm">
<UInput v-model.number="shift.duration_hours" type="number" min="1" max="24" step="0.5" size="sm" class="w-full" />
</UFormField>
<UFormField label="Mitarbeiter benötigt" size="sm">
<UInput v-model.number="shift.workers_required" type="number" min="1" max="100" size="sm" class="w-full" />
</UFormField>
<UFormField label="Wochentage" size="sm">
<p class="text-xs text-gray-500 mt-1">(leer = alle Tage)</p>
<div class="flex gap-1 mt-1">
<button
v-for="(day, d) in ['Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So']"
:key="d"
class="px-1.5 py-0.5 rounded text-xs font-medium transition-colors"
:class="shift.days_applicable.includes(d)
? 'bg-indigo-600 text-white'
: 'bg-gray-200 text-gray-600 hover:bg-gray-300'"
@click="toggleDay(shift, d)"
>
{{ day }}
</button>
</div>
</UFormField>
</div>
</div>
<div v-if="shifts.length === 0" class="text-center py-6 text-gray-400 text-sm">
Noch keine Schichten. Erstellen Sie zuerst einen Schichtzeitraum.
</div>
</div>
</div>
<!-- Horizon -->
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
<h2 class="font-bold text-gray-900 mb-4">Planungshorizont</h2>
<UFormField label="Maximale Planungsperiode (Tage)">
<UInput v-model.number="schedulingHorizonDays" type="number" min="7" max="365" class="w-32" />
</UFormField>
</div>
<div class="flex gap-3">
<UButton color="primary" :loading="saving" @click="save">
Schichtrahmen speichern
</UButton>
<UButton color="neutral" variant="ghost" @click="loadFramework">
Zurücksetzen
</UButton>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { ShiftFramework } from '~/shared/types/pocketbase'
import type { Period, Shift } from '~/shared/types/schedule'
definePageMeta({ layout: 'default', middleware: 'auth' })
const toast = useToast()
const { pb, authStore } = usePocketBase()
const orgStore = useOrg()
const loading = ref(true)
const saving = ref(false)
const frameworkId = ref<string | null>(null)
const periods = ref<Period[]>([])
const shifts = ref<Shift[]>([])
const schedulingHorizonDays = ref(28)
const settingsTabs = [
{ to: '/settings/organization', label: 'Organisation' },
{ to: '/settings/shifts', label: 'Schichten' },
{ to: '/settings/billing', label: 'Abonnement' },
]
const periodOptions = computed(() =>
periods.value.map(p => ({ label: p.name, value: p.id }))
)
function generateId() {
return Math.random().toString(36).slice(2, 10)
}
function addPeriod() {
const colors = ['#6366f1', '#f59e0b', '#1e293b', '#10b981']
periods.value.push({
id: generateId(),
name: '',
start_time: '06:00',
end_time: '14:00',
color: colors[periods.value.length % colors.length],
})
}
function addShift() {
if (periods.value.length === 0) return
shifts.value.push({
id: generateId(),
period_id: periods.value[0].id,
name: '',
duration_hours: 8,
workers_required: 2,
days_applicable: [],
})
}
function toggleDay(shift: Shift, day: number) {
const idx = shift.days_applicable.indexOf(day)
if (idx === -1) {
shift.days_applicable.push(day)
} else {
shift.days_applicable.splice(idx, 1)
}
}
async function loadFramework() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
try {
const result = await pb.collection('shift_frameworks').getFirstListItem(`org_id = "${orgId}"`)
const fw = result as unknown as ShiftFramework
frameworkId.value = fw.id
periods.value = fw.periods ? JSON.parse(JSON.stringify(fw.periods)) : []
shifts.value = fw.shifts ? JSON.parse(JSON.stringify(fw.shifts)) : []
schedulingHorizonDays.value = fw.scheduling_horizon_days || 28
} catch {
// No framework yet — start fresh
frameworkId.value = null
periods.value = [
{ id: generateId(), name: 'Frühschicht', start_time: '06:00', end_time: '14:00', color: '#6366f1' },
{ id: generateId(), name: 'Spätschicht', start_time: '14:00', end_time: '22:00', color: '#f59e0b' },
{ id: generateId(), name: 'Nachtschicht', start_time: '22:00', end_time: '06:00', color: '#1e293b' },
]
shifts.value = []
}
}
async function save() {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (!orgId) return
saving.value = true
try {
const data = {
org_id: orgId,
periods: periods.value,
shifts: shifts.value,
scheduling_horizon_days: schedulingHorizonDays.value,
}
if (frameworkId.value) {
await pb.collection('shift_frameworks').update(frameworkId.value, data)
} else {
const created = await pb.collection('shift_frameworks').create(data)
frameworkId.value = created.id
}
toast.add({ color: 'success', title: 'Schichtrahmen gespeichert' })
} catch (err) {
toast.add({ color: 'error', title: 'Fehler beim Speichern', description: String(err) })
} finally {
saving.value = false
}
}
onMounted(async () => {
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
await loadFramework()
loading.value = false
})
</script>