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:
21
.env.example
21
.env.example
@@ -1,19 +1,30 @@
|
|||||||
## General
|
## General
|
||||||
APP_NAME="Nuxt Pocketbase Starter"
|
APP_NAME="ShiftCraft"
|
||||||
APP_ID="com.pocketbase.nuxt"
|
APP_ID="app.shiftcraft"
|
||||||
NODE_ENV="development"
|
NODE_ENV="development"
|
||||||
|
|
||||||
|
|
||||||
## Frontend
|
## Frontend
|
||||||
NUXT_PUBLIC_APP_URL="http://localhost:3000"
|
NUXT_PUBLIC_APP_URL="http://localhost:3000"
|
||||||
|
NUXT_PUBLIC_PB_URL="http://127.0.0.1:8090"
|
||||||
NUXT_PUBLIC_POCKETBASE_URL="http://127.0.0.1:8090"
|
NUXT_PUBLIC_POCKETBASE_URL="http://127.0.0.1:8090"
|
||||||
NUXT_PUBLIC_RYBBIT_URL="" # e.g. https://app.rybbit.io
|
NUXT_PUBLIC_RYBBIT_URL="" # e.g. https://app.rybbit.io
|
||||||
NUXT_PUBLIC_RYBBIT_SITE_ID="" # Your Rybbit site ID
|
NUXT_PUBLIC_RYBBIT_SITE_ID="" # Your Rybbit site ID
|
||||||
|
|
||||||
|
|
||||||
## Backend
|
## Backend (PocketBase admin)
|
||||||
SUPERUSER_EMAIL="mail@example.com"
|
PB_ADMIN_EMAIL="admin@shiftcraft.app"
|
||||||
SUPERUSER_PW="!234Qwer"
|
PB_ADMIN_PASSWORD="changeme123"
|
||||||
|
SUPERUSER_EMAIL="admin@shiftcraft.app"
|
||||||
|
SUPERUSER_PW="changeme123"
|
||||||
|
|
||||||
|
## AI (Anthropic Claude — required for constraint parsing)
|
||||||
|
ANTHROPIC_API_KEY="sk-ant-..."
|
||||||
|
|
||||||
|
## Payments (Stripe — optional)
|
||||||
|
STRIPE_SECRET_KEY=""
|
||||||
|
STRIPE_PUBLISHABLE_KEY=""
|
||||||
|
STRIPE_WEBHOOK_SECRET=""
|
||||||
|
|
||||||
AUTH_GOOGLE_CLIENT_ID="<client-id>"
|
AUTH_GOOGLE_CLIENT_ID="<client-id>"
|
||||||
AUTH_GOOGLE_CLIENT_SECRET="<client-secret>"
|
AUTH_GOOGLE_CLIENT_SECRET="<client-secret>"
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export default defineAppConfig({
|
export default defineAppConfig({
|
||||||
ui: {
|
ui: {
|
||||||
colors: {
|
colors: {
|
||||||
primary: 'teal',
|
primary: 'indigo',
|
||||||
neutral: 'slate'
|
neutral: 'slate'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
37
app/composables/useOrg.ts
Normal file
37
app/composables/useOrg.ts
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
import type { Organization } from '~/shared/types/pocketbase'
|
||||||
|
|
||||||
|
export const useOrg = defineStore('org', {
|
||||||
|
state: () => ({
|
||||||
|
org: null as Organization | null,
|
||||||
|
loading: false,
|
||||||
|
}),
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
plan: (state) => state.org?.plan ?? 'free',
|
||||||
|
orgId: (state) => state.org?.id ?? null,
|
||||||
|
},
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
async fetchOrg(orgId: string) {
|
||||||
|
const { pb } = usePocketBase()
|
||||||
|
this.loading = true
|
||||||
|
try {
|
||||||
|
this.org = await pb.collection('organizations').getOne(orgId) as unknown as Organization
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to fetch org', err)
|
||||||
|
} finally {
|
||||||
|
this.loading = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async updateOrg(data: Partial<Organization>) {
|
||||||
|
if (!this.org) return
|
||||||
|
const { pb } = usePocketBase()
|
||||||
|
this.org = await pb.collection('organizations').update(this.org.id, data) as unknown as Organization
|
||||||
|
},
|
||||||
|
|
||||||
|
clearOrg() {
|
||||||
|
this.org = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
20
app/composables/useSubscription.ts
Normal file
20
app/composables/useSubscription.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { PLAN_LIMITS } from '~/app/utils/planLimits'
|
||||||
|
import type { PlanTier } from '~/app/utils/planLimits'
|
||||||
|
|
||||||
|
export const useSubscription = () => {
|
||||||
|
const orgStore = useOrg()
|
||||||
|
|
||||||
|
const plan = computed<PlanTier>(() => (orgStore.plan as PlanTier) || 'free')
|
||||||
|
const limits = computed(() => PLAN_LIMITS[plan.value])
|
||||||
|
|
||||||
|
const canAddEmployee = (currentCount: number) => {
|
||||||
|
const limit = limits.value.employee_limit
|
||||||
|
return limit === Infinity || currentCount < limit
|
||||||
|
}
|
||||||
|
|
||||||
|
const canExportPDF = computed(() => limits.value.pdf_export)
|
||||||
|
const canExportExcel = computed(() => limits.value.excel_export)
|
||||||
|
const isFreePlan = computed(() => plan.value === 'free')
|
||||||
|
|
||||||
|
return { plan, limits, canAddEmployee, canExportPDF, canExportExcel, isFreePlan }
|
||||||
|
}
|
||||||
15
app/layouts/auth.vue
Normal file
15
app/layouts/auth.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-gradient-to-br from-indigo-50 via-white to-violet-50 flex items-center justify-center px-4">
|
||||||
|
<div class="w-full max-w-md">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="text-center mb-8">
|
||||||
|
<div class="w-12 h-12 rounded-2xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center mx-auto mb-4">
|
||||||
|
<span class="text-white font-bold text-xl">S</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">ShiftCraft</h1>
|
||||||
|
<p class="text-gray-500 text-sm mt-1">Schichtplanung, die Ihre Sprache spricht</p>
|
||||||
|
</div>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,11 +1,138 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-gray-50 flex">
|
||||||
<AppHeader />
|
<!-- Sidebar -->
|
||||||
|
<aside class="hidden lg:flex flex-col w-64 bg-white border-r border-gray-100 fixed inset-y-0 z-30">
|
||||||
|
<!-- Logo -->
|
||||||
|
<div class="h-16 flex items-center gap-3 px-6 border-b border-gray-100">
|
||||||
|
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-indigo-500 to-violet-600 flex items-center justify-center shrink-0">
|
||||||
|
<span class="text-white font-bold text-sm">S</span>
|
||||||
|
</div>
|
||||||
|
<span class="font-bold text-gray-900 text-lg">ShiftCraft</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<UMain>
|
<!-- Nav links -->
|
||||||
<UContainer class="py-8">
|
<nav class="flex-1 px-3 py-4 space-y-1 overflow-y-auto">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all duration-150"
|
||||||
|
:class="isActive(item.to) ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50 hover:text-gray-900'"
|
||||||
|
>
|
||||||
|
<UIcon :name="item.icon" class="w-5 h-5 shrink-0" />
|
||||||
|
{{ item.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Bottom: plan badge + user -->
|
||||||
|
<div class="px-3 py-4 border-t border-gray-100 space-y-3">
|
||||||
|
<div class="px-3 py-2 rounded-xl bg-indigo-50 border border-indigo-100 flex items-center gap-2">
|
||||||
|
<span class="text-indigo-600 text-xs font-semibold uppercase tracking-wide">{{ planLabel }}</span>
|
||||||
|
<span class="ml-auto text-indigo-400">
|
||||||
|
<UIcon v-if="plan !== 'free'" name="i-lucide-zap" class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<NuxtLink to="/settings/billing" class="block px-3 py-2 rounded-xl text-xs text-gray-500 hover:bg-gray-50 transition-colors" v-if="plan === 'free'">
|
||||||
|
Auf Pro upgraden →
|
||||||
|
</NuxtLink>
|
||||||
|
<div class="flex items-center gap-3 px-3 py-2">
|
||||||
|
<div class="w-8 h-8 rounded-full bg-indigo-100 flex items-center justify-center shrink-0">
|
||||||
|
<span class="text-indigo-700 text-sm font-medium">{{ userInitial }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">{{ userName }}</p>
|
||||||
|
</div>
|
||||||
|
<UButton icon="i-lucide-log-out" color="neutral" variant="ghost" size="xs" @click="handleSignOut" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Mobile header -->
|
||||||
|
<div class="lg:hidden fixed top-0 inset-x-0 z-30 h-14 bg-white border-b border-gray-100 flex items-center justify-between px-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>
|
||||||
|
<UButton icon="i-lucide-menu" color="neutral" variant="ghost" @click="mobileOpen = true" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile drawer -->
|
||||||
|
<USlideover v-model:open="mobileOpen" side="left" class="lg:hidden">
|
||||||
|
<div class="flex flex-col h-full bg-white">
|
||||||
|
<div class="h-14 flex items-center gap-3 px-6 border-b border-gray-100">
|
||||||
|
<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>
|
||||||
|
<UButton class="ml-auto" icon="i-lucide-x" color="neutral" variant="ghost" @click="mobileOpen = false" />
|
||||||
|
</div>
|
||||||
|
<nav class="flex-1 px-3 py-4 space-y-1">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="item in navItems"
|
||||||
|
:key="item.to"
|
||||||
|
:to="item.to"
|
||||||
|
class="flex items-center gap-3 px-3 py-2.5 rounded-xl text-sm font-medium transition-all"
|
||||||
|
:class="isActive(item.to) ? 'bg-indigo-50 text-indigo-700' : 'text-gray-600 hover:bg-gray-50'"
|
||||||
|
@click="mobileOpen = false"
|
||||||
|
>
|
||||||
|
<UIcon :name="item.icon" class="w-5 h-5 shrink-0" />
|
||||||
|
{{ item.label }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</USlideover>
|
||||||
|
|
||||||
|
<!-- Main content -->
|
||||||
|
<main class="flex-1 lg:ml-64 pt-14 lg:pt-0">
|
||||||
|
<div class="p-6 lg:p-8 max-w-7xl mx-auto">
|
||||||
<slot />
|
<slot />
|
||||||
</UContainer>
|
</div>
|
||||||
</UMain>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const { signOut, user } = useUser()
|
||||||
|
const orgStore = useOrg()
|
||||||
|
const { plan } = useSubscription()
|
||||||
|
|
||||||
|
const mobileOpen = ref(false)
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/dashboard', label: 'Dashboard', icon: 'i-lucide-layout-dashboard' },
|
||||||
|
{ to: '/employees', label: 'Mitarbeiter', icon: 'i-lucide-users' },
|
||||||
|
{ to: '/constraints', label: 'Bedingungen', icon: 'i-lucide-sliders' },
|
||||||
|
{ to: '/schedules', label: 'Schichtpläne', icon: 'i-lucide-calendar' },
|
||||||
|
{ to: '/settings/organization', label: 'Einstellungen', icon: 'i-lucide-settings' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const planLabel = computed(() => {
|
||||||
|
if (plan.value === 'free') return 'Free Plan'
|
||||||
|
if (plan.value === 'pro') return 'Pro Plan'
|
||||||
|
return 'Business Plan'
|
||||||
|
})
|
||||||
|
|
||||||
|
const userName = computed(() => user?.name || user?.email || 'Nutzer')
|
||||||
|
const userInitial = computed(() => (userName.value || 'N').charAt(0).toUpperCase())
|
||||||
|
|
||||||
|
function isActive(path: string) {
|
||||||
|
return route.path.startsWith(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSignOut() {
|
||||||
|
signOut()
|
||||||
|
await navigateTo('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load org on mount
|
||||||
|
onMounted(async () => {
|
||||||
|
const { authStore } = usePocketBase()
|
||||||
|
const orgId = authStore.value.record?.org_id as string | undefined
|
||||||
|
if (orgId && !orgStore.org) {
|
||||||
|
await orgStore.fetchOrg(orgId)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div>
|
<div class="min-h-screen bg-white">
|
||||||
<UPageHero
|
<!-- Navigation -->
|
||||||
:title="$t('hero.title')"
|
<nav class="fixed top-0 w-full z-50 bg-white/80 backdrop-blur-xl border-b border-gray-100">
|
||||||
:description="$t('hero.description')"
|
<div class="max-w-7xl mx-auto px-6 h-16 flex items-center justify-between">
|
||||||
:links="links"
|
<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">
|
||||||
<div class="flex justify-center mt-8">
|
<span class="text-white font-bold text-sm">S</span>
|
||||||
<CounterWidget v-if="isAuthenticated" />
|
</div>
|
||||||
<UEmpty
|
<span class="font-bold text-gray-900 text-lg">ShiftCraft</span>
|
||||||
v-else
|
</div>
|
||||||
icon="i-lucide-user-x"
|
<div class="hidden md:flex items-center gap-8">
|
||||||
:title="$t('counter.notAuthenticated')"
|
<a href="#features" class="text-gray-600 hover:text-gray-900 text-sm font-medium transition-colors">Features</a>
|
||||||
:description="$t('counter.signInToUse')"
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<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(() => [
|
const features = [
|
||||||
{
|
{ icon: '💬', title: 'Einfach eingeben', description: 'Keine komplizierten Formulare. Schreiben Sie einfach, was Sie wollen — wie in einer Nachricht.', bgColor: 'bg-indigo-50' },
|
||||||
label: t('hero.signUp'),
|
{ icon: '⚡', title: 'Automatisch optimiert', description: 'Unser intelligentes System erstellt den bestmöglichen, fairen Plan in Sekunden — nicht Stunden.', bgColor: 'bg-violet-50' },
|
||||||
to: '/login',
|
{ icon: '⚖️', title: 'Rechtlich sicher', description: 'Gesetzliche Vorlagen für Deutschland, Österreich und die Schweiz sind bereits eingebaut.', bgColor: 'bg-emerald-50' },
|
||||||
icon: 'i-lucide-log-in'
|
{ 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' },
|
||||||
label: t('hero.profile'),
|
]
|
||||||
to: '/profile',
|
|
||||||
color: 'neutral',
|
const steps = [
|
||||||
variant: 'subtle',
|
{ icon: '🗓️', title: 'Rahmen festlegen', description: 'Definieren Sie Ihre Schichtzeiten und wie viele Mitarbeiter pro Schicht benötigt werden.' },
|
||||||
trailingIcon: 'i-lucide-arrow-right'
|
{ 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.' },
|
||||||
] as ButtonProps[])
|
]
|
||||||
|
|
||||||
|
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>
|
</script>
|
||||||
|
|||||||
@@ -1,110 +1,65 @@
|
|||||||
<template>
|
<template>
|
||||||
<UAuthForm
|
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8">
|
||||||
:fields="fields"
|
<h2 class="text-2xl font-bold text-gray-900 mb-2">Willkommen zurück</h2>
|
||||||
:schema="schema"
|
<p class="text-gray-500 mb-6 text-sm">Melden Sie sich in Ihrem ShiftCraft-Konto an</p>
|
||||||
:providers="providers"
|
|
||||||
:title="$t('login.title')"
|
<UForm :schema="schema" :state="formState" @submit="onSubmit" class="space-y-4">
|
||||||
icon="i-lucide-lock"
|
<UFormField label="E-Mail" name="email">
|
||||||
@submit="onSubmit($event)"
|
<UInput v-model="formState.email" type="email" placeholder="name@firma.de" class="w-full" />
|
||||||
>
|
</UFormField>
|
||||||
<template #footer>
|
<UFormField label="Passwort" name="password">
|
||||||
{{ $t('login.agree') }}
|
<UInput v-model="formState.password" type="password" placeholder="••••••••" class="w-full" />
|
||||||
<ULink
|
</UFormField>
|
||||||
to="/"
|
|
||||||
class="text-primary font-medium"
|
<UButton type="submit" color="primary" class="w-full justify-center" :loading="loading" size="lg">
|
||||||
>
|
Anmelden
|
||||||
{{ $t('login.terms') }}
|
</UButton>
|
||||||
</ULink>.
|
</UForm>
|
||||||
</template>
|
|
||||||
</UAuthForm>
|
<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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import * as z from 'zod'
|
import * as z from 'zod'
|
||||||
import type { FormSubmitEvent } from '@nuxt/ui'
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
import type { AuthFormField } from '@nuxt/ui/runtime/components/AuthForm.vue.js'
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({ layout: 'auth', middleware: 'guest' })
|
||||||
layout: 'empty'
|
|
||||||
})
|
|
||||||
|
|
||||||
const { t } = useI18n()
|
|
||||||
const toast = useToast()
|
const toast = useToast()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
// The otpId is used to track the OTP request and verify the user
|
const formState = reactive({
|
||||||
// null if no request was sent yet
|
email: '',
|
||||||
const otpId = ref<string | null>(null)
|
password: '',
|
||||||
|
|
||||||
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 { 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({
|
const schema = z.object({
|
||||||
email: z.email(t('login.invalidEmail')),
|
email: z.email('Ungültige E-Mail-Adresse'),
|
||||||
otp: z.array(z.string()).optional().transform(val => val?.join(''))
|
password: z.string().min(1, 'Passwort ist erforderlich'),
|
||||||
})
|
})
|
||||||
|
|
||||||
type Schema = z.output<typeof schema>
|
type Schema = z.output<typeof schema>
|
||||||
|
|
||||||
const { locale } = useI18n()
|
|
||||||
async function onSubmit(payload: FormSubmitEvent<Schema>) {
|
async function onSubmit(payload: FormSubmitEvent<Schema>) {
|
||||||
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
if (otpId.value !== null && payload.data.otp?.length) {
|
const { pb } = usePocketBase()
|
||||||
// If OTP is provided, sign in with OTP
|
const authData = await pb.collection('users').authWithPassword(payload.data.email, payload.data.password)
|
||||||
await signInWithOtp(otpId.value, payload.data.otp)
|
// Load org
|
||||||
navigateTo('/confirm')
|
const orgStore = useOrg()
|
||||||
} else {
|
const orgId = (authData.record as { org_id?: string }).org_id
|
||||||
// If OTP is not provided, sign in with email (send OTP)
|
if (orgId) await orgStore.fetchOrg(orgId)
|
||||||
otpId.value = await signInWithEmail(payload.data.email, locale.value)
|
await navigateTo('/dashboard')
|
||||||
toast.add({
|
} catch {
|
||||||
title: t('login.emailSent'),
|
toast.add({ color: 'error', title: 'Anmeldung fehlgeschlagen', description: 'E-Mail oder Passwort ist falsch.' })
|
||||||
description: t('login.emailSentDescription'),
|
} finally {
|
||||||
icon: 'i-lucide-check',
|
loading.value = false
|
||||||
color: 'success'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
toast.add({ color: 'error', description: (error as Error).message })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
31
app/utils/constraintDisplay.ts
Normal file
31
app/utils/constraintDisplay.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import type { ConstraintJSON } from '~/shared/types/constraint'
|
||||||
|
|
||||||
|
export function constraintToHuman(constraint: ConstraintJSON): string {
|
||||||
|
return constraint.natural_language_summary || formatConstraintFallback(constraint)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatConstraintFallback(c: ConstraintJSON): string {
|
||||||
|
const params = c.params as Record<string, unknown>
|
||||||
|
switch (c.type) {
|
||||||
|
case 'max_hours_per_day':
|
||||||
|
return `Maximal ${params.max_hours} Stunden pro Tag`
|
||||||
|
case 'max_hours_per_week':
|
||||||
|
return `Maximal ${params.max_hours} Stunden pro Woche`
|
||||||
|
case 'min_rest_between_shifts':
|
||||||
|
return `Mindestens ${params.min_hours} Stunden Ruhezeit zwischen Schichten`
|
||||||
|
case 'max_consecutive_shifts':
|
||||||
|
return `Maximal ${params.max_count} Schichten am Stück`
|
||||||
|
case 'max_consecutive_shift_type':
|
||||||
|
return `Maximal ${params.max_count} ${params.period_id}-Schichten am Stück`
|
||||||
|
case 'forbidden_shift_sequence':
|
||||||
|
return `${params.first_period_id}-Schicht darf nicht direkt auf ${params.second_period_id}-Schicht folgen`
|
||||||
|
case 'employee_avoids_period':
|
||||||
|
return `Mitarbeiter bevorzugt keine ${params.period_id}-Schichten`
|
||||||
|
case 'employee_prefers_period':
|
||||||
|
return `Mitarbeiter bevorzugt ${params.period_id}-Schichten`
|
||||||
|
case 'fair_distribution':
|
||||||
|
return `Faire Verteilung der ${params.metric === 'night_shifts' ? 'Nachtschichten' : 'Schichten'} (max. ${params.max_deviation_percent}% Abweichung)`
|
||||||
|
default:
|
||||||
|
return c.type.replace(/_/g, ' ')
|
||||||
|
}
|
||||||
|
}
|
||||||
42
app/utils/dateHelpers.ts
Normal file
42
app/utils/dateHelpers.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
export function formatDate(date: string | Date): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { dateStyle: 'medium' }).format(new Date(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateShort(date: string | Date): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { day: '2-digit', month: '2-digit' }).format(new Date(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getDaysInRange(start: string, end: string): string[] {
|
||||||
|
const days: string[] = []
|
||||||
|
const current = new Date(start)
|
||||||
|
const endDate = new Date(end)
|
||||||
|
while (current <= endDate) {
|
||||||
|
days.push(current.toISOString().split('T')[0])
|
||||||
|
current.setDate(current.getDate() + 1)
|
||||||
|
}
|
||||||
|
return days
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekday(date: string): number {
|
||||||
|
const d = new Date(date)
|
||||||
|
return (d.getDay() + 6) % 7 // 0=Mon, 6=Sun
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeekend(date: string): boolean {
|
||||||
|
const day = getWeekday(date)
|
||||||
|
return day === 5 || day === 6
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: string, days: number): string {
|
||||||
|
const d = new Date(date)
|
||||||
|
d.setDate(d.getDate() + days)
|
||||||
|
return d.toISOString().split('T')[0]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWeekdayName(date: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { weekday: 'short' }).format(new Date(date))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMonthName(date: string): string {
|
||||||
|
return new Intl.DateTimeFormat('de-DE', { month: 'long', year: 'numeric' }).format(new Date(date))
|
||||||
|
}
|
||||||
67
app/utils/planLimits.ts
Normal file
67
app/utils/planLimits.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
export const PLAN_LIMITS = {
|
||||||
|
free: {
|
||||||
|
name: 'Free',
|
||||||
|
price_eur_month: 0,
|
||||||
|
employee_limit: 5,
|
||||||
|
history_months: 1,
|
||||||
|
solve_runs_per_month: 10,
|
||||||
|
ai_parses_per_month: 20,
|
||||||
|
legal_templates: true,
|
||||||
|
pdf_export: false,
|
||||||
|
excel_export: false,
|
||||||
|
support: 'community',
|
||||||
|
stripe_price_id: null,
|
||||||
|
description: 'Für kleine Teams zum Ausprobieren',
|
||||||
|
features: [
|
||||||
|
'Bis zu 5 Mitarbeiter',
|
||||||
|
'10 Schichtpläne/Monat',
|
||||||
|
'Gesetzliche Vorlagen (Deutschland)',
|
||||||
|
'KI-Bedingungserkennung',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
name: 'Pro',
|
||||||
|
price_eur_month: 29,
|
||||||
|
employee_limit: 25,
|
||||||
|
history_months: 6,
|
||||||
|
solve_runs_per_month: 100,
|
||||||
|
ai_parses_per_month: 200,
|
||||||
|
legal_templates: true,
|
||||||
|
pdf_export: true,
|
||||||
|
excel_export: true,
|
||||||
|
support: 'email',
|
||||||
|
stripe_price_id: 'price_pro_monthly',
|
||||||
|
description: 'Für wachsende Teams',
|
||||||
|
features: [
|
||||||
|
'Bis zu 25 Mitarbeiter',
|
||||||
|
'100 Schichtpläne/Monat',
|
||||||
|
'PDF & Excel Export',
|
||||||
|
'6 Monate Verlauf',
|
||||||
|
'E-Mail Support',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
business: {
|
||||||
|
name: 'Business',
|
||||||
|
price_eur_month: 99,
|
||||||
|
employee_limit: Infinity,
|
||||||
|
history_months: Infinity,
|
||||||
|
solve_runs_per_month: Infinity,
|
||||||
|
ai_parses_per_month: Infinity,
|
||||||
|
legal_templates: true,
|
||||||
|
pdf_export: true,
|
||||||
|
excel_export: true,
|
||||||
|
support: 'priority',
|
||||||
|
stripe_price_id: 'price_business_monthly',
|
||||||
|
description: 'Für große Unternehmen',
|
||||||
|
features: [
|
||||||
|
'Unbegrenzte Mitarbeiter',
|
||||||
|
'Unbegrenzte Schichtpläne',
|
||||||
|
'PDF & Excel Export',
|
||||||
|
'Unbegrenzter Verlauf',
|
||||||
|
'Priority Support',
|
||||||
|
'API-Zugang (demnächst)',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type PlanTier = keyof typeof PLAN_LIMITS
|
||||||
@@ -36,9 +36,15 @@ export default defineNuxtConfig({
|
|||||||
css: ['~/assets/css/main.css'],
|
css: ['~/assets/css/main.css'],
|
||||||
|
|
||||||
runtimeConfig: {
|
runtimeConfig: {
|
||||||
|
pbAdminEmail: process.env.PB_ADMIN_EMAIL || '',
|
||||||
|
pbAdminPassword: process.env.PB_ADMIN_PASSWORD || '',
|
||||||
|
anthropicApiKey: process.env.ANTHROPIC_API_KEY || '',
|
||||||
|
stripeSecretKey: process.env.STRIPE_SECRET_KEY || '',
|
||||||
|
stripeWebhookSecret: process.env.STRIPE_WEBHOOK_SECRET || '',
|
||||||
public: {
|
public: {
|
||||||
appUrl: 'http://localhost:3000',
|
appUrl: 'http://localhost:3000',
|
||||||
pocketbaseUrl: 'http://127.0.0.1:8090',
|
pocketbaseUrl: process.env.NUXT_PUBLIC_PB_URL || 'http://127.0.0.1:8090',
|
||||||
|
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY || '',
|
||||||
rybbitScriptUrl: '',
|
rybbitScriptUrl: '',
|
||||||
rybbitSiteId: ''
|
rybbitSiteId: ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
"capacitor:copy:after": "cp creds/fcm-google-services.json android/app/google-services.json"
|
"capacitor:copy:after": "cp creds/fcm-google-services.json android/app/google-services.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@anthropic-ai/sdk": "^0.90.0",
|
||||||
"@capacitor/android": "^8.3.1",
|
"@capacitor/android": "^8.3.1",
|
||||||
"@capacitor/app": "^8.1.0",
|
"@capacitor/app": "^8.1.0",
|
||||||
"@capacitor/core": "^8.3.1",
|
"@capacitor/core": "^8.3.1",
|
||||||
@@ -32,6 +33,7 @@
|
|||||||
"@nuxtjs/i18n": "10.2.4",
|
"@nuxtjs/i18n": "10.2.4",
|
||||||
"@pinia/nuxt": "0.11.3",
|
"@pinia/nuxt": "0.11.3",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
|
"highs": "^1.8.0",
|
||||||
"nuxt": "^4.4.2",
|
"nuxt": "^4.4.2",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
"tailwindcss": "^4.2.2",
|
"tailwindcss": "^4.2.2",
|
||||||
|
|||||||
46
pnpm-lock.yaml
generated
46
pnpm-lock.yaml
generated
@@ -8,6 +8,9 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
|
'@anthropic-ai/sdk':
|
||||||
|
specifier: ^0.90.0
|
||||||
|
version: 0.90.0(zod@4.3.6)
|
||||||
'@capacitor/android':
|
'@capacitor/android':
|
||||||
specifier: ^8.3.1
|
specifier: ^8.3.1
|
||||||
version: 8.3.1(@capacitor/core@8.3.1)
|
version: 8.3.1(@capacitor/core@8.3.1)
|
||||||
@@ -44,6 +47,9 @@ importers:
|
|||||||
dotenv:
|
dotenv:
|
||||||
specifier: ^17.4.2
|
specifier: ^17.4.2
|
||||||
version: 17.4.2
|
version: 17.4.2
|
||||||
|
highs:
|
||||||
|
specifier: ^1.8.0
|
||||||
|
version: 1.8.0
|
||||||
nuxt:
|
nuxt:
|
||||||
specifier: ^4.4.2
|
specifier: ^4.4.2
|
||||||
version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.32)(cac@6.7.14)(db0@0.3.4(sqlite3@5.1.7))(encoding@0.1.13)(eslint@10.2.0(jiti@2.6.1))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.32(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.1))(rollup@4.60.1)(sqlite3@5.1.7)(srvx@0.11.15)(terser@5.46.1)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@6.0.3))(xml2js@0.6.2)(yaml@2.8.3)
|
version: 4.4.2(@babel/core@7.29.0)(@babel/plugin-syntax-jsx@7.28.6(@babel/core@7.29.0))(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(@parcel/watcher@2.5.6)(@types/node@25.6.0)(@vue/compiler-sfc@3.5.32)(cac@6.7.14)(db0@0.3.4(sqlite3@5.1.7))(encoding@0.1.13)(eslint@10.2.0(jiti@2.6.1))(ioredis@5.10.1)(lightningcss@1.32.0)(magicast@0.5.2)(optionator@0.9.4)(pinia@3.0.4(typescript@6.0.3)(vue@3.5.32(typescript@6.0.3)))(rollup-plugin-visualizer@7.0.1(rollup@4.60.1))(rollup@4.60.1)(sqlite3@5.1.7)(srvx@0.11.15)(terser@5.46.1)(typescript@6.0.3)(vite@7.3.2(@types/node@25.6.0)(jiti@2.6.1)(lightningcss@1.32.0)(terser@5.46.1)(yaml@2.8.3))(vue-tsc@3.2.6(typescript@6.0.3))(xml2js@0.6.2)(yaml@2.8.3)
|
||||||
@@ -106,6 +112,15 @@ packages:
|
|||||||
'@antfu/install-pkg@1.1.0':
|
'@antfu/install-pkg@1.1.0':
|
||||||
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
resolution: {integrity: sha512-MGQsmw10ZyI+EJo45CdSER4zEb+p31LpDAFp2Z3gkSd1yqVZGi0Ebx++YTEMonJy4oChEMLsxZ64j8FH6sSqtQ==}
|
||||||
|
|
||||||
|
'@anthropic-ai/sdk@0.90.0':
|
||||||
|
resolution: {integrity: sha512-MzZtPabJF1b0FTDl6Z6H5ljphPwACLGP13lu8MTiB8jXaW/YXlpOp+Po2cVou3MPM5+f5toyLnul9whKCy7fBg==}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
zod: ^3.25.0 || ^4.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
zod:
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@apidevtools/json-schema-ref-parser@14.2.1':
|
'@apidevtools/json-schema-ref-parser@14.2.1':
|
||||||
resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==}
|
resolution: {integrity: sha512-HmdFw9CDYqM6B25pqGBpNeLCKvGPlIx1EbLrVL0zPvj50CJQUHyBNBw45Muk0kEIkogo1VZvOKHajdMuAzSxRg==}
|
||||||
engines: {node: '>= 20'}
|
engines: {node: '>= 20'}
|
||||||
@@ -217,6 +232,10 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
'@babel/core': ^7.0.0-0
|
'@babel/core': ^7.0.0-0
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.2':
|
||||||
|
resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==}
|
||||||
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
resolution: {integrity: sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
@@ -4383,6 +4402,9 @@ packages:
|
|||||||
hey-listen@1.0.8:
|
hey-listen@1.0.8:
|
||||||
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
resolution: {integrity: sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==}
|
||||||
|
|
||||||
|
highs@1.8.0:
|
||||||
|
resolution: {integrity: sha512-0QFXXjU/mxU1y3Ec44QpESq6STkPauNDfIPf0welcUGjilpVYnfH9RepBvr0t9YDQPUniIcZTX9xyxqxF668ag==}
|
||||||
|
|
||||||
hookable@5.5.3:
|
hookable@5.5.3:
|
||||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||||
|
|
||||||
@@ -4625,6 +4647,10 @@ packages:
|
|||||||
json-buffer@3.0.1:
|
json-buffer@3.0.1:
|
||||||
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==}
|
||||||
|
|
||||||
|
json-schema-to-ts@3.1.1:
|
||||||
|
resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==}
|
||||||
|
engines: {node: '>=16'}
|
||||||
|
|
||||||
json-schema-to-typescript-lite@15.0.0:
|
json-schema-to-typescript-lite@15.0.0:
|
||||||
resolution: {integrity: sha512-5mMORSQm9oTLyjM4mWnyNBi2T042Fhg1/0gCIB6X8U/LVpM2A+Nmj2yEyArqVouDmFThDxpEXcnTgSrjkGJRFA==}
|
resolution: {integrity: sha512-5mMORSQm9oTLyjM4mWnyNBi2T042Fhg1/0gCIB6X8U/LVpM2A+Nmj2yEyArqVouDmFThDxpEXcnTgSrjkGJRFA==}
|
||||||
|
|
||||||
@@ -6116,6 +6142,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
ts-algebra@2.0.0:
|
||||||
|
resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==}
|
||||||
|
|
||||||
ts-api-utils@2.5.0:
|
ts-api-utils@2.5.0:
|
||||||
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
resolution: {integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==}
|
||||||
engines: {node: '>=18.12'}
|
engines: {node: '>=18.12'}
|
||||||
@@ -6726,6 +6755,12 @@ snapshots:
|
|||||||
package-manager-detector: 1.6.0
|
package-manager-detector: 1.6.0
|
||||||
tinyexec: 1.1.1
|
tinyexec: 1.1.1
|
||||||
|
|
||||||
|
'@anthropic-ai/sdk@0.90.0(zod@4.3.6)':
|
||||||
|
dependencies:
|
||||||
|
json-schema-to-ts: 3.1.1
|
||||||
|
optionalDependencies:
|
||||||
|
zod: 4.3.6
|
||||||
|
|
||||||
'@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)':
|
'@apidevtools/json-schema-ref-parser@14.2.1(@types/json-schema@7.0.15)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/json-schema': 7.0.15
|
'@types/json-schema': 7.0.15
|
||||||
@@ -6875,6 +6910,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
'@babel/runtime@7.29.2': {}
|
||||||
|
|
||||||
'@babel/template@7.28.6':
|
'@babel/template@7.28.6':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.0
|
'@babel/code-frame': 7.29.0
|
||||||
@@ -11150,6 +11187,8 @@ snapshots:
|
|||||||
|
|
||||||
hey-listen@1.0.8: {}
|
hey-listen@1.0.8: {}
|
||||||
|
|
||||||
|
highs@1.8.0: {}
|
||||||
|
|
||||||
hookable@5.5.3: {}
|
hookable@5.5.3: {}
|
||||||
|
|
||||||
hookable@6.1.0: {}
|
hookable@6.1.0: {}
|
||||||
@@ -11405,6 +11444,11 @@ snapshots:
|
|||||||
|
|
||||||
json-buffer@3.0.1: {}
|
json-buffer@3.0.1: {}
|
||||||
|
|
||||||
|
json-schema-to-ts@3.1.1:
|
||||||
|
dependencies:
|
||||||
|
'@babel/runtime': 7.29.2
|
||||||
|
ts-algebra: 2.0.0
|
||||||
|
|
||||||
json-schema-to-typescript-lite@15.0.0:
|
json-schema-to-typescript-lite@15.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15)
|
'@apidevtools/json-schema-ref-parser': 14.2.1(@types/json-schema@7.0.15)
|
||||||
@@ -13345,6 +13389,8 @@ snapshots:
|
|||||||
|
|
||||||
tree-kill@1.2.2: {}
|
tree-kill@1.2.2: {}
|
||||||
|
|
||||||
|
ts-algebra@2.0.0: {}
|
||||||
|
|
||||||
ts-api-utils@2.5.0(typescript@6.0.3):
|
ts-api-utils@2.5.0(typescript@6.0.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
typescript: 6.0.3
|
typescript: 6.0.3
|
||||||
|
|||||||
248
scripts/setup-pb.ts
Normal file
248
scripts/setup-pb.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env npx tsx
|
||||||
|
/**
|
||||||
|
* ShiftCraft PocketBase Setup Script
|
||||||
|
*
|
||||||
|
* Creates all required collections and seeds legal templates.
|
||||||
|
* Run after starting PocketBase:
|
||||||
|
* pnpm pocketbase:start
|
||||||
|
* npx tsx scripts/setup-pb.ts
|
||||||
|
*/
|
||||||
|
|
||||||
|
import PocketBase from 'pocketbase'
|
||||||
|
|
||||||
|
const PB_URL = process.env.NUXT_PUBLIC_PB_URL || process.env.NUXT_PUBLIC_POCKETBASE_URL || 'http://localhost:8090'
|
||||||
|
const PB_ADMIN_EMAIL = process.env.PB_ADMIN_EMAIL || process.env.SUPERUSER_EMAIL || 'admin@shiftcraft.app'
|
||||||
|
const PB_ADMIN_PASSWORD = process.env.PB_ADMIN_PASSWORD || process.env.SUPERUSER_PW || 'changeme123'
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const pb = new PocketBase(PB_URL)
|
||||||
|
|
||||||
|
console.log(`Connecting to PocketBase at ${PB_URL}...`)
|
||||||
|
await pb.admins.authWithPassword(PB_ADMIN_EMAIL, PB_ADMIN_PASSWORD)
|
||||||
|
console.log('Connected to PocketBase admin')
|
||||||
|
|
||||||
|
const collections = [
|
||||||
|
{
|
||||||
|
name: 'organizations',
|
||||||
|
type: 'base',
|
||||||
|
schema: [
|
||||||
|
{ name: 'name', type: 'text', required: true },
|
||||||
|
{ name: 'slug', type: 'text', required: true },
|
||||||
|
{ name: 'timezone', type: 'text' },
|
||||||
|
{ name: 'industry', type: 'text' },
|
||||||
|
{ name: 'owner', type: 'text' },
|
||||||
|
{ name: 'plan', type: 'select', options: { values: ['free', 'pro', 'business'], maxSelect: 1 } },
|
||||||
|
{ name: 'plan_employee_limit', type: 'number' },
|
||||||
|
{ name: 'plan_history_months', type: 'number' },
|
||||||
|
{ name: 'stripe_customer_id', type: 'text' },
|
||||||
|
{ name: 'stripe_subscription_id', type: 'text' },
|
||||||
|
{ name: 'stripe_subscription_status', type: 'text' },
|
||||||
|
{ name: 'trial_ends_at', type: 'date' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'employees',
|
||||||
|
type: 'base',
|
||||||
|
schema: [
|
||||||
|
{ name: 'org_id', type: 'text', required: true },
|
||||||
|
{ name: 'name', type: 'text', required: true },
|
||||||
|
{ name: 'email', type: 'email' },
|
||||||
|
{ name: 'employee_number', type: 'text' },
|
||||||
|
{ name: 'roles', type: 'json' },
|
||||||
|
{ name: 'skills', type: 'json' },
|
||||||
|
{ name: 'employment_type', type: 'select', options: { values: ['full_time', 'part_time', 'mini_job'], maxSelect: 1 } },
|
||||||
|
{ name: 'weekly_hours_target', type: 'number' },
|
||||||
|
{ name: 'max_weekly_hours', type: 'number' },
|
||||||
|
{ name: 'available_periods', type: 'json' },
|
||||||
|
{ name: 'unavailable_dates', type: 'json' },
|
||||||
|
{ name: 'notes', type: 'text' },
|
||||||
|
{ name: 'active', type: 'bool' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'shift_frameworks',
|
||||||
|
type: 'base',
|
||||||
|
schema: [
|
||||||
|
{ name: 'org_id', type: 'text', required: true },
|
||||||
|
{ name: 'periods', type: 'json' },
|
||||||
|
{ name: 'shifts', type: 'json' },
|
||||||
|
{ name: 'scheduling_horizon_days', type: 'number' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'constraints',
|
||||||
|
type: 'base',
|
||||||
|
schema: [
|
||||||
|
{ name: 'org_id', type: 'text', required: true },
|
||||||
|
{ name: 'label', type: 'text', required: true },
|
||||||
|
{ name: 'source_text', type: 'text' },
|
||||||
|
{ name: 'constraint_json', type: 'json' },
|
||||||
|
{ name: 'scope', type: 'text' },
|
||||||
|
{ name: 'scope_ref', type: 'text' },
|
||||||
|
{ name: 'category', type: 'select', options: { values: ['legal', 'preference', 'operational', 'other'], maxSelect: 1 } },
|
||||||
|
{ name: 'hard', type: 'bool' },
|
||||||
|
{ name: 'weight', type: 'number' },
|
||||||
|
{ name: 'active', type: 'bool' },
|
||||||
|
{ name: 'source', type: 'select', options: { values: ['ai', 'legal', 'manual'], maxSelect: 1 } },
|
||||||
|
{ name: 'template_id', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'legal_templates',
|
||||||
|
type: 'base',
|
||||||
|
schema: [
|
||||||
|
{ name: 'region', type: 'text' },
|
||||||
|
{ name: 'law_name', type: 'text' },
|
||||||
|
{ name: 'label', type: 'text', required: true },
|
||||||
|
{ name: 'description', type: 'text' },
|
||||||
|
{ name: 'constraint_json', type: 'json' },
|
||||||
|
{ name: 'category', type: 'text' },
|
||||||
|
{ name: 'mandatory', type: 'bool' },
|
||||||
|
{ name: 'sort_order', type: 'number' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'schedule_runs',
|
||||||
|
type: 'base',
|
||||||
|
schema: [
|
||||||
|
{ name: 'org_id', type: 'text', required: true },
|
||||||
|
{ name: 'name', type: 'text', required: true },
|
||||||
|
{ name: 'period_start', type: 'date', required: true },
|
||||||
|
{ name: 'period_end', type: 'date', required: true },
|
||||||
|
{ name: 'framework_snapshot', type: 'json' },
|
||||||
|
{ name: 'constraints_snapshot', type: 'json' },
|
||||||
|
{ name: 'employees_snapshot', type: 'json' },
|
||||||
|
{ name: 'status', type: 'select', options: { values: ['pending', 'solving', 'solved', 'infeasible', 'error'], maxSelect: 1 } },
|
||||||
|
{ name: 'solver_duration_ms', type: 'number' },
|
||||||
|
{ name: 'objective_value', type: 'number' },
|
||||||
|
{ name: 'infeasibility_hints', type: 'json' },
|
||||||
|
{ name: 'result', type: 'json' },
|
||||||
|
{ name: 'created_by', type: 'text' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const col of collections) {
|
||||||
|
try {
|
||||||
|
await pb.collections.getOne(col.name)
|
||||||
|
console.log(` Collection '${col.name}' already exists — skipping`)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await pb.collections.create(col)
|
||||||
|
console.log(` Created collection '${col.name}'`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Failed to create '${col.name}':`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Seed legal templates
|
||||||
|
console.log('\nSeeding legal templates...')
|
||||||
|
const templates = [
|
||||||
|
{
|
||||||
|
region: 'Deutschland',
|
||||||
|
law_name: 'ArbZG §3',
|
||||||
|
label: 'Maximale Arbeitszeit 10 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)',
|
||||||
|
},
|
||||||
|
category: 'legal',
|
||||||
|
mandatory: true,
|
||||||
|
sort_order: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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)',
|
||||||
|
},
|
||||||
|
category: 'legal',
|
||||||
|
mandatory: true,
|
||||||
|
sort_order: 2,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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',
|
||||||
|
},
|
||||||
|
category: 'legal',
|
||||||
|
mandatory: false,
|
||||||
|
sort_order: 3,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
region: 'Deutschland',
|
||||||
|
law_name: 'Empfehlung',
|
||||||
|
label: 'Maximal 6 Schichten am Stück',
|
||||||
|
description: 'Empfohlene Begrenzung aufeinanderfolgender Arbeitstage.',
|
||||||
|
constraint_json: {
|
||||||
|
type: 'max_consecutive_shifts',
|
||||||
|
scope: { type: 'global' },
|
||||||
|
params: { max_count: 6 },
|
||||||
|
hard: false,
|
||||||
|
weight: 70,
|
||||||
|
natural_language_summary: 'Nicht mehr als 6 Schichten in Folge',
|
||||||
|
},
|
||||||
|
category: 'legal',
|
||||||
|
mandatory: false,
|
||||||
|
sort_order: 4,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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)',
|
||||||
|
},
|
||||||
|
category: 'preference',
|
||||||
|
mandatory: false,
|
||||||
|
sort_order: 5,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
for (const tmpl of templates) {
|
||||||
|
try {
|
||||||
|
await pb.collection('legal_templates').getFirstListItem(`label = "${tmpl.label}"`)
|
||||||
|
console.log(` Template '${tmpl.label}' already exists — skipping`)
|
||||||
|
} catch {
|
||||||
|
try {
|
||||||
|
await pb.collection('legal_templates').create(tmpl)
|
||||||
|
console.log(` Created template '${tmpl.label}'`)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(` Failed to create template '${tmpl.label}':`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nSetup complete!')
|
||||||
|
console.log('Next steps:')
|
||||||
|
console.log(' 1. Open PocketBase admin at http://localhost:8090/_/')
|
||||||
|
console.log(' 2. Configure the users collection to include org_id and role fields')
|
||||||
|
console.log(' 3. Run: pnpm dev')
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(console.error)
|
||||||
98
server/api/constraints/parse.post.ts
Normal file
98
server/api/constraints/parse.post.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { getAnthropicClient } from '~/server/utils/anthropic'
|
||||||
|
import { getPBAdminClient } from '~/server/utils/pb-admin'
|
||||||
|
import type { ParsedConstraintResult, ConstraintJSON } from '~/shared/types/constraint'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event): Promise<ParsedConstraintResult> => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { text, org_id } = body
|
||||||
|
|
||||||
|
if (!text || !org_id) {
|
||||||
|
throw createError({ statusCode: 400, message: 'text and org_id are required' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const pb = await getPBAdminClient()
|
||||||
|
|
||||||
|
// Fetch employees and framework for context
|
||||||
|
const [employeesResult, frameworkResult] = await Promise.allSettled([
|
||||||
|
pb.collection('employees').getFullList({ filter: `org_id = "${org_id}" && active = true`, fields: 'id,name,roles' }),
|
||||||
|
pb.collection('shift_frameworks').getFirstListItem(`org_id = "${org_id}"`),
|
||||||
|
])
|
||||||
|
|
||||||
|
const employees = employeesResult.status === 'fulfilled'
|
||||||
|
? employeesResult.value.map((e: { id: string; name: string; roles: string[] }) => ({ id: e.id, name: e.name, roles: e.roles }))
|
||||||
|
: []
|
||||||
|
|
||||||
|
const periods = frameworkResult.status === 'fulfilled'
|
||||||
|
? ((frameworkResult.value as { periods?: Array<{ id: string; name: string }> }).periods || [])
|
||||||
|
: []
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `You are a scheduling constraint parser for a workforce management application called ShiftCraft.
|
||||||
|
Given free-text input from a manager, extract one or more scheduling constraints and output them as a JSON array.
|
||||||
|
|
||||||
|
AVAILABLE EMPLOYEES: ${JSON.stringify(employees)}
|
||||||
|
AVAILABLE SHIFT PERIODS: ${JSON.stringify(periods)}
|
||||||
|
|
||||||
|
CONSTRAINT TYPES YOU CAN USE:
|
||||||
|
- max_hours_per_day: params: {max_hours: number}
|
||||||
|
- max_hours_per_week: params: {max_hours: number}
|
||||||
|
- min_rest_between_shifts: params: {min_hours: number}
|
||||||
|
- max_consecutive_shifts: params: {max_count: number}
|
||||||
|
- max_consecutive_shift_type: params: {period_id: string, max_count: number}
|
||||||
|
- min_consecutive_days_off: params: {min_days: number}
|
||||||
|
- forbidden_shift_sequence: params: {first_period_id: string, second_period_id: string}
|
||||||
|
- employee_avoids_period: params: {period_id: string}
|
||||||
|
- employee_prefers_period: params: {period_id: string, prefer_count_per_week?: number}
|
||||||
|
- max_weekend_shifts_per_month: params: {max_count: number}
|
||||||
|
- fair_distribution: params: {metric: "total_shifts"|"night_shifts"|"weekend_shifts", max_deviation_percent: number}
|
||||||
|
|
||||||
|
RULES:
|
||||||
|
- Preference language ("doesn't like", "prefers", "mag keine", "bevorzugt") → hard: false, weight: 65
|
||||||
|
- Obligation language ("must not", "cannot", "never", "darf nicht") → hard: true
|
||||||
|
- Resolve employee names to their IDs from the list above. If ambiguous, use scope: {type: "global"}.
|
||||||
|
- Always include natural_language_summary in the same language as the input.
|
||||||
|
- Output ONLY a valid JSON array. No commentary.
|
||||||
|
|
||||||
|
OUTPUT FORMAT:
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"type": "constraint_type",
|
||||||
|
"scope": {"type": "global"} | {"type": "employee", "employee_id": "..."} | {"type": "role", "role": "..."},
|
||||||
|
"params": {...},
|
||||||
|
"hard": true|false,
|
||||||
|
"weight": 1-100,
|
||||||
|
"natural_language_summary": "..."
|
||||||
|
}
|
||||||
|
]`
|
||||||
|
|
||||||
|
try {
|
||||||
|
const client = getAnthropicClient()
|
||||||
|
const response = await client.messages.create({
|
||||||
|
model: 'claude-opus-4-5',
|
||||||
|
max_tokens: 2048,
|
||||||
|
system: SYSTEM_PROMPT,
|
||||||
|
messages: [{ role: 'user', content: text }],
|
||||||
|
})
|
||||||
|
|
||||||
|
const content = response.content[0]
|
||||||
|
if (content.type !== 'text') throw new Error('Unexpected response type')
|
||||||
|
|
||||||
|
// Extract JSON from response
|
||||||
|
const jsonMatch = content.text.match(/\[[\s\S]*\]/)
|
||||||
|
if (!jsonMatch) throw new Error('No JSON array found in response')
|
||||||
|
|
||||||
|
const constraints: ConstraintJSON[] = JSON.parse(jsonMatch[0])
|
||||||
|
const ambiguities: string[] = []
|
||||||
|
|
||||||
|
// Validate basic structure
|
||||||
|
for (const c of constraints) {
|
||||||
|
if (!c.type || !c.scope || !c.params) {
|
||||||
|
ambiguities.push(`Unvollständige Bedingung erkannt: ${JSON.stringify(c)}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { constraints, ambiguities }
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Constraint parse error:', err)
|
||||||
|
throw createError({ statusCode: 500, message: `KI-Fehler: ${String(err)}` })
|
||||||
|
}
|
||||||
|
})
|
||||||
47
server/api/schedules/solve.post.ts
Normal file
47
server/api/schedules/solve.post.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import { getPBAdminClient } from '~/server/utils/pb-admin'
|
||||||
|
import { solveSchedule } from '~/server/utils/solver'
|
||||||
|
import type { SolveInput } from '~/shared/types/schedule'
|
||||||
|
|
||||||
|
export default defineEventHandler(async (event) => {
|
||||||
|
const body = await readBody(event)
|
||||||
|
const { run_id } = body
|
||||||
|
|
||||||
|
if (!run_id) throw createError({ statusCode: 400, message: 'run_id required' })
|
||||||
|
|
||||||
|
const pb = await getPBAdminClient()
|
||||||
|
|
||||||
|
// Fetch the schedule run
|
||||||
|
const run = await pb.collection('schedule_runs').getOne(run_id)
|
||||||
|
|
||||||
|
// Update status to solving
|
||||||
|
await pb.collection('schedule_runs').update(run_id, { status: 'solving' })
|
||||||
|
|
||||||
|
try {
|
||||||
|
const input: SolveInput = {
|
||||||
|
organization_id: run.org_id as string,
|
||||||
|
period_start: run.period_start as string,
|
||||||
|
period_end: run.period_end as string,
|
||||||
|
framework: run.framework_snapshot as SolveInput['framework'],
|
||||||
|
employees: run.employees_snapshot as SolveInput['employees'],
|
||||||
|
constraints: (run.constraints_snapshot as SolveInput['constraints']) || [],
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await solveSchedule(input)
|
||||||
|
|
||||||
|
await pb.collection('schedule_runs').update(run_id, {
|
||||||
|
status: result.status,
|
||||||
|
result: result.assignments,
|
||||||
|
objective_value: result.objective_value,
|
||||||
|
solver_duration_ms: result.duration_ms,
|
||||||
|
infeasibility_hints: result.infeasibility_hints || [],
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
} catch (err) {
|
||||||
|
await pb.collection('schedule_runs').update(run_id, {
|
||||||
|
status: 'error',
|
||||||
|
infeasibility_hints: [{ description: String(err) }],
|
||||||
|
})
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
})
|
||||||
14
server/utils/anthropic.ts
Normal file
14
server/utils/anthropic.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import Anthropic from '@anthropic-ai/sdk'
|
||||||
|
|
||||||
|
let _client: Anthropic | null = null
|
||||||
|
|
||||||
|
export function getAnthropicClient(): Anthropic {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
if (!config.anthropicApiKey) {
|
||||||
|
throw new Error('ANTHROPIC_API_KEY is not configured')
|
||||||
|
}
|
||||||
|
if (!_client) {
|
||||||
|
_client = new Anthropic({ apiKey: config.anthropicApiKey as string })
|
||||||
|
}
|
||||||
|
return _client
|
||||||
|
}
|
||||||
24
server/utils/pb-admin.ts
Normal file
24
server/utils/pb-admin.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import PocketBase from 'pocketbase'
|
||||||
|
|
||||||
|
let _adminClient: PocketBase | null = null
|
||||||
|
let _tokenExpiry: number = 0
|
||||||
|
|
||||||
|
export async function getPBAdminClient(): Promise<PocketBase> {
|
||||||
|
const config = useRuntimeConfig()
|
||||||
|
const pbUrl = config.public.pocketbaseUrl as string
|
||||||
|
|
||||||
|
if (!_adminClient) {
|
||||||
|
_adminClient = new PocketBase(pbUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-authenticate if token expired (PB tokens last 7 days but refresh at 1h)
|
||||||
|
if (!_adminClient.authStore.isValid || Date.now() > _tokenExpiry) {
|
||||||
|
await _adminClient.collection('_superusers').authWithPassword(
|
||||||
|
config.pbAdminEmail as string,
|
||||||
|
config.pbAdminPassword as string
|
||||||
|
)
|
||||||
|
_tokenExpiry = Date.now() + 3600 * 1000 // 1 hour
|
||||||
|
}
|
||||||
|
|
||||||
|
return _adminClient
|
||||||
|
}
|
||||||
32
server/utils/solver/feasibilityCheck.ts
Normal file
32
server/utils/solver/feasibilityCheck.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import type { SolveInput } from '~/shared/types/schedule'
|
||||||
|
|
||||||
|
interface FeasibilityIssue {
|
||||||
|
message: string
|
||||||
|
blocking: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function checkFeasibility(input: SolveInput): FeasibilityIssue[] {
|
||||||
|
const issues: FeasibilityIssue[] = []
|
||||||
|
const { employees, framework } = input
|
||||||
|
|
||||||
|
if (employees.length === 0) {
|
||||||
|
issues.push({ message: 'Keine Mitarbeiter vorhanden', blocking: true })
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
if (framework.shifts.length === 0) {
|
||||||
|
issues.push({ message: 'Keine Schichten definiert', blocking: true })
|
||||||
|
return issues
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if enough employees for the highest shift requirement
|
||||||
|
const maxRequired = Math.max(...framework.shifts.map(s => s.workers_required))
|
||||||
|
if (employees.length < maxRequired) {
|
||||||
|
issues.push({
|
||||||
|
message: `Zu wenige Mitarbeiter: ${maxRequired} benötigt, nur ${employees.length} vorhanden`,
|
||||||
|
blocking: true
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return issues
|
||||||
|
}
|
||||||
69
server/utils/solver/index.ts
Normal file
69
server/utils/solver/index.ts
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import type { SolveInput, SolveResult } from '~/shared/types/schedule'
|
||||||
|
import { buildModel } from './modelBuilder'
|
||||||
|
import { parseAssignments } from './resultParser'
|
||||||
|
import { checkFeasibility } from './feasibilityCheck'
|
||||||
|
|
||||||
|
export async function solveSchedule(input: SolveInput): Promise<SolveResult> {
|
||||||
|
const startTime = Date.now()
|
||||||
|
|
||||||
|
// Pre-solve sanity check
|
||||||
|
const issues = checkFeasibility(input)
|
||||||
|
if (issues.length > 0 && issues.some(i => i.blocking)) {
|
||||||
|
return {
|
||||||
|
status: 'infeasible',
|
||||||
|
assignments: [],
|
||||||
|
infeasibility_hints: issues.map(i => ({ description: i.message }))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Dynamic import — HiGHS WASM is large
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const HiGHS = (await import('highs' as any)).default
|
||||||
|
const highs = await HiGHS()
|
||||||
|
|
||||||
|
const model = buildModel(input)
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
const result = highs.solve(model, {}) as Record<string, unknown>
|
||||||
|
|
||||||
|
const duration_ms = Date.now() - startTime
|
||||||
|
const status = result.Status as string
|
||||||
|
|
||||||
|
if (status === 'Infeasible' || status === 'Infeasible or Unbounded') {
|
||||||
|
return {
|
||||||
|
status: 'infeasible',
|
||||||
|
assignments: [],
|
||||||
|
duration_ms,
|
||||||
|
infeasibility_hints: [
|
||||||
|
{ description: 'Das Optimierungsproblem hat keine Lösung. Bitte überprüfen Sie Ihre Bedingungen, insbesondere ob genügend Mitarbeiter für alle Schichten verfügbar sind.' },
|
||||||
|
...issues.map(i => ({ description: i.message }))
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status !== 'Optimal' && status !== 'Feasible') {
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
assignments: [],
|
||||||
|
duration_ms,
|
||||||
|
infeasibility_hints: [{ description: `Solver Status: ${status}` }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const assignments = parseAssignments(result, input)
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: 'solved',
|
||||||
|
assignments,
|
||||||
|
objective_value: result.ObjectiveValue as number,
|
||||||
|
duration_ms,
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Solver error:', err)
|
||||||
|
return {
|
||||||
|
status: 'error',
|
||||||
|
assignments: [],
|
||||||
|
infeasibility_hints: [{ description: String(err) }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
341
server/utils/solver/modelBuilder.ts
Normal file
341
server/utils/solver/modelBuilder.ts
Normal file
@@ -0,0 +1,341 @@
|
|||||||
|
import type { SolveInput } from '~/shared/types/schedule'
|
||||||
|
import { getDaysInRange, getWeekday } from '~/app/utils/dateHelpers'
|
||||||
|
import type { ConstraintJSON } from '~/shared/types/constraint'
|
||||||
|
|
||||||
|
export function buildModel(input: SolveInput) {
|
||||||
|
const { employees, framework, period_start, period_end, constraints } = input
|
||||||
|
const days = getDaysInRange(period_start, period_end)
|
||||||
|
const E = employees.length
|
||||||
|
const S = framework.shifts.length
|
||||||
|
const D = days.length
|
||||||
|
|
||||||
|
// Variable naming: x_e_s_d = 1 if employee e works shift s on day d
|
||||||
|
const varNames: string[] = []
|
||||||
|
const varIndex: Record<string, number> = {}
|
||||||
|
|
||||||
|
for (let e = 0; e < E; e++) {
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
for (let d = 0; d < D; d++) {
|
||||||
|
const name = `x_${e}_${s}_${d}`
|
||||||
|
varIndex[name] = varNames.length
|
||||||
|
varNames.push(name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Penalty variables for soft constraints
|
||||||
|
const penaltyVarStart = varNames.length
|
||||||
|
let penaltyVarCount = 0
|
||||||
|
const softConstraintPenalties: Array<{ varIdx: number; weight: number }> = []
|
||||||
|
|
||||||
|
function addPenaltyVar(weight: number): number {
|
||||||
|
const idx = penaltyVarStart + penaltyVarCount++
|
||||||
|
varNames.push(`penalty_${idx}`)
|
||||||
|
softConstraintPenalties.push({ varIdx: idx, weight })
|
||||||
|
return idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// Objective: minimize sum of soft penalties
|
||||||
|
const objectiveCoeffs: Record<string, number> = {}
|
||||||
|
for (const p of softConstraintPenalties) {
|
||||||
|
objectiveCoeffs[varNames[p.varIdx]] = p.weight
|
||||||
|
}
|
||||||
|
|
||||||
|
const bounds: Record<string, [number, number]> = {}
|
||||||
|
const binaries = new Set<string>()
|
||||||
|
|
||||||
|
for (const name of varNames) {
|
||||||
|
bounds[name] = [0, 1]
|
||||||
|
if (name.startsWith('x_')) binaries.add(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows: Array<{
|
||||||
|
name: string
|
||||||
|
vars: Record<string, number>
|
||||||
|
lb: number
|
||||||
|
ub: number
|
||||||
|
}> = []
|
||||||
|
|
||||||
|
let rowIdx = 0
|
||||||
|
function addRow(vars: Record<string, number>, lb: number, ub: number) {
|
||||||
|
rows.push({ name: `r${rowIdx++}`, vars, lb, ub })
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural constraint 1: at most 1 shift per employee per day
|
||||||
|
for (let e = 0; e < E; e++) {
|
||||||
|
for (let d = 0; d < D; d++) {
|
||||||
|
const vars: Record<string, number> = {}
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
vars[`x_${e}_${s}_${d}`] = 1
|
||||||
|
}
|
||||||
|
addRow(vars, 0, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural constraint 2: coverage requirements
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
const shift = framework.shifts[s]
|
||||||
|
for (let d = 0; d < D; d++) {
|
||||||
|
const weekday = getWeekday(days[d])
|
||||||
|
const applicable = shift.days_applicable.length === 0 || shift.days_applicable.includes(weekday)
|
||||||
|
if (!applicable) {
|
||||||
|
// Force all x_e_s_d = 0 for this day
|
||||||
|
for (let e = 0; e < E; e++) {
|
||||||
|
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const coverageVars: Record<string, number> = {}
|
||||||
|
for (let e = 0; e < E; e++) {
|
||||||
|
coverageVars[`x_${e}_${s}_${d}`] = 1
|
||||||
|
}
|
||||||
|
addRow(coverageVars, shift.workers_required, E)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Structural constraint 3: availability
|
||||||
|
for (let e = 0; e < E; e++) {
|
||||||
|
const emp = employees[e]
|
||||||
|
|
||||||
|
for (let d = 0; d < D; d++) {
|
||||||
|
const date = days[d]
|
||||||
|
|
||||||
|
// Block unavailable dates
|
||||||
|
if (emp.unavailable_dates.includes(date)) {
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Block periods not available
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
const shift = framework.shifts[s]
|
||||||
|
const period = framework.periods.find(p => p.id === shift.period_id)
|
||||||
|
if (period && emp.available_periods.length > 0 && !emp.available_periods.includes(period.id)) {
|
||||||
|
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process constraints from ConstraintJSON
|
||||||
|
for (const c of constraints) {
|
||||||
|
const cj = c.constraint_json as ConstraintJSON
|
||||||
|
const params = cj.params as Record<string, unknown>
|
||||||
|
|
||||||
|
// Determine affected employees
|
||||||
|
let affectedEmployees: number[] = Array.from({ length: E }, (_, i) => i)
|
||||||
|
if (cj.scope.type === 'employee') {
|
||||||
|
const empIdx = employees.findIndex(e => e.id === (cj.scope as { type: 'employee'; employee_id: string }).employee_id)
|
||||||
|
if (empIdx >= 0) affectedEmployees = [empIdx]
|
||||||
|
else continue
|
||||||
|
} else if (cj.scope.type === 'role') {
|
||||||
|
const role = (cj.scope as { type: 'role'; role: string }).role
|
||||||
|
affectedEmployees = employees
|
||||||
|
.map((e, i) => ({ e, i }))
|
||||||
|
.filter(({ e }) => e.roles.includes(role))
|
||||||
|
.map(({ i }) => i)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (cj.type) {
|
||||||
|
case 'max_consecutive_shifts': {
|
||||||
|
const max = params.max_count as number
|
||||||
|
for (const e of affectedEmployees) {
|
||||||
|
for (let d = 0; d <= D - (max + 1); d++) {
|
||||||
|
const vars: Record<string, number> = {}
|
||||||
|
for (let dd = d; dd <= d + max; dd++) {
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
vars[`x_${e}_${s}_${dd}`] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addRow(vars, 0, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'max_consecutive_shift_type': {
|
||||||
|
const max = params.max_count as number
|
||||||
|
const periodId = params.period_id as string
|
||||||
|
const shiftIndices = framework.shifts
|
||||||
|
.map((sh, i) => ({ sh, i }))
|
||||||
|
.filter(({ sh }) => sh.period_id === periodId)
|
||||||
|
.map(({ i }) => i)
|
||||||
|
|
||||||
|
if (shiftIndices.length === 0) break
|
||||||
|
|
||||||
|
for (const e of affectedEmployees) {
|
||||||
|
for (let d = 0; d <= D - (max + 1); d++) {
|
||||||
|
const vars: Record<string, number> = {}
|
||||||
|
for (let dd = d; dd <= d + max; dd++) {
|
||||||
|
for (const s of shiftIndices) {
|
||||||
|
vars[`x_${e}_${s}_${dd}`] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
addRow(vars, 0, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'forbidden_shift_sequence': {
|
||||||
|
const firstPeriod = params.first_period_id as string
|
||||||
|
const secondPeriod = params.second_period_id as string
|
||||||
|
const firstShifts = framework.shifts
|
||||||
|
.map((sh, i) => ({ sh, i }))
|
||||||
|
.filter(({ sh }) => sh.period_id === firstPeriod)
|
||||||
|
.map(({ i }) => i)
|
||||||
|
const secondShifts = framework.shifts
|
||||||
|
.map((sh, i) => ({ sh, i }))
|
||||||
|
.filter(({ sh }) => sh.period_id === secondPeriod)
|
||||||
|
.map(({ i }) => i)
|
||||||
|
|
||||||
|
for (const e of affectedEmployees) {
|
||||||
|
for (let d = 0; d < D - 1; d++) {
|
||||||
|
for (const s1 of firstShifts) {
|
||||||
|
for (const s2 of secondShifts) {
|
||||||
|
addRow({
|
||||||
|
[`x_${e}_${s1}_${d}`]: 1,
|
||||||
|
[`x_${e}_${s2}_${d + 1}`]: 1
|
||||||
|
}, 0, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'employee_avoids_period': {
|
||||||
|
const periodId = params.period_id as string
|
||||||
|
const shiftIndices = framework.shifts
|
||||||
|
.map((sh, i) => ({ sh, i }))
|
||||||
|
.filter(({ sh }) => sh.period_id === periodId)
|
||||||
|
.map(({ i }) => i)
|
||||||
|
|
||||||
|
if (cj.hard) {
|
||||||
|
for (const e of affectedEmployees) {
|
||||||
|
for (let d = 0; d < D; d++) {
|
||||||
|
for (const s of shiftIndices) {
|
||||||
|
addRow({ [`x_${e}_${s}_${d}`]: 1 }, 0, 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Soft: penalty per assignment
|
||||||
|
const weight = (c.weight || 70) * 10
|
||||||
|
for (const e of affectedEmployees) {
|
||||||
|
for (let d = 0; d < D; d++) {
|
||||||
|
for (const s of shiftIndices) {
|
||||||
|
const pv = addPenaltyVar(weight)
|
||||||
|
objectiveCoeffs[varNames[pv]] = weight
|
||||||
|
bounds[varNames[pv]] = [0, 1]
|
||||||
|
addRow({
|
||||||
|
[varNames[pv]]: 1,
|
||||||
|
[`x_${e}_${s}_${d}`]: -1
|
||||||
|
}, 0, 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'max_weekend_shifts_per_month': {
|
||||||
|
const max = params.max_count as number
|
||||||
|
const weekendDays = days
|
||||||
|
.map((day, i) => ({ day, i }))
|
||||||
|
.filter(({ day }) => {
|
||||||
|
const wd = getWeekday(day)
|
||||||
|
return wd === 5 || wd === 6
|
||||||
|
})
|
||||||
|
.map(({ i }) => i)
|
||||||
|
|
||||||
|
for (const e of affectedEmployees) {
|
||||||
|
const vars: Record<string, number> = {}
|
||||||
|
for (const d of weekendDays) {
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
vars[`x_${e}_${s}_${d}`] = 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Object.keys(vars).length > 0) {
|
||||||
|
addRow(vars, 0, max)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'min_rest_between_shifts':
|
||||||
|
// Already handled implicitly by "max 1 shift per day"
|
||||||
|
break
|
||||||
|
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild objective with finalized penalty vars
|
||||||
|
for (const p of softConstraintPenalties) {
|
||||||
|
objectiveCoeffs[varNames[p.varIdx]] = p.weight
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize bounds for penalty vars
|
||||||
|
for (let i = penaltyVarStart; i < varNames.length; i++) {
|
||||||
|
if (!bounds[varNames[i]]) {
|
||||||
|
bounds[varNames[i]] = [0, 1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: 'ShiftCraft',
|
||||||
|
sense: 'minimize' as const,
|
||||||
|
offset: 0,
|
||||||
|
col_lower: varNames.map(n => bounds[n]?.[0] ?? 0),
|
||||||
|
col_upper: varNames.map(n => bounds[n]?.[1] ?? 1),
|
||||||
|
col_cost: varNames.map(n => objectiveCoeffs[n] ?? 0),
|
||||||
|
col_names: varNames,
|
||||||
|
row_lower: rows.map(r => r.lb),
|
||||||
|
row_upper: rows.map(r => r.ub),
|
||||||
|
row_names: rows.map(r => r.name),
|
||||||
|
a_matrix: {
|
||||||
|
format: 'colwise' as const,
|
||||||
|
start: buildColwiseStart(varNames, rows),
|
||||||
|
index: buildColwiseIndex(varNames, rows),
|
||||||
|
value: buildColwiseValues(varNames, rows),
|
||||||
|
},
|
||||||
|
integrality: varNames.map(n => binaries.has(n) ? 1 : 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColwiseStart(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
|
||||||
|
const starts: number[] = [0]
|
||||||
|
let count = 0
|
||||||
|
for (const name of varNames) {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (name in row.vars) count++
|
||||||
|
}
|
||||||
|
starts.push(count)
|
||||||
|
}
|
||||||
|
return starts
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColwiseIndex(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
|
||||||
|
const indices: number[] = []
|
||||||
|
for (const name of varNames) {
|
||||||
|
rows.forEach((row, i) => {
|
||||||
|
if (name in row.vars) indices.push(i)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return indices
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildColwiseValues(varNames: string[], rows: Array<{ vars: Record<string, number> }>): number[] {
|
||||||
|
const values: number[] = []
|
||||||
|
for (const name of varNames) {
|
||||||
|
for (const row of rows) {
|
||||||
|
if (name in row.vars) values.push(row.vars[name])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
38
server/utils/solver/resultParser.ts
Normal file
38
server/utils/solver/resultParser.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import type { SolveInput, ShiftAssignment } from '~/shared/types/schedule'
|
||||||
|
import { getDaysInRange } from '~/app/utils/dateHelpers'
|
||||||
|
|
||||||
|
export function parseAssignments(result: Record<string, unknown>, input: SolveInput): ShiftAssignment[] {
|
||||||
|
const { employees, framework, period_start, period_end } = input
|
||||||
|
const days = getDaysInRange(period_start, period_end)
|
||||||
|
const E = employees.length
|
||||||
|
const S = framework.shifts.length
|
||||||
|
const D = days.length
|
||||||
|
const assignments: ShiftAssignment[] = []
|
||||||
|
|
||||||
|
const solution = result.Columns as Record<string, { Primal: number }>
|
||||||
|
|
||||||
|
for (let e = 0; e < E; e++) {
|
||||||
|
for (let s = 0; s < S; s++) {
|
||||||
|
for (let d = 0; d < D; d++) {
|
||||||
|
const varName = `x_${e}_${s}_${d}`
|
||||||
|
const value = solution[varName]?.Primal ?? 0
|
||||||
|
if (value > 0.5) {
|
||||||
|
const shift = framework.shifts[s]
|
||||||
|
const period = framework.periods.find(p => p.id === shift.period_id)
|
||||||
|
assignments.push({
|
||||||
|
employee_id: employees[e].id,
|
||||||
|
employee_name: employees[e].name,
|
||||||
|
shift_id: shift.id,
|
||||||
|
shift_name: shift.name,
|
||||||
|
period_id: shift.period_id,
|
||||||
|
date: days[d],
|
||||||
|
start_time: period?.start_time ?? '00:00',
|
||||||
|
end_time: period?.end_time ?? '00:00',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return assignments
|
||||||
|
}
|
||||||
35
shared/types/constraint.ts
Normal file
35
shared/types/constraint.ts
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
export type ConstraintType =
|
||||||
|
| 'max_hours_per_day'
|
||||||
|
| 'max_hours_per_week'
|
||||||
|
| 'min_rest_between_shifts'
|
||||||
|
| 'max_consecutive_shifts'
|
||||||
|
| 'max_consecutive_shift_type'
|
||||||
|
| 'min_consecutive_days_off'
|
||||||
|
| 'max_shifts_per_period_per_week'
|
||||||
|
| 'forbidden_shift_sequence'
|
||||||
|
| 'employee_unavailable'
|
||||||
|
| 'employee_prefers_period'
|
||||||
|
| 'employee_avoids_period'
|
||||||
|
| 'require_role_per_shift'
|
||||||
|
| 'max_weekend_shifts_per_month'
|
||||||
|
| 'fair_distribution'
|
||||||
|
|
||||||
|
export type ConstraintScope =
|
||||||
|
| { type: 'global' }
|
||||||
|
| { type: 'employee'; employee_id: string }
|
||||||
|
| { type: 'role'; role: string }
|
||||||
|
| { type: 'period'; period_id: string }
|
||||||
|
|
||||||
|
export interface ConstraintJSON {
|
||||||
|
type: ConstraintType
|
||||||
|
scope: ConstraintScope
|
||||||
|
params: Record<string, unknown>
|
||||||
|
hard: boolean
|
||||||
|
weight?: number
|
||||||
|
natural_language_summary: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedConstraintResult {
|
||||||
|
constraints: ConstraintJSON[]
|
||||||
|
ambiguities: string[]
|
||||||
|
}
|
||||||
95
shared/types/pocketbase.ts
Normal file
95
shared/types/pocketbase.ts
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
export interface Organization {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
slug: string
|
||||||
|
timezone: string
|
||||||
|
industry: string
|
||||||
|
owner: string
|
||||||
|
plan: 'free' | 'pro' | 'business'
|
||||||
|
plan_employee_limit: number
|
||||||
|
plan_history_months: number
|
||||||
|
stripe_customer_id: string
|
||||||
|
stripe_subscription_id: string
|
||||||
|
stripe_subscription_status: string
|
||||||
|
trial_ends_at: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PBEmployee {
|
||||||
|
id: string
|
||||||
|
org_id: string
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
employee_number: string
|
||||||
|
roles: string[]
|
||||||
|
skills: string[]
|
||||||
|
employment_type: string
|
||||||
|
weekly_hours_target: number
|
||||||
|
max_weekly_hours: number
|
||||||
|
available_periods: string[]
|
||||||
|
unavailable_dates: string[]
|
||||||
|
notes: string
|
||||||
|
active: boolean
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PBConstraint {
|
||||||
|
id: string
|
||||||
|
org_id: string
|
||||||
|
label: string
|
||||||
|
source_text: string
|
||||||
|
constraint_json: import('./constraint').ConstraintJSON
|
||||||
|
scope: string
|
||||||
|
scope_ref: string
|
||||||
|
category: string
|
||||||
|
hard: boolean
|
||||||
|
weight: number
|
||||||
|
active: boolean
|
||||||
|
source: string
|
||||||
|
template_id: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PBScheduleRun {
|
||||||
|
id: string
|
||||||
|
org_id: string
|
||||||
|
name: string
|
||||||
|
period_start: string
|
||||||
|
period_end: string
|
||||||
|
framework_snapshot: unknown
|
||||||
|
constraints_snapshot: unknown
|
||||||
|
employees_snapshot: unknown
|
||||||
|
status: 'pending' | 'solving' | 'solved' | 'infeasible' | 'error'
|
||||||
|
solver_duration_ms: number
|
||||||
|
objective_value: number
|
||||||
|
infeasibility_hints: unknown
|
||||||
|
result: unknown
|
||||||
|
created_by: string
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LegalTemplate {
|
||||||
|
id: string
|
||||||
|
region: string
|
||||||
|
law_name: string
|
||||||
|
label: string
|
||||||
|
description: string
|
||||||
|
constraint_json: import('./constraint').ConstraintJSON
|
||||||
|
category: string
|
||||||
|
mandatory: boolean
|
||||||
|
sort_order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShiftFramework {
|
||||||
|
id: string
|
||||||
|
org_id: string
|
||||||
|
periods: import('./schedule').Period[]
|
||||||
|
shifts: import('./schedule').Shift[]
|
||||||
|
scheduling_horizon_days: number
|
||||||
|
created: string
|
||||||
|
updated: string
|
||||||
|
}
|
||||||
59
shared/types/schedule.ts
Normal file
59
shared/types/schedule.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
export interface ShiftAssignment {
|
||||||
|
employee_id: string
|
||||||
|
employee_name: string
|
||||||
|
shift_id: string
|
||||||
|
shift_name: string
|
||||||
|
period_id: string
|
||||||
|
date: string // ISO date
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolveResult {
|
||||||
|
status: 'solved' | 'infeasible' | 'error'
|
||||||
|
assignments: ShiftAssignment[]
|
||||||
|
objective_value?: number
|
||||||
|
duration_ms?: number
|
||||||
|
infeasibility_hints?: Array<{ constraint_id?: string; description: string }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SolveInput {
|
||||||
|
organization_id: string
|
||||||
|
period_start: string
|
||||||
|
period_end: string
|
||||||
|
framework: {
|
||||||
|
periods: Period[]
|
||||||
|
shifts: Shift[]
|
||||||
|
}
|
||||||
|
employees: Employee[]
|
||||||
|
constraints: Array<{ id: string; constraint_json: import('./constraint').ConstraintJSON; hard: boolean; weight?: number }>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Period {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
start_time: string
|
||||||
|
end_time: string
|
||||||
|
color: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shift {
|
||||||
|
id: string
|
||||||
|
period_id: string
|
||||||
|
name: string
|
||||||
|
duration_hours: number
|
||||||
|
workers_required: number
|
||||||
|
days_applicable: number[] // 0=Mon...6=Sun, empty = all days
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Employee {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
roles: string[]
|
||||||
|
skills: string[]
|
||||||
|
employment_type: 'full_time' | 'part_time' | 'mini_job'
|
||||||
|
weekly_hours_target: number
|
||||||
|
max_weekly_hours: number
|
||||||
|
available_periods: string[]
|
||||||
|
unavailable_dates: string[]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user