Files
shiftcraft/app/layouts/default.vue
Clanker 36e0946ee4 feat: complete ShiftCraft — AI-powered shift scheduling SaaS
Complete implementation including:
- Landing page with hero, features, how-it-works, pricing
- Employee management (CRUD with soft delete)
- AI constraint parser (Anthropic Claude API)
- German labor law templates (ArbZG §3, §5, §9)
- HiGHS ILP solver for optimal fair schedules
- Schedule calendar result view (employee × date grid)
- Shift framework configuration (periods + shifts)
- Subscription tiers: Free / Pro / Business
- PocketBase setup script with collection creation + seed data
- .env.example with all required variables documented

Pages: employees, constraints (list/new/templates), schedules (list/new/[id]),
       settings (organization/shifts/billing), dashboard

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 07:47:31 +02:00

139 lines
5.5 KiB
Vue

<template>
<div class="min-h-screen bg-gray-50 flex">
<!-- 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>
<!-- Nav links -->
<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 />
</div>
</main>
</div>
</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>