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:
2026-04-18 07:47:31 +02:00
parent 2ea4ca5d52
commit 36e0946ee4
38 changed files with 4254 additions and 133 deletions

View File

@@ -1,110 +1,65 @@
<template>
<UAuthForm
:fields="fields"
:schema="schema"
:providers="providers"
:title="$t('login.title')"
icon="i-lucide-lock"
@submit="onSubmit($event)"
>
<template #footer>
{{ $t('login.agree') }}
<ULink
to="/"
class="text-primary font-medium"
>
{{ $t('login.terms') }}
</ULink>.
</template>
</UAuthForm>
<div class="bg-white rounded-2xl border border-gray-100 shadow-sm p-8">
<h2 class="text-2xl font-bold text-gray-900 mb-2">Willkommen zurück</h2>
<p class="text-gray-500 mb-6 text-sm">Melden Sie sich in Ihrem ShiftCraft-Konto an</p>
<UForm :schema="schema" :state="formState" @submit="onSubmit" class="space-y-4">
<UFormField label="E-Mail" name="email">
<UInput v-model="formState.email" type="email" placeholder="name@firma.de" class="w-full" />
</UFormField>
<UFormField label="Passwort" name="password">
<UInput v-model="formState.password" type="password" placeholder="••••••••" class="w-full" />
</UFormField>
<UButton type="submit" color="primary" class="w-full justify-center" :loading="loading" size="lg">
Anmelden
</UButton>
</UForm>
<div class="mt-6 text-center">
<p class="text-sm text-gray-500">
Noch kein Konto?
<NuxtLink to="/register" class="text-indigo-600 hover:text-indigo-700 font-medium">Kostenlos registrieren</NuxtLink>
</p>
</div>
</div>
</template>
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
import type { AuthFormField } from '@nuxt/ui/runtime/components/AuthForm.vue.js'
definePageMeta({
layout: 'empty'
})
definePageMeta({ layout: 'auth', middleware: 'guest' })
const { t } = useI18n()
const toast = useToast()
const loading = ref(false)
// The otpId is used to track the OTP request and verify the user
// null if no request was sent yet
const otpId = ref<string | null>(null)
const fields = computed(() => {
const fields: AuthFormField[] = [{
name: 'email',
type: 'text',
label: t('login.email'),
placeholder: t('login.emailPlaceholder'),
required: true
}]
if (otpId.value !== null) {
fields.push({
name: 'otp',
type: 'otp',
label: t('login.otp'),
length: 6,
size: 'xl'
})
}
return fields
const formState = reactive({
email: '',
password: '',
})
const { signInWithEmail, signInWithOAuth, signInWithOtp } = useUser()
const oAuthAndRedirect = async (provider: 'apple' | 'google') => {
try {
await signInWithOAuth(provider)
navigateTo('/confirm')
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
}
}
const providers = [
{
label: 'Google',
icon: 'i-simple-icons-google',
onClick: () => oAuthAndRedirect('google')
},
{
label: 'Apple',
icon: 'i-simple-icons-apple',
onClick: () => oAuthAndRedirect('apple')
}
]
const schema = z.object({
email: z.email(t('login.invalidEmail')),
otp: z.array(z.string()).optional().transform(val => val?.join(''))
email: z.email('Ungültige E-Mail-Adresse'),
password: z.string().min(1, 'Passwort ist erforderlich'),
})
type Schema = z.output<typeof schema>
const { locale } = useI18n()
async function onSubmit(payload: FormSubmitEvent<Schema>) {
loading.value = true
try {
if (otpId.value !== null && payload.data.otp?.length) {
// If OTP is provided, sign in with OTP
await signInWithOtp(otpId.value, payload.data.otp)
navigateTo('/confirm')
} else {
// If OTP is not provided, sign in with email (send OTP)
otpId.value = await signInWithEmail(payload.data.email, locale.value)
toast.add({
title: t('login.emailSent'),
description: t('login.emailSentDescription'),
icon: 'i-lucide-check',
color: 'success'
})
}
} catch (error) {
toast.add({ color: 'error', description: (error as Error).message })
const { pb } = usePocketBase()
const authData = await pb.collection('users').authWithPassword(payload.data.email, payload.data.password)
// Load org
const orgStore = useOrg()
const orgId = (authData.record as { org_id?: string }).org_id
if (orgId) await orgStore.fetchOrg(orgId)
await navigateTo('/dashboard')
} catch {
toast.add({ color: 'error', title: 'Anmeldung fehlgeschlagen', description: 'E-Mail oder Passwort ist falsch.' })
} finally {
loading.value = false
}
}
</script>