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:
181
app/pages/constraints/index.vue
Normal file
181
app/pages/constraints/index.vue
Normal file
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Bedingungen</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">Regeln und Präferenzen für Ihre Schichtplanung</p>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<NuxtLink to="/constraints/templates">
|
||||
<UButton color="neutral" variant="outline" icon="i-lucide-book-open">
|
||||
Gesetzliche Vorlagen
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/constraints/new">
|
||||
<UButton color="primary" icon="i-lucide-plus">
|
||||
Neue Bedingung
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</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 Bedingungen...
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-else-if="constraints.length === 0" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-12 text-center">
|
||||
<UIcon name="i-lucide-sliders" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="font-semibold text-gray-700 mb-1">Noch keine Bedingungen</h3>
|
||||
<p class="text-gray-400 text-sm mb-4">Fügen Sie Regeln hinzu oder importieren Sie gesetzliche Vorlagen.</p>
|
||||
<div class="flex justify-center gap-3">
|
||||
<NuxtLink to="/constraints/templates">
|
||||
<UButton color="neutral" variant="outline" icon="i-lucide-book-open" size="sm">Vorlagen</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/constraints/new">
|
||||
<UButton color="primary" icon="i-lucide-plus" size="sm">Neue Bedingung</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Grouped constraints -->
|
||||
<div v-else class="space-y-6">
|
||||
<div v-for="(group, category) in grouped" :key="category">
|
||||
<h2 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">
|
||||
{{ categoryLabel(category as string) }}
|
||||
</h2>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
|
||||
<div
|
||||
v-for="c in group"
|
||||
:key="c.id"
|
||||
class="px-6 py-4 flex items-start gap-4"
|
||||
:class="!c.active ? 'opacity-50' : ''"
|
||||
>
|
||||
<!-- Hard/Soft indicator -->
|
||||
<div class="mt-0.5 shrink-0">
|
||||
<UBadge :color="c.hard ? 'error' : 'warning'" variant="subtle" size="sm">
|
||||
{{ c.hard ? 'Pflicht' : 'Wunsch' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<!-- Content -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-900 text-sm">{{ c.label }}</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">
|
||||
{{ c.constraint_json?.natural_language_summary || c.label }}
|
||||
</p>
|
||||
<div class="flex items-center gap-2 mt-1">
|
||||
<span v-if="c.source === 'legal'" class="text-xs text-blue-600 font-medium">
|
||||
<UIcon name="i-lucide-landmark" class="w-3 h-3 inline" /> Gesetzlich
|
||||
</span>
|
||||
<span v-else-if="c.source === 'ai'" class="text-xs text-violet-600 font-medium">
|
||||
<UIcon name="i-lucide-sparkles" class="w-3 h-3 inline" /> KI-erkannt
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-2 shrink-0">
|
||||
<UToggle
|
||||
:model-value="c.active"
|
||||
@update:model-value="toggleConstraint(c.id, $event)"
|
||||
size="sm"
|
||||
/>
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:loading="deletingId === c.id"
|
||||
@click="deleteConstraint(c.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PBConstraint } from '~/shared/types/pocketbase'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
|
||||
const loading = ref(true)
|
||||
const deletingId = ref<string | null>(null)
|
||||
const constraints = ref<PBConstraint[]>([])
|
||||
|
||||
const grouped = computed(() => {
|
||||
const groups: Record<string, PBConstraint[]> = {}
|
||||
for (const c of constraints.value) {
|
||||
const cat = c.category || 'other'
|
||||
if (!groups[cat]) groups[cat] = []
|
||||
groups[cat].push(c)
|
||||
}
|
||||
return groups
|
||||
})
|
||||
|
||||
function categoryLabel(cat: string) {
|
||||
const map: Record<string, string> = {
|
||||
legal: 'Gesetzliche Vorgaben',
|
||||
preference: 'Präferenzen',
|
||||
operational: 'Betriebliche Regeln',
|
||||
other: 'Sonstiges',
|
||||
}
|
||||
return map[cat] ?? cat
|
||||
}
|
||||
|
||||
async function loadConstraints() {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (!orgId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await pb.collection('constraints').getFullList({
|
||||
filter: `org_id = "${orgId}"`,
|
||||
sort: 'category,label',
|
||||
})
|
||||
constraints.value = result as unknown as PBConstraint[]
|
||||
} catch (err) {
|
||||
console.error('Failed to load constraints', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleConstraint(id: string, active: boolean) {
|
||||
try {
|
||||
await pb.collection('constraints').update(id, { active })
|
||||
const idx = constraints.value.findIndex(c => c.id === id)
|
||||
if (idx !== -1) constraints.value[idx].active = active
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConstraint(id: string) {
|
||||
deletingId.value = id
|
||||
try {
|
||||
await pb.collection('constraints').delete(id)
|
||||
constraints.value = constraints.value.filter(c => c.id !== id)
|
||||
toast.add({ color: 'success', title: 'Bedingung gelöscht' })
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
|
||||
await loadConstraints()
|
||||
})
|
||||
</script>
|
||||
209
app/pages/constraints/new.vue
Normal file
209
app/pages/constraints/new.vue
Normal file
@@ -0,0 +1,209 @@
|
||||
<template>
|
||||
<div class="max-w-2xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<NuxtLink to="/constraints">
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
|
||||
</NuxtLink>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Neue Bedingung</h1>
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm ml-10">
|
||||
Beschreiben Sie Ihre Regel in eigenen Worten — die KI erkennt die Bedingung automatisch.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Input card -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6">
|
||||
<UFormField label="Bedingung beschreiben" class="mb-4">
|
||||
<UTextarea
|
||||
v-model="inputText"
|
||||
:rows="4"
|
||||
placeholder="Beschreiben Sie Ihre Bedingung in eigenen Worten..."
|
||||
class="w-full"
|
||||
:disabled="parsing"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<!-- Example hints -->
|
||||
<div class="mb-4">
|
||||
<p class="text-xs text-gray-400 mb-2 font-medium">Beispiele:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="hint in exampleHints"
|
||||
:key="hint"
|
||||
class="px-3 py-1 rounded-full bg-gray-50 border border-gray-200 text-xs text-gray-600 hover:bg-indigo-50 hover:border-indigo-200 hover:text-indigo-700 transition-colors"
|
||||
@click="inputText = hint"
|
||||
>
|
||||
{{ hint }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-lucide-sparkles"
|
||||
:loading="parsing"
|
||||
:disabled="!inputText.trim()"
|
||||
@click="parseConstraint"
|
||||
>
|
||||
Bedingung erkennen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Error state -->
|
||||
<div v-if="parseError" class="bg-red-50 border border-red-200 rounded-2xl p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-alert-circle" class="w-5 h-5 text-red-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p class="font-medium text-red-800 text-sm">Fehler bei der Erkennung</p>
|
||||
<p class="text-red-600 text-sm mt-1">{{ parseError }}</p>
|
||||
<p v-if="parseError.includes('API')" class="text-red-500 text-xs mt-2">
|
||||
Stellen Sie sicher, dass ANTHROPIC_API_KEY korrekt konfiguriert ist.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Ambiguities -->
|
||||
<div v-if="ambiguities.length > 0" class="bg-amber-50 border border-amber-200 rounded-2xl p-4 mb-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-alert-triangle" class="w-5 h-5 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<p class="font-medium text-amber-800 text-sm">Hinweise</p>
|
||||
<ul class="mt-1 space-y-1">
|
||||
<li v-for="a in ambiguities" :key="a" class="text-amber-700 text-sm">{{ a }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Parsed results -->
|
||||
<div v-if="parsedConstraints.length > 0" class="space-y-4 mb-6">
|
||||
<h2 class="font-bold text-gray-900">Erkannte Bedingungen</h2>
|
||||
<div
|
||||
v-for="(c, idx) in parsedConstraints"
|
||||
:key="idx"
|
||||
class="bg-white rounded-2xl border border-gray-100 shadow-sm p-5"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3 mb-3">
|
||||
<p class="font-medium text-gray-900">{{ c.natural_language_summary }}</p>
|
||||
<UBadge :color="c.hard ? 'error' : 'warning'" variant="subtle" size="sm" class="shrink-0">
|
||||
{{ c.hard ? 'Pflicht' : 'Wunsch' }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2 text-xs text-gray-500">
|
||||
<span class="bg-gray-50 rounded-lg px-2 py-1 font-mono">{{ c.type }}</span>
|
||||
<span v-if="c.scope.type !== 'global'" class="bg-violet-50 text-violet-700 rounded-lg px-2 py-1">
|
||||
{{ scopeLabel(c) }}
|
||||
</span>
|
||||
<span v-if="c.weight" class="bg-amber-50 text-amber-700 rounded-lg px-2 py-1">
|
||||
Gewicht: {{ c.weight }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-lucide-check"
|
||||
:loading="saving"
|
||||
@click="saveConstraints"
|
||||
>
|
||||
{{ parsedConstraints.length > 1 ? `${parsedConstraints.length} Bedingungen hinzufügen` : 'Bedingung hinzufügen' }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ConstraintJSON } from '~/shared/types/constraint'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
|
||||
const inputText = ref('')
|
||||
const parsing = ref(false)
|
||||
const saving = ref(false)
|
||||
const parseError = ref('')
|
||||
const parsedConstraints = ref<ConstraintJSON[]>([])
|
||||
const ambiguities = ref<string[]>([])
|
||||
|
||||
const exampleHints = [
|
||||
'Sabine mag keine Nachtschichten',
|
||||
'Maximal 10 Stunden pro Tag',
|
||||
'Mindestens 11 Stunden Ruhezeit zwischen Schichten',
|
||||
'Nicht mehr als 5 Schichten am Stück',
|
||||
'Keine Nachtschicht direkt nach Frühschicht',
|
||||
'Faire Verteilung der Wochenendschichten',
|
||||
]
|
||||
|
||||
function scopeLabel(c: ConstraintJSON) {
|
||||
if (c.scope.type === 'employee') return `Mitarbeiter: ${(c.scope as { employee_id: string }).employee_id}`
|
||||
if (c.scope.type === 'role') return `Rolle: ${(c.scope as { role: string }).role}`
|
||||
return 'Global'
|
||||
}
|
||||
|
||||
async function parseConstraint() {
|
||||
parseError.value = ''
|
||||
parsedConstraints.value = []
|
||||
ambiguities.value = []
|
||||
parsing.value = true
|
||||
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
|
||||
try {
|
||||
const result = await $fetch('/api/constraints/parse', {
|
||||
method: 'POST',
|
||||
body: { text: inputText.value, org_id: orgId },
|
||||
}) as { constraints: ConstraintJSON[]; ambiguities: string[] }
|
||||
|
||||
parsedConstraints.value = result.constraints
|
||||
ambiguities.value = result.ambiguities
|
||||
} catch (err: unknown) {
|
||||
const msg = (err as { data?: { message?: string } })?.data?.message || String(err)
|
||||
parseError.value = msg
|
||||
} finally {
|
||||
parsing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConstraints() {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (!orgId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
for (const c of parsedConstraints.value) {
|
||||
await pb.collection('constraints').create({
|
||||
org_id: orgId,
|
||||
label: c.natural_language_summary,
|
||||
source_text: inputText.value,
|
||||
constraint_json: c,
|
||||
scope: c.scope.type,
|
||||
scope_ref: (c.scope as Record<string, string>).employee_id || (c.scope as Record<string, string>).role || '',
|
||||
category: inferCategory(c),
|
||||
hard: c.hard,
|
||||
weight: c.weight ?? 50,
|
||||
active: true,
|
||||
source: 'ai',
|
||||
template_id: '',
|
||||
})
|
||||
}
|
||||
toast.add({ color: 'success', title: `${parsedConstraints.value.length} Bedingung(en) gespeichert` })
|
||||
await navigateTo('/constraints')
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler beim Speichern', description: String(err) })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function inferCategory(c: ConstraintJSON): string {
|
||||
if (['max_hours_per_day', 'max_hours_per_week', 'min_rest_between_shifts'].includes(c.type)) return 'legal'
|
||||
if (['employee_avoids_period', 'employee_prefers_period'].includes(c.type)) return 'preference'
|
||||
return 'operational'
|
||||
}
|
||||
</script>
|
||||
270
app/pages/constraints/templates.vue
Normal file
270
app/pages/constraints/templates.vue
Normal file
@@ -0,0 +1,270 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<NuxtLink to="/constraints">
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Gesetzliche Vorlagen</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">Vorgefertigte Bedingungen aus dem deutschen Arbeitszeitgesetz (ArbZG)</p>
|
||||
</div>
|
||||
</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 Vorlagen...
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="tmpl in templates"
|
||||
:key="tmpl.id"
|
||||
class="bg-white rounded-2xl border shadow-sm p-5 transition-colors"
|
||||
:class="activeIds.has(tmpl.id) ? 'border-indigo-200' : 'border-gray-100'"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<p class="font-semibold text-gray-900">{{ tmpl.label }}</p>
|
||||
<UBadge v-if="tmpl.mandatory" color="error" variant="subtle" size="sm">Pflicht</UBadge>
|
||||
<UBadge v-else color="warning" variant="subtle" size="sm">Empfehlung</UBadge>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 mb-2">{{ tmpl.description }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs text-blue-600 font-medium bg-blue-50 px-2 py-0.5 rounded-full">
|
||||
<UIcon name="i-lucide-landmark" class="w-3 h-3 inline mr-1" />{{ tmpl.law_name }}
|
||||
</span>
|
||||
<span class="text-xs text-gray-400">{{ tmpl.region }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<UButton
|
||||
v-if="activeIds.has(tmpl.id)"
|
||||
color="success"
|
||||
variant="soft"
|
||||
icon="i-lucide-check"
|
||||
size="sm"
|
||||
:loading="togglingId === tmpl.id"
|
||||
@click="removeTemplate(tmpl)"
|
||||
>
|
||||
Aktiv
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
color="primary"
|
||||
variant="outline"
|
||||
icon="i-lucide-plus"
|
||||
size="sm"
|
||||
:loading="togglingId === tmpl.id"
|
||||
@click="addTemplate(tmpl)"
|
||||
>
|
||||
Hinzufügen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { LegalTemplate } from '~/shared/types/pocketbase'
|
||||
import type { ConstraintJSON } from '~/shared/types/constraint'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
|
||||
const loading = ref(true)
|
||||
const togglingId = ref<string | null>(null)
|
||||
const templates = ref<LegalTemplate[]>([])
|
||||
const activeIds = ref<Set<string>>(new Set())
|
||||
|
||||
// Map from template_id → constraint record id
|
||||
const constraintByTemplateId = ref<Record<string, string>>({})
|
||||
|
||||
// Hardcoded ArbZG defaults
|
||||
const DEFAULT_TEMPLATES: LegalTemplate[] = [
|
||||
{
|
||||
id: 'arbzg-3',
|
||||
region: 'Deutschland',
|
||||
law_name: 'ArbZG §3',
|
||||
label: 'Maximale Arbeitszeit 8 Stunden/Tag',
|
||||
description: 'Die werktägliche Arbeitszeit darf 8 Stunden nicht überschreiten. Sie kann auf bis zu 10 Stunden verlängert werden, wenn innerhalb von 6 Kalendermonaten im Durchschnitt 8 Stunden/Tag nicht überschritten werden.',
|
||||
constraint_json: {
|
||||
type: 'max_hours_per_day',
|
||||
scope: { type: 'global' },
|
||||
params: { max_hours: 10 },
|
||||
hard: true,
|
||||
natural_language_summary: 'Maximal 10 Stunden Arbeitszeit pro Tag (ArbZG §3)',
|
||||
} as ConstraintJSON,
|
||||
category: 'legal',
|
||||
mandatory: true,
|
||||
sort_order: 1,
|
||||
},
|
||||
{
|
||||
id: 'arbzg-5',
|
||||
region: 'Deutschland',
|
||||
law_name: 'ArbZG §5',
|
||||
label: 'Mindestruhezeit 11 Stunden',
|
||||
description: 'Nach Beendigung der täglichen Arbeitszeit ist den Arbeitnehmern eine ununterbrochene Ruhezeit von mindestens elf Stunden zu gewähren.',
|
||||
constraint_json: {
|
||||
type: 'min_rest_between_shifts',
|
||||
scope: { type: 'global' },
|
||||
params: { min_hours: 11 },
|
||||
hard: true,
|
||||
natural_language_summary: 'Mindestens 11 Stunden Ruhezeit zwischen Schichten (ArbZG §5)',
|
||||
} as ConstraintJSON,
|
||||
category: 'legal',
|
||||
mandatory: true,
|
||||
sort_order: 2,
|
||||
},
|
||||
{
|
||||
id: 'arbzg-9',
|
||||
region: 'Deutschland',
|
||||
law_name: 'ArbZG §9',
|
||||
label: 'Sonntagsruhe',
|
||||
description: 'Arbeitnehmer dürfen an Sonn- und gesetzlichen Feiertagen von 0 bis 24 Uhr nicht beschäftigt werden (mit branchenspezifischen Ausnahmen).',
|
||||
constraint_json: {
|
||||
type: 'max_weekend_shifts_per_month',
|
||||
scope: { type: 'global' },
|
||||
params: { max_count: 4 },
|
||||
hard: false,
|
||||
weight: 80,
|
||||
natural_language_summary: 'Maximale Sonntagsschichten nach ArbZG §9 beachten',
|
||||
} as ConstraintJSON,
|
||||
category: 'legal',
|
||||
mandatory: false,
|
||||
sort_order: 3,
|
||||
},
|
||||
{
|
||||
id: 'arbzg-consecutive',
|
||||
region: 'Deutschland',
|
||||
law_name: 'Empfehlung',
|
||||
label: 'Maximal 6 Schichten am Stück',
|
||||
description: 'Empfohlene Begrenzung aufeinanderfolgender Arbeitstage für Mitarbeiterwohlbefinden.',
|
||||
constraint_json: {
|
||||
type: 'max_consecutive_shifts',
|
||||
scope: { type: 'global' },
|
||||
params: { max_count: 6 },
|
||||
hard: false,
|
||||
weight: 70,
|
||||
natural_language_summary: 'Empfehlung: Nicht mehr als 6 Schichten in Folge',
|
||||
} as ConstraintJSON,
|
||||
category: 'legal',
|
||||
mandatory: false,
|
||||
sort_order: 4,
|
||||
},
|
||||
{
|
||||
id: 'arbzg-fair',
|
||||
region: 'Deutschland',
|
||||
law_name: 'Empfehlung',
|
||||
label: 'Faire Schichtverteilung',
|
||||
description: 'Nachtschichten und Wochenendschichten werden gleichmäßig auf alle Mitarbeiter verteilt.',
|
||||
constraint_json: {
|
||||
type: 'fair_distribution',
|
||||
scope: { type: 'global' },
|
||||
params: { metric: 'night_shifts', max_deviation_percent: 20 },
|
||||
hard: false,
|
||||
weight: 60,
|
||||
natural_language_summary: 'Faire Verteilung der Nachtschichten (max. 20% Abweichung)',
|
||||
} as ConstraintJSON,
|
||||
category: 'preference',
|
||||
mandatory: false,
|
||||
sort_order: 5,
|
||||
},
|
||||
]
|
||||
|
||||
async function loadTemplates() {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
loading.value = true
|
||||
try {
|
||||
// Try PocketBase first
|
||||
let pbTemplates: LegalTemplate[] = []
|
||||
try {
|
||||
const result = await pb.collection('legal_templates').getFullList({ sort: 'sort_order' })
|
||||
pbTemplates = result as unknown as LegalTemplate[]
|
||||
} catch {
|
||||
// Collection may not exist yet — fall back to hardcoded
|
||||
}
|
||||
|
||||
templates.value = pbTemplates.length > 0 ? pbTemplates : DEFAULT_TEMPLATES
|
||||
|
||||
if (orgId) {
|
||||
// Load which templates are already active for this org
|
||||
const existing = await pb.collection('constraints').getFullList({
|
||||
filter: `org_id = "${orgId}" && source = "legal"`,
|
||||
fields: 'id,template_id',
|
||||
})
|
||||
for (const e of existing) {
|
||||
const tid = (e as { template_id?: string }).template_id
|
||||
if (tid) {
|
||||
activeIds.value.add(tid)
|
||||
constraintByTemplateId.value[tid] = e.id
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load templates', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addTemplate(tmpl: LegalTemplate) {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (!orgId) return
|
||||
|
||||
togglingId.value = tmpl.id
|
||||
try {
|
||||
const created = await pb.collection('constraints').create({
|
||||
org_id: orgId,
|
||||
label: tmpl.label,
|
||||
source_text: tmpl.description,
|
||||
constraint_json: tmpl.constraint_json,
|
||||
scope: 'global',
|
||||
scope_ref: '',
|
||||
category: tmpl.category,
|
||||
hard: tmpl.constraint_json.hard,
|
||||
weight: tmpl.constraint_json.weight ?? 100,
|
||||
active: true,
|
||||
source: 'legal',
|
||||
template_id: tmpl.id,
|
||||
})
|
||||
activeIds.value.add(tmpl.id)
|
||||
constraintByTemplateId.value[tmpl.id] = created.id
|
||||
toast.add({ color: 'success', title: `"${tmpl.label}" hinzugefügt` })
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
|
||||
} finally {
|
||||
togglingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function removeTemplate(tmpl: LegalTemplate) {
|
||||
const constraintId = constraintByTemplateId.value[tmpl.id]
|
||||
if (!constraintId) return
|
||||
|
||||
togglingId.value = tmpl.id
|
||||
try {
|
||||
await pb.collection('constraints').delete(constraintId)
|
||||
activeIds.value.delete(tmpl.id)
|
||||
delete constraintByTemplateId.value[tmpl.id]
|
||||
toast.add({ color: 'success', title: `"${tmpl.label}" entfernt` })
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
|
||||
} finally {
|
||||
togglingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
|
||||
await loadTemplates()
|
||||
})
|
||||
</script>
|
||||
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>
|
||||
240
app/pages/employees/index.vue
Normal file
240
app/pages/employees/index.vue
Normal file
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Mitarbeiter</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">
|
||||
{{ employees.length }} von {{ employeeLimit === Infinity ? '∞' : employeeLimit }} Mitarbeitern
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-lucide-user-plus"
|
||||
@click="showAddForm = true"
|
||||
:disabled="!canAdd"
|
||||
>
|
||||
Mitarbeiter hinzufügen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Add employee form -->
|
||||
<div v-if="showAddForm" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6">
|
||||
<h2 class="font-bold text-gray-900 mb-4">Neuer Mitarbeiter</h2>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<UFormField label="Name" required>
|
||||
<UInput v-model="form.name" placeholder="Max Mustermann" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="E-Mail (optional)">
|
||||
<UInput v-model="form.email" type="email" placeholder="max@firma.de" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Anstellungsart">
|
||||
<USelect
|
||||
v-model="form.employment_type"
|
||||
:options="employmentTypeOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
<UFormField label="Wochenstunden (Ziel)">
|
||||
<UInput v-model.number="form.weekly_hours_target" type="number" min="0" max="60" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Rollen/Tags (kommagetrennt)" class="sm:col-span-2">
|
||||
<UInput v-model="rolesInput" placeholder="z.B. Kassierer, Lager, Schichtleiter" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<UButton color="primary" :loading="saving" @click="addEmployee">Hinzufügen</UButton>
|
||||
<UButton color="neutral" variant="ghost" @click="cancelAdd">Abbrechen</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Employee list -->
|
||||
<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 Mitarbeiter...
|
||||
</div>
|
||||
|
||||
<div v-else-if="employees.length === 0 && !showAddForm" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-12 text-center">
|
||||
<UIcon name="i-lucide-users" class="w-12 h-12 text-gray-300 mx-auto mb-4" />
|
||||
<h3 class="font-semibold text-gray-700 mb-1">Noch keine Mitarbeiter</h3>
|
||||
<p class="text-gray-400 text-sm mb-4">Fügen Sie Ihren ersten Mitarbeiter hinzu, um loszulegen.</p>
|
||||
<UButton color="primary" icon="i-lucide-user-plus" @click="showAddForm = true">
|
||||
Mitarbeiter hinzufügen
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm divide-y divide-gray-50">
|
||||
<div
|
||||
v-for="emp in employees"
|
||||
:key="emp.id"
|
||||
class="px-6 py-4 flex items-center gap-4 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<!-- Avatar -->
|
||||
<div class="w-10 h-10 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
|
||||
<span class="text-indigo-700 font-semibold text-sm">{{ initials(emp.name) }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Info -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-medium text-gray-900">{{ emp.name }}</p>
|
||||
<p v-if="emp.email" class="text-xs text-gray-400">{{ emp.email }}</p>
|
||||
<div class="flex flex-wrap gap-1 mt-1">
|
||||
<span
|
||||
v-for="role in emp.roles"
|
||||
:key="role"
|
||||
class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-violet-50 text-violet-700"
|
||||
>{{ role }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Employment type badge -->
|
||||
<UBadge :color="employmentColor(emp.employment_type)" variant="subtle" size="sm" class="shrink-0">
|
||||
{{ employmentLabel(emp.employment_type) }}
|
||||
</UBadge>
|
||||
|
||||
<!-- Hours -->
|
||||
<span class="text-sm text-gray-500 shrink-0">{{ emp.weekly_hours_target }}h/Woche</span>
|
||||
|
||||
<!-- Delete -->
|
||||
<UButton
|
||||
icon="i-lucide-trash-2"
|
||||
color="error"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
:loading="deletingId === emp.id"
|
||||
@click="deleteEmployee(emp.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { PBEmployee } from '~/shared/types/pocketbase'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
const { canAddEmployee, limits } = useSubscription()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const showAddForm = ref(false)
|
||||
const deletingId = ref<string | null>(null)
|
||||
const employees = ref<PBEmployee[]>([])
|
||||
const rolesInput = ref('')
|
||||
|
||||
const employeeLimit = computed(() => limits.value.employee_limit)
|
||||
const canAdd = computed(() => canAddEmployee(employees.value.length))
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
email: '',
|
||||
employment_type: 'full_time',
|
||||
weekly_hours_target: 40,
|
||||
})
|
||||
|
||||
const employmentTypeOptions = [
|
||||
{ label: 'Vollzeit', value: 'full_time' },
|
||||
{ label: 'Teilzeit', value: 'part_time' },
|
||||
{ label: 'Minijob', value: 'mini_job' },
|
||||
]
|
||||
|
||||
function employmentLabel(type: string) {
|
||||
const map: Record<string, string> = { full_time: 'Vollzeit', part_time: 'Teilzeit', mini_job: 'Minijob' }
|
||||
return map[type] ?? type
|
||||
}
|
||||
|
||||
function employmentColor(type: string): 'primary' | 'success' | 'neutral' {
|
||||
if (type === 'full_time') return 'primary'
|
||||
if (type === 'part_time') return 'success'
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
function initials(name: string) {
|
||||
return name.split(' ').map(n => n[0]).slice(0, 2).join('').toUpperCase()
|
||||
}
|
||||
|
||||
function cancelAdd() {
|
||||
showAddForm.value = false
|
||||
form.name = ''
|
||||
form.email = ''
|
||||
form.employment_type = 'full_time'
|
||||
form.weekly_hours_target = 40
|
||||
rolesInput.value = ''
|
||||
}
|
||||
|
||||
async function loadEmployees() {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (!orgId) return
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await pb.collection('employees').getFullList({
|
||||
filter: `org_id = "${orgId}" && active = true`,
|
||||
sort: 'name',
|
||||
})
|
||||
employees.value = result as unknown as PBEmployee[]
|
||||
} catch (err) {
|
||||
console.error('Failed to load employees', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function addEmployee() {
|
||||
if (!form.name.trim()) {
|
||||
toast.add({ color: 'error', title: 'Name ist erforderlich' })
|
||||
return
|
||||
}
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (!orgId) return
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const roles = rolesInput.value
|
||||
? rolesInput.value.split(',').map(r => r.trim()).filter(Boolean)
|
||||
: []
|
||||
const created = await pb.collection('employees').create({
|
||||
org_id: orgId,
|
||||
name: form.name.trim(),
|
||||
email: form.email.trim() || '',
|
||||
employment_type: form.employment_type,
|
||||
weekly_hours_target: form.weekly_hours_target,
|
||||
max_weekly_hours: form.weekly_hours_target + 10,
|
||||
roles,
|
||||
skills: [],
|
||||
available_periods: [],
|
||||
unavailable_dates: [],
|
||||
active: true,
|
||||
})
|
||||
employees.value.push(created as unknown as PBEmployee)
|
||||
toast.add({ color: 'success', title: `${form.name} hinzugefügt` })
|
||||
cancelAdd()
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler', description: String(err) })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteEmployee(id: string) {
|
||||
deletingId.value = id
|
||||
try {
|
||||
await pb.collection('employees').update(id, { active: false })
|
||||
employees.value = employees.value.filter(e => e.id !== id)
|
||||
toast.add({ color: 'success', title: 'Mitarbeiter entfernt' })
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler beim Löschen', description: String(err) })
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
|
||||
await loadEmployees()
|
||||
})
|
||||
</script>
|
||||
@@ -1,41 +1,272 @@
|
||||
<template>
|
||||
<div>
|
||||
<UPageHero
|
||||
:title="$t('hero.title')"
|
||||
:description="$t('hero.description')"
|
||||
:links="links"
|
||||
/>
|
||||
<div class="flex justify-center mt-8">
|
||||
<CounterWidget v-if="isAuthenticated" />
|
||||
<UEmpty
|
||||
v-else
|
||||
icon="i-lucide-user-x"
|
||||
:title="$t('counter.notAuthenticated')"
|
||||
:description="$t('counter.signInToUse')"
|
||||
/>
|
||||
</div>
|
||||
<div class="min-h-screen bg-white">
|
||||
<!-- Navigation -->
|
||||
<nav class="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-xl border-b border-gray-100">
|
||||
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
|
||||
<span class="text-white font-bold text-sm">S</span>
|
||||
</div>
|
||||
<span class="font-bold text-gray-900 text-lg">ShiftCraft</span>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center gap-8">
|
||||
<a href="#features" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">Features</a>
|
||||
<a href="#how-it-works" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">So funktioniert's</a>
|
||||
<a href="#pricing" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">Preise</a>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<NuxtLink to="/login" class="text-sm font-medium text-gray-700 hover:text-gray-900 transition-colors">Anmelden</NuxtLink>
|
||||
<NuxtLink to="/register">
|
||||
<UButton color="primary" size="sm">Kostenlos starten</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Hero -->
|
||||
<section class="pt-32 pb-20 px-6 relative overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gradient-to-br from-indigo-50 via-violet-50 to-emerald-50 -z-10" />
|
||||
<div class="absolute top-20 right-0 w-96 h-96 bg-violet-200 rounded-full filter blur-3xl opacity-30 -z-10" />
|
||||
<div class="absolute bottom-0 left-20 w-64 h-64 bg-indigo-200 rounded-full filter blur-3xl opacity-30 -z-10" />
|
||||
|
||||
<div class="max-w-4xl mx-auto text-center">
|
||||
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-indigo-50 text-indigo-700 rounded-full text-sm font-medium mb-8 border border-indigo-100">
|
||||
<span class="w-1.5 h-1.5 rounded-full bg-indigo-500 animate-pulse" />
|
||||
Schichtplanung neu gedacht
|
||||
</div>
|
||||
|
||||
<h1 class="text-5xl md:text-7xl font-extrabold text-gray-900 leading-tight mb-6">
|
||||
Schichtplanung,<br>
|
||||
<span class="bg-gradient-to-r from-indigo-600 to-violet-600 bg-clip-text text-transparent">
|
||||
die Ihre Sprache<br>spricht
|
||||
</span>
|
||||
</h1>
|
||||
|
||||
<p class="text-xl text-gray-600 max-w-2xl mx-auto mb-10 leading-relaxed">
|
||||
Einfach Ihre Wünsche und Regeln eingeben — wie in einer E-Mail.
|
||||
ShiftCraft erstellt automatisch den optimalen, fairen Schichtplan.
|
||||
</p>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center mb-16">
|
||||
<NuxtLink to="/register">
|
||||
<UButton color="primary" size="xl" trailing-icon="i-lucide-arrow-right" class="shadow-lg shadow-indigo-200">
|
||||
Kostenlos starten
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<a href="#how-it-works">
|
||||
<UButton color="neutral" variant="outline" size="xl">
|
||||
Demo ansehen
|
||||
</UButton>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Mock UI Preview -->
|
||||
<div class="relative max-w-3xl mx-auto">
|
||||
<div class="bg-white rounded-3xl shadow-2xl border border-gray-100 overflow-hidden">
|
||||
<div class="bg-gray-50 px-6 py-4 border-b border-gray-100 flex items-center gap-2">
|
||||
<div class="w-3 h-3 rounded-full bg-red-400" />
|
||||
<div class="w-3 h-3 rounded-full bg-yellow-400" />
|
||||
<div class="w-3 h-3 rounded-full bg-green-400" />
|
||||
<span class="ml-4 text-xs text-gray-400 font-medium">ShiftCraft — Neue Bedingung</span>
|
||||
</div>
|
||||
<div class="p-6 text-left">
|
||||
<div class="mb-4">
|
||||
<label class="text-xs font-medium text-gray-500 uppercase tracking-wide mb-2 block">Bedingung eingeben</label>
|
||||
<div class="w-full px-4 py-3 bg-gray-50 border border-gray-200 rounded-xl text-gray-700 text-sm min-h-[80px]">
|
||||
Sabine mag keine Nachtschichten und sollte maximal 4 Tage am Stück arbeiten. Tom darf nicht mehr als 3 Nachtschichten hintereinander machen.
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div v-for="preview in constraintPreviews" :key="preview.text" class="bg-indigo-50 border border-indigo-100 rounded-xl p-3 flex items-start gap-3">
|
||||
<UIcon name="i-lucide-check-circle" class="text-indigo-600 mt-0.5 w-4 h-4 shrink-0" />
|
||||
<div>
|
||||
<p class="text-sm font-medium text-indigo-900">{{ preview.text }}</p>
|
||||
<p class="text-xs text-indigo-500">{{ preview.meta }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="absolute -bottom-4 -right-4 w-32 h-32 bg-violet-100 rounded-full -z-10" />
|
||||
<div class="absolute -top-4 -left-4 w-24 h-24 bg-emerald-100 rounded-full -z-10" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Features -->
|
||||
<section id="features" class="py-24 px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">Alles was Sie brauchen</h2>
|
||||
<p class="text-xl text-gray-600 max-w-2xl mx-auto">Keine komplizierte Software mehr. Einfach eingeben, was Sie wollen.</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div v-for="feature in features" :key="feature.title" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 hover:shadow-md transition-shadow">
|
||||
<div class="w-12 h-12 rounded-2xl flex items-center justify-center text-2xl mb-6" :class="feature.bgColor">
|
||||
{{ feature.icon }}
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3">{{ feature.title }}</h3>
|
||||
<p class="text-gray-600 leading-relaxed">{{ feature.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- How it works -->
|
||||
<section id="how-it-works" class="py-24 px-6 bg-gradient-to-br from-gray-50 to-indigo-50/30">
|
||||
<div class="max-w-5xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">So einfach geht's</h2>
|
||||
<p class="text-xl text-gray-600">In drei Schritten zum perfekten Schichtplan</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div v-for="(step, i) in steps" :key="step.title" class="text-center relative">
|
||||
<div class="w-20 h-20 rounded-3xl bg-white shadow-lg border border-gray-100 flex items-center justify-center text-4xl mx-auto mb-6 relative">
|
||||
{{ step.icon }}
|
||||
<span class="absolute -top-2 -right-2 w-7 h-7 rounded-full bg-indigo-600 text-white text-xs font-bold flex items-center justify-center">{{ i + 1 }}</span>
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-3">{{ step.title }}</h3>
|
||||
<p class="text-gray-600 leading-relaxed">{{ step.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Legal highlight -->
|
||||
<section class="py-24 px-6">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="bg-gradient-to-r from-indigo-600 to-violet-700 rounded-3xl p-12 text-white">
|
||||
<div class="grid md:grid-cols-2 gap-12 items-center">
|
||||
<div>
|
||||
<div class="inline-flex items-center gap-2 px-4 py-1.5 bg-white/20 rounded-full text-sm font-medium mb-6">
|
||||
⚖️ Rechtlich abgesichert
|
||||
</div>
|
||||
<h2 class="text-3xl font-bold mb-4">Gesetzliche Vorgaben inklusive</h2>
|
||||
<p class="text-indigo-100 text-lg leading-relaxed mb-6">
|
||||
Alle relevanten Vorschriften aus dem Arbeitszeitgesetz sind bereits als Vorlagen hinterlegt.
|
||||
Einfach aktivieren — fertig.
|
||||
</p>
|
||||
<ul class="space-y-3">
|
||||
<li v-for="rule in legalRules" :key="rule" class="flex items-center gap-3">
|
||||
<span class="text-emerald-300">✓</span>
|
||||
<span class="text-indigo-100">{{ rule }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div v-for="template in legalTemplates" :key="template.label" class="bg-white/10 backdrop-blur rounded-2xl p-4 border border-white/20">
|
||||
<div class="flex items-center justify-between mb-1">
|
||||
<span class="font-medium">{{ template.label }}</span>
|
||||
<span class="text-xs bg-red-400/30 text-red-200 px-2 py-0.5 rounded-full">Pflicht</span>
|
||||
</div>
|
||||
<p class="text-sm text-indigo-200">{{ template.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Pricing -->
|
||||
<section id="pricing" class="py-24 px-6 bg-gray-50">
|
||||
<div class="max-w-6xl mx-auto">
|
||||
<div class="text-center mb-16">
|
||||
<h2 class="text-4xl font-bold text-gray-900 mb-4">Einfache Preise</h2>
|
||||
<p class="text-xl text-gray-600">Kein verstecktes Kleingedrucktes. Starten Sie kostenlos.</p>
|
||||
</div>
|
||||
<div class="grid md:grid-cols-3 gap-8">
|
||||
<div
|
||||
v-for="(tier, key) in PLAN_LIMITS"
|
||||
:key="key"
|
||||
class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 relative"
|
||||
:class="key === 'pro' ? 'ring-2 ring-indigo-500 shadow-xl shadow-indigo-100' : ''"
|
||||
>
|
||||
<div v-if="key === 'pro'" class="absolute -top-4 left-1/2 -translate-x-1/2 px-4 py-1 bg-indigo-600 text-white text-sm font-medium rounded-full whitespace-nowrap">
|
||||
Beliebteste Wahl
|
||||
</div>
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-2">{{ tier.name }}</h3>
|
||||
<p class="text-gray-500 mb-6 text-sm">{{ tier.description }}</p>
|
||||
<div class="mb-8">
|
||||
<span class="text-5xl font-extrabold text-gray-900">€{{ tier.price_eur_month }}</span>
|
||||
<span class="text-gray-500 ml-2">/Monat</span>
|
||||
</div>
|
||||
<ul class="space-y-3 mb-8">
|
||||
<li v-for="f in tier.features" :key="f" class="flex items-center gap-3 text-gray-600 text-sm">
|
||||
<UIcon name="i-lucide-check" class="text-emerald-500 w-4 h-4 shrink-0" />
|
||||
{{ f }}
|
||||
</li>
|
||||
</ul>
|
||||
<NuxtLink :to="tier.price_eur_month === 0 ? '/register' : '/register'">
|
||||
<UButton
|
||||
:color="key === 'pro' ? 'primary' : 'neutral'"
|
||||
:variant="key === 'pro' ? 'solid' : 'outline'"
|
||||
class="w-full justify-center"
|
||||
size="lg"
|
||||
>
|
||||
{{ tier.price_eur_month === 0 ? 'Kostenlos starten' : 'Jetzt upgraden' }}
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="py-12 px-6 border-t border-gray-100">
|
||||
<div class="max-w-6xl mx-auto flex flex-col md:flex-row items-center justify-between gap-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="w-7 h-7 rounded-lg bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xs">S</span>
|
||||
</div>
|
||||
<span class="font-bold text-gray-900">ShiftCraft</span>
|
||||
</div>
|
||||
<p class="text-gray-500 text-sm">© 2025 ShiftCraft. Alle Rechte vorbehalten.</p>
|
||||
<div class="flex gap-6">
|
||||
<a href="#" class="text-gray-500 hover:text-gray-700 text-sm transition-colors">Datenschutz</a>
|
||||
<a href="#" class="text-gray-500 hover:text-gray-700 text-sm transition-colors">Impressum</a>
|
||||
<a href="#" class="text-gray-500 hover:text-gray-700 text-sm transition-colors">Kontakt</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ButtonProps } from '@nuxt/ui'
|
||||
import { PLAN_LIMITS } from '~/app/utils/planLimits'
|
||||
|
||||
const { isAuthenticated } = storeToRefs(useUser())
|
||||
definePageMeta({ layout: false })
|
||||
|
||||
const { t } = useI18n()
|
||||
const constraintPreviews = [
|
||||
{ text: 'Sabine bevorzugt keine Nachtschichten', meta: 'Soft-Bedingung · Gewicht 65' },
|
||||
{ text: 'Max. 4 aufeinanderfolgende Schichten (Sabine)', meta: 'Harte Bedingung' },
|
||||
{ text: 'Max. 3 Nachtschichten am Stück (Tom)', meta: 'Harte Bedingung' },
|
||||
]
|
||||
|
||||
const links = computed(() => [
|
||||
{
|
||||
label: t('hero.signUp'),
|
||||
to: '/login',
|
||||
icon: 'i-lucide-log-in'
|
||||
},
|
||||
{
|
||||
label: t('hero.profile'),
|
||||
to: '/profile',
|
||||
color: 'neutral',
|
||||
variant: 'subtle',
|
||||
trailingIcon: 'i-lucide-arrow-right'
|
||||
}
|
||||
] as ButtonProps[])
|
||||
const features = [
|
||||
{ icon: '💬', title: 'Einfach eingeben', description: 'Keine komplizierten Formulare. Schreiben Sie einfach, was Sie wollen — wie in einer Nachricht.', bgColor: 'bg-indigo-50' },
|
||||
{ icon: '⚡', title: 'Automatisch optimiert', description: 'Unser intelligentes System erstellt den bestmöglichen, fairen Plan in Sekunden — nicht Stunden.', bgColor: 'bg-violet-50' },
|
||||
{ icon: '⚖️', title: 'Rechtlich sicher', description: 'Gesetzliche Vorlagen für Deutschland, Österreich und die Schweiz sind bereits eingebaut.', bgColor: 'bg-emerald-50' },
|
||||
{ icon: '🤝', title: 'Mitarbeiterwünsche', description: 'Individuelle Präferenzen werden berücksichtigt — für mehr Zufriedenheit im Team.', bgColor: 'bg-amber-50' },
|
||||
{ icon: '📊', title: 'Faire Verteilung', description: 'Automatisch faire Verteilung von Nacht-, Wochenend- und Feiertagsschichten.', bgColor: 'bg-pink-50' },
|
||||
{ icon: '📤', title: 'Export & Teilen', description: 'Den fertigen Plan als PDF oder Excel exportieren und direkt teilen.', bgColor: 'bg-cyan-50' },
|
||||
]
|
||||
|
||||
const steps = [
|
||||
{ icon: '🗓️', title: 'Rahmen festlegen', description: 'Definieren Sie Ihre Schichtzeiten und wie viele Mitarbeiter pro Schicht benötigt werden.' },
|
||||
{ icon: '✏️', title: 'Regeln eingeben', description: 'Schreiben Sie Ihre Wünsche und Regeln in normaler Sprache — so einfach wie eine E-Mail.' },
|
||||
{ icon: '✨', title: 'Plan erhalten', description: 'ShiftCraft berechnet automatisch den optimalen, fairen Schichtplan für Ihr Team.' },
|
||||
]
|
||||
|
||||
const legalRules = [
|
||||
'Max. 10 Stunden Arbeitszeit pro Tag',
|
||||
'Min. 11 Stunden Ruhezeit zwischen Schichten',
|
||||
'Max. 48 Stunden Arbeitszeit pro Woche',
|
||||
'Keine Frühschicht nach Nachtschicht',
|
||||
]
|
||||
|
||||
const legalTemplates = [
|
||||
{ label: 'Max. 10 Stunden/Tag', description: 'Arbeitszeitgesetz §3 — Pflicht für alle Unternehmen' },
|
||||
{ label: 'Min. 11h Ruhezeit', description: 'Arbeitszeitgesetz §5 — Zwischen zwei Schichten' },
|
||||
{ label: 'Max. 6 Tage am Stück', description: 'Arbeitszeitgesetz §9 — Sonntagsruhe' },
|
||||
]
|
||||
</script>
|
||||
|
||||
@@ -1,110 +1,65 @@
|
||||
<template>
|
||||
<UAuthForm
|
||||
:fields="fields"
|
||||
:schema="schema"
|
||||
:providers="providers"
|
||||
:title="$t('login.title')"
|
||||
icon="i-lucide-lock"
|
||||
@submit="onSubmit($event)"
|
||||
>
|
||||
<template #footer>
|
||||
{{ $t('login.agree') }}
|
||||
<ULink
|
||||
to="/"
|
||||
class="text-primary font-medium"
|
||||
>
|
||||
{{ $t('login.terms') }}
|
||||
</ULink>.
|
||||
</template>
|
||||
</UAuthForm>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Willkommen zurück</h2>
|
||||
<p class="text-gray-500 mb-6 text-sm">Melden Sie sich in Ihrem ShiftCraft-Konto an</p>
|
||||
|
||||
<UForm :schema="schema" :state="formState" @submit="onSubmit" class="space-y-4">
|
||||
<UFormField label="E-Mail" name="email">
|
||||
<UInput v-model="formState.email" type="email" placeholder="name@firma.de" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Passwort" name="password">
|
||||
<UInput v-model="formState.password" type="password" placeholder="••••••••" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" color="primary" class="w-full justify-center" :loading="loading" size="lg">
|
||||
Anmelden
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-500">
|
||||
Noch kein Konto?
|
||||
<NuxtLink to="/register" class="text-indigo-600 hover:text-indigo-700 font-medium">Kostenlos registrieren</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as z from 'zod'
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
import type { AuthFormField } from '@nuxt/ui/runtime/components/AuthForm.vue.js'
|
||||
|
||||
definePageMeta({
|
||||
layout: 'empty'
|
||||
})
|
||||
definePageMeta({ layout: 'auth', middleware: 'guest' })
|
||||
|
||||
const { t } = useI18n()
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
// The otpId is used to track the OTP request and verify the user
|
||||
// null if no request was sent yet
|
||||
const otpId = ref<string | null>(null)
|
||||
|
||||
const fields = computed(() => {
|
||||
const fields: AuthFormField[] = [{
|
||||
name: 'email',
|
||||
type: 'text',
|
||||
label: t('login.email'),
|
||||
placeholder: t('login.emailPlaceholder'),
|
||||
required: true
|
||||
}]
|
||||
if (otpId.value !== null) {
|
||||
fields.push({
|
||||
name: 'otp',
|
||||
type: 'otp',
|
||||
label: t('login.otp'),
|
||||
length: 6,
|
||||
size: 'xl'
|
||||
})
|
||||
}
|
||||
return fields
|
||||
const formState = reactive({
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const { signInWithEmail, signInWithOAuth, signInWithOtp } = useUser()
|
||||
|
||||
const oAuthAndRedirect = async (provider: 'apple' | 'google') => {
|
||||
try {
|
||||
await signInWithOAuth(provider)
|
||||
navigateTo('/confirm')
|
||||
} catch (error) {
|
||||
toast.add({ color: 'error', description: (error as Error).message })
|
||||
}
|
||||
}
|
||||
|
||||
const providers = [
|
||||
{
|
||||
label: 'Google',
|
||||
icon: 'i-simple-icons-google',
|
||||
onClick: () => oAuthAndRedirect('google')
|
||||
},
|
||||
{
|
||||
label: 'Apple',
|
||||
icon: 'i-simple-icons-apple',
|
||||
onClick: () => oAuthAndRedirect('apple')
|
||||
}
|
||||
]
|
||||
|
||||
const schema = z.object({
|
||||
email: z.email(t('login.invalidEmail')),
|
||||
otp: z.array(z.string()).optional().transform(val => val?.join(''))
|
||||
email: z.email('Ungültige E-Mail-Adresse'),
|
||||
password: z.string().min(1, 'Passwort ist erforderlich'),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
const { locale } = useI18n()
|
||||
async function onSubmit(payload: FormSubmitEvent<Schema>) {
|
||||
loading.value = true
|
||||
try {
|
||||
if (otpId.value !== null && payload.data.otp?.length) {
|
||||
// If OTP is provided, sign in with OTP
|
||||
await signInWithOtp(otpId.value, payload.data.otp)
|
||||
navigateTo('/confirm')
|
||||
} else {
|
||||
// If OTP is not provided, sign in with email (send OTP)
|
||||
otpId.value = await signInWithEmail(payload.data.email, locale.value)
|
||||
toast.add({
|
||||
title: t('login.emailSent'),
|
||||
description: t('login.emailSentDescription'),
|
||||
icon: 'i-lucide-check',
|
||||
color: 'success'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
toast.add({ color: 'error', description: (error as Error).message })
|
||||
const { pb } = usePocketBase()
|
||||
const authData = await pb.collection('users').authWithPassword(payload.data.email, payload.data.password)
|
||||
// Load org
|
||||
const orgStore = useOrg()
|
||||
const orgId = (authData.record as { org_id?: string }).org_id
|
||||
if (orgId) await orgStore.fetchOrg(orgId)
|
||||
await navigateTo('/dashboard')
|
||||
} catch {
|
||||
toast.add({ color: 'error', title: 'Anmeldung fehlgeschlagen', description: 'E-Mail oder Passwort ist falsch.' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
107
app/pages/register.vue
Normal file
107
app/pages/register.vue
Normal file
@@ -0,0 +1,107 @@
|
||||
<template>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8">
|
||||
<h2 class="text-2xl font-bold text-gray-900 mb-2">Konto erstellen</h2>
|
||||
<p class="text-gray-500 mb-6 text-sm">Starten Sie kostenlos — keine Kreditkarte erforderlich</p>
|
||||
|
||||
<UForm :schema="schema" :state="formState" @submit="onSubmit" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Ihr Name" name="name">
|
||||
<UInput v-model="formState.name" placeholder="Max Mustermann" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Firma / Organisation" name="orgName">
|
||||
<UInput v-model="formState.orgName" placeholder="Mein Betrieb GmbH" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
<UFormField label="E-Mail" name="email">
|
||||
<UInput v-model="formState.email" type="email" placeholder="name@firma.de" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="Passwort" name="password">
|
||||
<UInput v-model="formState.password" type="password" placeholder="Mindestens 8 Zeichen" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UButton type="submit" color="primary" class="w-full justify-center" :loading="loading" size="lg">
|
||||
Kostenlos registrieren
|
||||
</UButton>
|
||||
</UForm>
|
||||
|
||||
<div class="mt-6 text-center">
|
||||
<p class="text-sm text-gray-500">
|
||||
Bereits ein Konto?
|
||||
<NuxtLink to="/login" class="text-indigo-600 hover:text-indigo-700 font-medium">Anmelden</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import * as z from 'zod'
|
||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||
|
||||
definePageMeta({ layout: 'auth', middleware: 'guest' })
|
||||
|
||||
const toast = useToast()
|
||||
const loading = ref(false)
|
||||
|
||||
const formState = reactive({
|
||||
name: '',
|
||||
orgName: '',
|
||||
email: '',
|
||||
password: '',
|
||||
})
|
||||
|
||||
const schema = z.object({
|
||||
name: z.string().min(2, 'Name muss mindestens 2 Zeichen lang sein'),
|
||||
orgName: z.string().min(2, 'Organisationsname muss mindestens 2 Zeichen lang sein'),
|
||||
email: z.email('Ungültige E-Mail-Adresse'),
|
||||
password: z.string().min(8, 'Passwort muss mindestens 8 Zeichen lang sein'),
|
||||
})
|
||||
|
||||
type Schema = z.output<typeof schema>
|
||||
|
||||
async function onSubmit(payload: FormSubmitEvent<Schema>) {
|
||||
loading.value = true
|
||||
try {
|
||||
const { pb } = usePocketBase()
|
||||
|
||||
// Create org first
|
||||
const slugBase = payload.data.orgName.toLowerCase().replace(/[^a-z0-9]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '')
|
||||
const slug = `${slugBase}-${Math.random().toString(36).slice(2, 7)}`
|
||||
|
||||
const newOrg = await pb.collection('organizations').create({
|
||||
name: payload.data.orgName,
|
||||
slug,
|
||||
timezone: 'Europe/Berlin',
|
||||
industry: 'general',
|
||||
plan: 'free',
|
||||
plan_employee_limit: 5,
|
||||
plan_history_months: 1,
|
||||
})
|
||||
|
||||
// Create user
|
||||
const newUser = await pb.collection('users').create({
|
||||
name: payload.data.name,
|
||||
email: payload.data.email,
|
||||
password: payload.data.password,
|
||||
passwordConfirm: payload.data.password,
|
||||
org_id: newOrg.id,
|
||||
role: 'owner',
|
||||
emailVisibility: true,
|
||||
})
|
||||
|
||||
// Update org with owner
|
||||
await pb.collection('organizations').update(newOrg.id, { owner: newUser.id })
|
||||
|
||||
// Login
|
||||
await pb.collection('users').authWithPassword(payload.data.email, payload.data.password)
|
||||
const orgStore = useOrg()
|
||||
await orgStore.fetchOrg(newOrg.id)
|
||||
|
||||
toast.add({ color: 'success', title: 'Willkommen bei ShiftCraft!', description: 'Ihr Konto wurde erfolgreich erstellt.' })
|
||||
await navigateTo('/dashboard')
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Registrierung fehlgeschlagen', description: (err as Error).message })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
259
app/pages/schedules/[id]/index.vue
Normal file
259
app/pages/schedules/[id]/index.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Header -->
|
||||
<div class="mb-6 flex items-center gap-3">
|
||||
<NuxtLink to="/schedules">
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
|
||||
</NuxtLink>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<h1 class="text-2xl font-bold text-gray-900 truncate">{{ run?.name ?? 'Schichtplan' }}</h1>
|
||||
<UBadge v-if="run" :color="statusColor(run.status)" variant="subtle">
|
||||
{{ statusLabel(run.status) }}
|
||||
</UBadge>
|
||||
</div>
|
||||
<p v-if="run" class="text-gray-500 text-sm mt-1">
|
||||
{{ formatDate(run.period_start) }} – {{ formatDate(run.period_end) }}
|
||||
<span v-if="run.solver_duration_ms" class="ml-2 text-gray-400">
|
||||
· {{ (run.solver_duration_ms / 1000).toFixed(1) }}s Berechnungszeit
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
</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 Schichtplan...
|
||||
</div>
|
||||
|
||||
<!-- Infeasible notice -->
|
||||
<div v-else-if="run?.status === 'infeasible'" class="space-y-4">
|
||||
<div class="bg-amber-50 border border-amber-200 rounded-2xl p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-alert-triangle" class="w-6 h-6 text-amber-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h2 class="font-bold text-amber-900 mb-2">Kein gültiger Schichtplan gefunden</h2>
|
||||
<p class="text-amber-700 text-sm mb-4">
|
||||
Die aktiven Bedingungen sind möglicherweise zu restriktiv oder es gibt nicht genügend Mitarbeiter.
|
||||
</p>
|
||||
<div v-if="hints.length > 0">
|
||||
<p class="font-medium text-amber-800 text-sm mb-2">Hinweise:</p>
|
||||
<ul class="space-y-1">
|
||||
<li v-for="hint in hints" :key="hint.description" class="text-amber-700 text-sm flex items-start gap-2">
|
||||
<UIcon name="i-lucide-chevron-right" class="w-4 h-4 mt-0.5 shrink-0" />
|
||||
{{ hint.description }}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="mt-4 flex gap-3">
|
||||
<NuxtLink to="/constraints">
|
||||
<UButton color="warning" variant="soft" size="sm" icon="i-lucide-sliders">
|
||||
Bedingungen prüfen
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
<NuxtLink to="/schedules/new">
|
||||
<UButton color="neutral" variant="outline" size="sm" icon="i-lucide-refresh-cw">
|
||||
Neu berechnen
|
||||
</UButton>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Error notice -->
|
||||
<div v-else-if="run?.status === 'error'" class="bg-red-50 border border-red-200 rounded-2xl p-6">
|
||||
<div class="flex items-start gap-3">
|
||||
<UIcon name="i-lucide-x-circle" class="w-6 h-6 text-red-600 mt-0.5 shrink-0" />
|
||||
<div>
|
||||
<h2 class="font-bold text-red-900 mb-1">Fehler bei der Berechnung</h2>
|
||||
<p class="text-red-600 text-sm">{{ hints[0]?.description ?? 'Unbekannter Fehler' }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Schedule table -->
|
||||
<div v-else-if="run?.status === 'solved' && assignments.length > 0">
|
||||
<!-- Stats row -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ employeeNames.length }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Mitarbeiter</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ assignments.length }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Schichten gesamt</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">{{ periodDays }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Tage</p>
|
||||
</div>
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-4 text-center">
|
||||
<p class="text-2xl font-bold text-gray-900">
|
||||
{{ run.objective_value !== undefined ? run.objective_value.toFixed(1) : '—' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 mt-1">Obj. Wert</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Table -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm overflow-hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 border-b border-gray-100">
|
||||
<th class="px-4 py-3 text-left font-medium text-gray-600 whitespace-nowrap sticky left-0 bg-gray-50 z-10 min-w-32">
|
||||
Mitarbeiter
|
||||
</th>
|
||||
<th
|
||||
v-for="date in allDates"
|
||||
:key="date"
|
||||
class="px-2 py-3 text-center font-medium text-gray-600 whitespace-nowrap min-w-16"
|
||||
:class="isWeekend(date) ? 'bg-amber-50' : ''"
|
||||
>
|
||||
<div>{{ dayOfWeek(date) }}</div>
|
||||
<div class="text-xs text-gray-400">{{ shortDate(date) }}</div>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-50">
|
||||
<tr v-for="emp in employeeNames" :key="emp" class="hover:bg-gray-50 transition-colors">
|
||||
<td class="px-4 py-2.5 font-medium text-gray-900 whitespace-nowrap sticky left-0 bg-white z-10">
|
||||
{{ emp }}
|
||||
</td>
|
||||
<td
|
||||
v-for="date in allDates"
|
||||
:key="date"
|
||||
class="px-1 py-2 text-center"
|
||||
:class="isWeekend(date) ? 'bg-amber-50/50' : ''"
|
||||
>
|
||||
<div
|
||||
v-for="a in getAssignments(emp, date)"
|
||||
:key="a.shift_id"
|
||||
class="px-1.5 py-0.5 rounded-md text-xs font-medium whitespace-nowrap"
|
||||
:style="{ backgroundColor: getPeriodColor(a.period_id) + '20', color: getPeriodColor(a.period_id) }"
|
||||
>
|
||||
{{ a.shift_name }}
|
||||
</div>
|
||||
<span v-if="getAssignments(emp, date).length === 0" class="text-gray-300 text-xs">—</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend -->
|
||||
<div v-if="periods.length > 0" class="mt-4 flex flex-wrap gap-3">
|
||||
<div v-for="p in periods" :key="p.id" class="flex items-center gap-1.5 text-xs text-gray-600">
|
||||
<div class="w-3 h-3 rounded-sm" :style="{ backgroundColor: p.color }" />
|
||||
{{ p.name }} ({{ p.start_time }}–{{ p.end_time }})
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { formatDate } from '~/app/utils/dateHelpers'
|
||||
import type { PBScheduleRun } from '~/shared/types/pocketbase'
|
||||
import type { ShiftAssignment, Period } from '~/shared/types/schedule'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const route = useRoute()
|
||||
const { pb } = usePocketBase()
|
||||
|
||||
const loading = ref(true)
|
||||
const run = ref<PBScheduleRun | null>(null)
|
||||
const assignments = ref<ShiftAssignment[]>([])
|
||||
const periods = ref<Period[]>([])
|
||||
|
||||
const hints = computed(() => {
|
||||
const h = run.value?.infeasibility_hints
|
||||
if (!h) return []
|
||||
if (Array.isArray(h)) return h as Array<{ description: string }>
|
||||
return []
|
||||
})
|
||||
|
||||
const employeeNames = computed(() => {
|
||||
const names = new Set(assignments.value.map(a => a.employee_name))
|
||||
return Array.from(names).sort()
|
||||
})
|
||||
|
||||
const allDates = computed(() => {
|
||||
if (!run.value) return []
|
||||
const dates: string[] = []
|
||||
const start = new Date(run.value.period_start)
|
||||
const end = new Date(run.value.period_end)
|
||||
const cur = new Date(start)
|
||||
while (cur <= end) {
|
||||
dates.push(cur.toISOString().slice(0, 10))
|
||||
cur.setDate(cur.getDate() + 1)
|
||||
}
|
||||
return dates
|
||||
})
|
||||
|
||||
const periodDays = computed(() => allDates.value.length)
|
||||
|
||||
function getAssignments(empName: string, date: string) {
|
||||
return assignments.value.filter(a => a.employee_name === empName && a.date === date)
|
||||
}
|
||||
|
||||
function getPeriodColor(periodId: string) {
|
||||
const p = periods.value.find(p => p.id === periodId)
|
||||
return p?.color || '#6366f1'
|
||||
}
|
||||
|
||||
function dayOfWeek(date: string) {
|
||||
const days = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa']
|
||||
return days[new Date(date + 'T12:00:00').getDay()]
|
||||
}
|
||||
|
||||
function shortDate(date: string) {
|
||||
const d = new Date(date + 'T12:00:00')
|
||||
return `${d.getDate()}.${d.getMonth() + 1}`
|
||||
}
|
||||
|
||||
function isWeekend(date: string) {
|
||||
const day = new Date(date + 'T12:00:00').getDay()
|
||||
return day === 0 || day === 6
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const id = route.params.id as string
|
||||
try {
|
||||
const result = await pb.collection('schedule_runs').getOne(id)
|
||||
run.value = result as unknown as PBScheduleRun
|
||||
|
||||
if (result.result) {
|
||||
assignments.value = Array.isArray(result.result)
|
||||
? result.result as ShiftAssignment[]
|
||||
: []
|
||||
}
|
||||
|
||||
const snapshot = result.framework_snapshot as { periods?: Period[] }
|
||||
if (snapshot?.periods) {
|
||||
periods.value = snapshot.periods
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load schedule run', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
126
app/pages/schedules/index.vue
Normal file
126
app/pages/schedules/index.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<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>
|
||||
241
app/pages/schedules/new.vue
Normal file
241
app/pages/schedules/new.vue
Normal file
@@ -0,0 +1,241 @@
|
||||
<template>
|
||||
<div class="max-w-xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-8 flex items-center gap-3">
|
||||
<NuxtLink to="/schedules">
|
||||
<UButton color="neutral" variant="ghost" icon="i-lucide-arrow-left" size="sm" />
|
||||
</NuxtLink>
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Neuen Schichtplan erstellen</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">KI-gestützter Optimierer berechnet den besten Plan</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Solving progress -->
|
||||
<div v-if="solvingRunId" class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8 text-center">
|
||||
<div class="w-16 h-16 rounded-full bg-indigo-50 flex items-center justify-center mx-auto mb-4">
|
||||
<UIcon name="i-lucide-loader-2" class="w-8 h-8 text-indigo-600 animate-spin" />
|
||||
</div>
|
||||
<h2 class="font-bold text-gray-900 mb-2">Berechnung läuft...</h2>
|
||||
<p class="text-gray-500 text-sm mb-4">Der Schichtplan-Optimierer arbeitet. Dies kann einige Sekunden dauern.</p>
|
||||
<UProgress v-model="solveProgress" :max="100" class="mb-4" />
|
||||
<p class="text-xs text-gray-400">Status: {{ solveStatusLabel }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Input form -->
|
||||
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div class="space-y-5">
|
||||
<UFormField label="Name des Schichtplans" required>
|
||||
<UInput
|
||||
v-model="form.name"
|
||||
placeholder="z.B. Schichtplan Juni 2026"
|
||||
class="w-full"
|
||||
/>
|
||||
</UFormField>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<UFormField label="Start-Datum" required>
|
||||
<UInput v-model="form.period_start" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
<UFormField label="End-Datum" required>
|
||||
<UInput v-model="form.period_end" type="date" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<!-- Summary -->
|
||||
<div class="bg-gray-50 rounded-xl p-4 space-y-2 text-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Mitarbeiter</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="loadingStats" class="text-gray-400">...</span>
|
||||
<span v-else>{{ employeeCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Aktive Bedingungen</span>
|
||||
<span class="font-medium text-gray-900">
|
||||
<span v-if="loadingStats" class="text-gray-400">...</span>
|
||||
<span v-else>{{ constraintCount }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center justify-between">
|
||||
<span class="text-gray-500">Schichtrahmen</span>
|
||||
<span class="font-medium" :class="hasFramework ? 'text-green-600' : 'text-red-600'">
|
||||
<span v-if="loadingStats" class="text-gray-400">...</span>
|
||||
<span v-else>{{ hasFramework ? 'Konfiguriert' : 'Fehlt!' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warnings -->
|
||||
<div v-if="!loadingStats && !hasFramework" class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-alert-triangle" class="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<p class="text-amber-700">
|
||||
Kein Schichtrahmen konfiguriert.
|
||||
<NuxtLink to="/settings/shifts" class="font-medium underline">Schichten einrichten →</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!loadingStats && employeeCount === 0" class="bg-amber-50 border border-amber-200 rounded-xl p-4 text-sm">
|
||||
<div class="flex items-start gap-2">
|
||||
<UIcon name="i-lucide-alert-triangle" class="w-4 h-4 text-amber-600 mt-0.5 shrink-0" />
|
||||
<p class="text-amber-700">
|
||||
Keine Mitarbeiter gefunden.
|
||||
<NuxtLink to="/employees" class="font-medium underline">Mitarbeiter hinzufügen →</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
color="primary"
|
||||
icon="i-lucide-calculator"
|
||||
class="w-full justify-center"
|
||||
size="lg"
|
||||
:loading="creating"
|
||||
:disabled="!canCreate"
|
||||
@click="createAndSolve"
|
||||
>
|
||||
Schichtplan berechnen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { ShiftFramework } from '~/shared/types/pocketbase'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
|
||||
const creating = ref(false)
|
||||
const loadingStats = ref(true)
|
||||
const employeeCount = ref(0)
|
||||
const constraintCount = ref(0)
|
||||
const hasFramework = ref(false)
|
||||
const solvingRunId = ref<string | null>(null)
|
||||
const solveProgress = ref(10)
|
||||
const solveStatus = ref('pending')
|
||||
|
||||
const today = new Date()
|
||||
const nextMonth = new Date(today.getFullYear(), today.getMonth() + 1, 1)
|
||||
const lastDayNextMonth = new Date(today.getFullYear(), today.getMonth() + 2, 0)
|
||||
|
||||
const form = reactive({
|
||||
name: `Schichtplan ${nextMonth.toLocaleString('de', { month: 'long', year: 'numeric' })}`,
|
||||
period_start: nextMonth.toISOString().slice(0, 10),
|
||||
period_end: lastDayNextMonth.toISOString().slice(0, 10),
|
||||
})
|
||||
|
||||
const canCreate = computed(() =>
|
||||
!!form.name && !!form.period_start && !!form.period_end && !loadingStats.value && employeeCount.value > 0 && hasFramework.value
|
||||
)
|
||||
|
||||
const solveStatusLabel = computed(() => {
|
||||
const map: Record<string, string> = { pending: 'Wird vorbereitet...', solving: 'Löser läuft...', solved: 'Abgeschlossen', infeasible: 'Keine Lösung gefunden', error: 'Fehler' }
|
||||
return map[solveStatus.value] ?? solveStatus.value
|
||||
})
|
||||
|
||||
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
|
||||
if (!orgId) { loadingStats.value = false; return }
|
||||
|
||||
try {
|
||||
const [employees, constraints, frameworks] = 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('shift_frameworks').getList(1, 1, { filter: `org_id = "${orgId}"` }),
|
||||
])
|
||||
employeeCount.value = employees.totalItems
|
||||
constraintCount.value = constraints.totalItems
|
||||
hasFramework.value = frameworks.totalItems > 0
|
||||
} catch (err) {
|
||||
console.error('Stats load error', err)
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (pollInterval) clearInterval(pollInterval)
|
||||
})
|
||||
|
||||
async function createAndSolve() {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (!orgId) return
|
||||
creating.value = true
|
||||
|
||||
try {
|
||||
// Fetch snapshot data
|
||||
const [employees, constraints, frameworkResult] = await Promise.all([
|
||||
pb.collection('employees').getFullList({ filter: `org_id = "${orgId}" && active = true` }),
|
||||
pb.collection('constraints').getFullList({ filter: `org_id = "${orgId}" && active = true` }),
|
||||
pb.collection('shift_frameworks').getFirstListItem(`org_id = "${orgId}"`),
|
||||
])
|
||||
|
||||
const framework = frameworkResult as unknown as ShiftFramework
|
||||
|
||||
// Create schedule run record
|
||||
const run = await pb.collection('schedule_runs').create({
|
||||
org_id: orgId,
|
||||
name: form.name,
|
||||
period_start: form.period_start,
|
||||
period_end: form.period_end,
|
||||
status: 'pending',
|
||||
framework_snapshot: { periods: framework.periods, shifts: framework.shifts },
|
||||
employees_snapshot: employees,
|
||||
constraints_snapshot: constraints.map((c: Record<string, unknown>) => ({
|
||||
id: c.id,
|
||||
constraint_json: c.constraint_json,
|
||||
hard: c.hard,
|
||||
weight: c.weight,
|
||||
})),
|
||||
created_by: authStore.value.record?.id,
|
||||
})
|
||||
|
||||
solvingRunId.value = run.id
|
||||
solveProgress.value = 20
|
||||
|
||||
// Call solve API
|
||||
$fetch('/api/schedules/solve', {
|
||||
method: 'POST',
|
||||
body: { run_id: run.id },
|
||||
}).catch((err) => {
|
||||
console.error('Solve error', err)
|
||||
})
|
||||
|
||||
// Poll status
|
||||
pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const updated = await pb.collection('schedule_runs').getOne(run.id)
|
||||
solveStatus.value = updated.status as string
|
||||
if (solveProgress.value < 80) solveProgress.value += 10
|
||||
|
||||
if (updated.status === 'solved') {
|
||||
solveProgress.value = 100
|
||||
clearInterval(pollInterval!)
|
||||
await navigateTo(`/schedules/${run.id}`)
|
||||
} else if (updated.status === 'infeasible' || updated.status === 'error') {
|
||||
clearInterval(pollInterval!)
|
||||
toast.add({ color: 'warning', title: 'Berechnung abgeschlossen', description: updated.status === 'infeasible' ? 'Kein gültiger Plan gefunden.' : 'Fehler bei der Berechnung.' })
|
||||
await navigateTo(`/schedules/${run.id}`)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Poll error', e)
|
||||
}
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
toast.add({ color: 'error', title: 'Fehler beim Erstellen', description: String(err) })
|
||||
} finally {
|
||||
creating.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
172
app/pages/settings/billing.vue
Normal file
172
app/pages/settings/billing.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div class="max-w-3xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Abonnement</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">Verwalten Sie Ihr ShiftCraft-Abonnement</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>
|
||||
|
||||
<!-- Current plan card -->
|
||||
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6 mb-6">
|
||||
<div class="flex items-start justify-between mb-4">
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 mb-1">Aktueller Plan</p>
|
||||
<h2 class="text-2xl font-bold text-gray-900">{{ currentPlanDetails.name }}</h2>
|
||||
<p class="text-gray-500 text-sm mt-1">{{ currentPlanDetails.description }}</p>
|
||||
</div>
|
||||
<div class="text-right">
|
||||
<span class="text-3xl font-bold text-gray-900">€{{ currentPlanDetails.price_eur_month }}</span>
|
||||
<span class="text-gray-400 text-sm">/Monat</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Usage stats -->
|
||||
<div class="grid grid-cols-2 sm:grid-cols-4 gap-4 p-4 bg-gray-50 rounded-xl">
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-bold text-gray-900">
|
||||
{{ employeeCount }}<span class="text-gray-400 text-sm font-normal"> / {{ employeeLimit === Infinity ? '∞' : employeeLimit }}</span>
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">Mitarbeiter</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-bold text-gray-900">
|
||||
{{ currentPlanDetails.solve_runs_per_month === Infinity ? '∞' : currentPlanDetails.solve_runs_per_month }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">Schichtpläne/Monat</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-bold text-gray-900">
|
||||
{{ currentPlanDetails.history_months === Infinity ? '∞' : currentPlanDetails.history_months }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">Monate Verlauf</p>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-lg font-bold" :class="currentPlanDetails.pdf_export ? 'text-green-600' : 'text-gray-400'">
|
||||
{{ currentPlanDetails.pdf_export ? 'Ja' : 'Nein' }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500">PDF/Excel Export</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Upgrade plans -->
|
||||
<div v-if="plan !== 'business'" class="space-y-4">
|
||||
<h3 class="font-bold text-gray-900">Upgraden Sie Ihren Plan</h3>
|
||||
<div class="grid sm:grid-cols-2 gap-4">
|
||||
<!-- Pro plan -->
|
||||
<div
|
||||
v-if="plan === 'free'"
|
||||
class="bg-gradient-to-br from-indigo-50 to-violet-50 rounded-2xl border-2 border-indigo-200 p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-bold text-indigo-900 text-lg">Pro</h4>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-bold text-indigo-900">€29</span>
|
||||
<span class="text-indigo-600 text-sm">/Monat</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-indigo-700 text-sm mb-4">{{ PLAN_LIMITS.pro.description }}</p>
|
||||
<ul class="space-y-1.5 mb-5">
|
||||
<li v-for="f in PLAN_LIMITS.pro.features" :key="f" class="flex items-center gap-2 text-sm text-indigo-800">
|
||||
<UIcon name="i-lucide-check" class="w-4 h-4 text-indigo-600 shrink-0" />
|
||||
{{ f }}
|
||||
</li>
|
||||
</ul>
|
||||
<UButton color="primary" class="w-full justify-center" @click="upgrade('pro')">
|
||||
Auf Pro upgraden
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<!-- Business plan -->
|
||||
<div class="bg-gradient-to-br from-violet-50 to-purple-50 rounded-2xl border-2 border-violet-200 p-6">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<h4 class="font-bold text-violet-900 text-lg">Business</h4>
|
||||
<div class="text-right">
|
||||
<span class="text-2xl font-bold text-violet-900">€99</span>
|
||||
<span class="text-violet-600 text-sm">/Monat</span>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-violet-700 text-sm mb-4">{{ PLAN_LIMITS.business.description }}</p>
|
||||
<ul class="space-y-1.5 mb-5">
|
||||
<li v-for="f in PLAN_LIMITS.business.features" :key="f" class="flex items-center gap-2 text-sm text-violet-800">
|
||||
<UIcon name="i-lucide-check" class="w-4 h-4 text-violet-600 shrink-0" />
|
||||
{{ f }}
|
||||
</li>
|
||||
</ul>
|
||||
<UButton color="secondary" class="w-full justify-center" style="background: #7c3aed; color: white" @click="upgrade('business')">
|
||||
Auf Business upgraden
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-400 text-center">
|
||||
Upgrade-Funktion via Stripe — noch nicht vollständig integriert. Kontaktieren Sie uns für Enterprise-Pricing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-green-50 border border-green-200 rounded-2xl p-4 flex items-center gap-3">
|
||||
<UIcon name="i-lucide-check-circle" class="w-5 h-5 text-green-600 shrink-0" />
|
||||
<p class="text-green-800 text-sm font-medium">Sie haben den Business-Plan — vollen Funktionsumfang freigeschaltet!</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PLAN_LIMITS } from '~/app/utils/planLimits'
|
||||
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const { pb, authStore } = usePocketBase()
|
||||
const orgStore = useOrg()
|
||||
const { plan, limits } = useSubscription()
|
||||
|
||||
const employeeCount = ref(0)
|
||||
const employeeLimit = computed(() => limits.value.employee_limit)
|
||||
|
||||
const settingsTabs = [
|
||||
{ to: '/settings/organization', label: 'Organisation' },
|
||||
{ to: '/settings/shifts', label: 'Schichten' },
|
||||
{ to: '/settings/billing', label: 'Abonnement' },
|
||||
]
|
||||
|
||||
const currentPlanDetails = computed(() => PLAN_LIMITS[plan.value])
|
||||
|
||||
function upgrade(_planName: string) {
|
||||
toast.add({
|
||||
color: 'primary',
|
||||
title: 'Stripe-Integration',
|
||||
description: 'Upgrade-Link kommt bald. Kontaktieren Sie uns unter billing@shiftcraft.app',
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const orgId = orgStore.orgId || (authStore.value.record?.org_id as string | undefined)
|
||||
if (orgId && !orgStore.org) await orgStore.fetchOrg(orgId)
|
||||
if (!orgId) return
|
||||
|
||||
try {
|
||||
const result = await pb.collection('employees').getList(1, 1, {
|
||||
filter: `org_id = "${orgId}" && active = true`,
|
||||
})
|
||||
employeeCount.value = result.totalItems
|
||||
} catch (err) {
|
||||
console.error('Failed to load employee count', err)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
128
app/pages/settings/organization.vue
Normal file
128
app/pages/settings/organization.vue
Normal file
@@ -0,0 +1,128 @@
|
||||
<template>
|
||||
<div class="max-w-2xl">
|
||||
<!-- Header -->
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Organisationseinstellungen</h1>
|
||||
<p class="text-gray-500 mt-1 text-sm">Verwalten Sie die Grundeinstellungen Ihrer 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 Einstellungen...
|
||||
</div>
|
||||
|
||||
<div v-else class="bg-white rounded-2xl border border-gray-100 shadow-sm p-6">
|
||||
<div class="space-y-5">
|
||||
<UFormField label="Organisationsname" required>
|
||||
<UInput v-model="form.name" placeholder="Mein Unternehmen GmbH" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Zeitzone">
|
||||
<USelect v-model="form.timezone" :options="timezoneOptions" class="w-full" />
|
||||
</UFormField>
|
||||
|
||||
<UFormField label="Branche">
|
||||
<USelect v-model="form.industry" :options="industryOptions" class="w-full" />
|
||||
</UFormField>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex gap-3">
|
||||
<UButton color="primary" :loading="saving" @click="save">
|
||||
Speichern
|
||||
</UButton>
|
||||
<UButton color="neutral" variant="ghost" @click="reset">
|
||||
Zurücksetzen
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
definePageMeta({ layout: 'default', middleware: 'auth' })
|
||||
|
||||
const toast = useToast()
|
||||
const orgStore = useOrg()
|
||||
const { authStore } = usePocketBase()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
timezone: 'Europe/Berlin',
|
||||
industry: 'retail',
|
||||
})
|
||||
|
||||
const settingsTabs = [
|
||||
{ to: '/settings/organization', label: 'Organisation' },
|
||||
{ to: '/settings/shifts', label: 'Schichten' },
|
||||
{ to: '/settings/billing', label: 'Abonnement' },
|
||||
]
|
||||
|
||||
const timezoneOptions = [
|
||||
{ label: 'Europa/Berlin (CET)', value: 'Europe/Berlin' },
|
||||
{ label: 'Europa/Wien (CET)', value: 'Europe/Vienna' },
|
||||
{ label: 'Europa/Zürich (CET)', value: 'Europe/Zurich' },
|
||||
{ label: 'UTC', value: 'UTC' },
|
||||
]
|
||||
|
||||
const industryOptions = [
|
||||
{ label: 'Einzelhandel', value: 'retail' },
|
||||
{ label: 'Gastronomie', value: 'hospitality' },
|
||||
{ label: 'Gesundheitswesen', value: 'healthcare' },
|
||||
{ label: 'Logistik', value: 'logistics' },
|
||||
{ label: 'Produktion', value: 'manufacturing' },
|
||||
{ label: 'Sonstiges', value: 'other' },
|
||||
]
|
||||
|
||||
function loadFormFromOrg() {
|
||||
if (orgStore.org) {
|
||||
form.name = orgStore.org.name || ''
|
||||
form.timezone = orgStore.org.timezone || 'Europe/Berlin'
|
||||
form.industry = orgStore.org.industry || 'retail'
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
loadFormFromOrg()
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
await orgStore.updateOrg({
|
||||
name: form.name,
|
||||
timezone: form.timezone,
|
||||
industry: form.industry,
|
||||
})
|
||||
toast.add({ color: 'success', title: 'Einstellungen 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)
|
||||
loadFormFromOrg()
|
||||
loading.value = false
|
||||
})
|
||||
</script>
|
||||
280
app/pages/settings/shifts.vue
Normal file
280
app/pages/settings/shifts.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user