Compare commits

...

2 Commits

Author SHA1 Message Date
Tom Trappmann
1dcc49bdbe removing files 2026-02-02 22:51:10 +01:00
Tom Trappmann
598ad36146 Add login landing page 2026-02-02 22:50:55 +01:00
25 changed files with 124 additions and 1963 deletions

View File

@@ -1,2 +1,7 @@
@import "tailwindcss";
@import "@nuxt/ui";
@import "@nuxt/ui";
@import url("https://fonts.googleapis.com/css2?family=Sora:wght@300;400;500;600;700&display=swap");
body {
font-family: "Sora", "Avenir Next", "Trebuchet MS", sans-serif;
}

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'
const route = useRoute()
const items = computed<NavigationMenuItem[]>(() => [
{
label: 'Docs',
to: '/docs/getting-started',
active: route.path.startsWith('/docs/getting-started')
},
])
</script>
<template>
<UHeader>
<template #title>
<Logo class="h-6 w-auto" />
<h3>NautilusDesk</h3>
</template>
<UNavigationMenu :items="items" />
<template #right>
<UColorModeButton />
</template>
</UHeader>
</template>

View File

@@ -1,5 +0,0 @@
<template>
<NuxtLink to="/" class="flex items-center gap-2">
<img src="/icon.png" alt="Logo" class="h-full w-auto" />
</NuxtLink>
</template>

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
defineProps<{
collapsed?: boolean
}>()
const locations = ref([{
label: 'Xanten',
avatar: {
alt: 'Xanten'
}
},
{
label: 'Bonn',
avatar: {
alt: 'Bonn'
}
},])
const selectedTeam = ref(locations.value[0])
const items = computed<DropdownMenuItem[][]>(() => {
return [locations.value.map(team => ({
...team,
onSelect() {
selectedTeam.value = team
}
})),]
})
</script>
<template>
<UDropdownMenu :items="items" :content="{ align: 'center', collisionPadding: 12 }"
:ui="{ content: collapsed ? 'w-40' : 'w-(--reka-dropdown-menu-trigger-width)' }">
<UButton v-bind="{
...selectedTeam,
label: collapsed ? undefined : selectedTeam?.label,
trailingIcon: collapsed ? undefined : 'i-lucide-chevrons-up-down'
}" color="neutral" variant="ghost" block :square="collapsed" class="data-[state=open]:bg-elevated"
:class="[!collapsed && 'py-2']" :ui="{
trailingIcon: 'text-dimmed'
}" />
</UDropdownMenu>
</template>

View File

@@ -1,81 +0,0 @@
<script setup lang="ts">
import type { DropdownMenuItem } from '@nuxt/ui'
defineProps<{
collapsed?: boolean
}>()
const colorMode = useColorMode()
const appConfig = useAppConfig()
const user = ref({
name: 'Tom Trappmann',
avatar: {
src: '',
alt: 'Tom Trappmann'
}
})
const items = computed<DropdownMenuItem[][]>(() => ([[{
type: 'label',
label: user.value.name,
avatar: user.value.avatar
}], [{
label: 'Settings',
icon: 'i-lucide-settings',
to: '/settings'
}], [{
label: 'Appearance',
icon: 'i-lucide-sun-moon',
children: [{
label: 'Light',
icon: 'i-lucide-sun',
type: 'checkbox',
checked: colorMode.value === 'light',
onSelect(e: Event) {
e.preventDefault()
colorMode.preference = 'light'
}
}, {
label: 'Dark',
icon: 'i-lucide-moon',
type: 'checkbox',
checked: colorMode.value === 'dark',
onUpdateChecked(checked: boolean) {
if (checked) {
colorMode.preference = 'dark'
}
},
onSelect(e: Event) {
e.preventDefault()
}
}]
}], [{
label: 'Log out',
icon: 'i-lucide-log-out'
}]]))
</script>
<template>
<UDropdownMenu :items="items" :content="{ align: 'center', collisionPadding: 12 }"
:ui="{ content: collapsed ? 'w-48' : 'w-(--reka-dropdown-menu-trigger-width)' }">
<UButton v-bind="{
...user,
label: collapsed ? undefined : user?.name,
trailingIcon: collapsed ? undefined : 'i-lucide-chevrons-up-down'
}" color="neutral" variant="ghost" block :square="collapsed" class="data-[state=open]:bg-elevated" :ui="{
trailingIcon: 'text-dimmed'
}" />
<template #chip-leading="{ item }">
<div class="inline-flex items-center justify-center shrink-0 size-5">
<span class="rounded-full ring ring-bg bg-(--chip-light) dark:bg-(--chip-dark) size-2" :style="{
'--chip-light': `var(--color-${(item as any).chip}-500)`,
'--chip-dark': `var(--color-${(item as any).chip}-400)`
}" />
</div>
</template>
</UDropdownMenu>
</template>

View File

@@ -1,34 +0,0 @@
<script setup lang="ts">
import { formatTimeAgo } from '@vueuse/core'
import type { Notification } from '~/types'
const { isNotificationsSlideoverOpen } = useDashboard()
const { data: notifications } = await useFetch<Notification[]>('/api/notifications')
</script>
<template>
<USlideover v-model:open="isNotificationsSlideoverOpen" title="Notifications">
<template #body>
<NuxtLink v-for="notification in notifications" :key="notification.id" :to="`/inbox?id=${notification.id}`"
class="px-3 py-2.5 rounded-md hover:bg-elevated/50 flex items-center gap-3 relative -mx-3 first:-mt-3 last:-mb-3">
<UChip color="error" :show="!!notification.unread" inset>
<UAvatar v-bind="notification.sender.avatar" :alt="notification.sender.name" size="md" />
</UChip>
<div class="text-sm flex-1">
<p class="flex items-center justify-between">
<span class="text-highlighted font-medium">{{ notification.sender.name }}</span>
<time :datetime="notification.date" class="text-muted text-xs"
v-text="formatTimeAgo(new Date(notification.date))" />
</p>
<p class="text-dimmed">
{{ notification.body }}
</p>
</div>
</NuxtLink>
</template>
</USlideover>
</template>

View File

@@ -1,44 +0,0 @@
<script setup lang="ts">
import * as z from 'zod'
import type { FormSubmitEvent } from '@nuxt/ui'
const schema = z.object({
name: z.string().min(2, 'Too short'),
email: z.string().email('Invalid email')
})
const open = ref(false)
type Schema = z.output<typeof schema>
const state = reactive<Partial<Schema>>({
name: undefined,
email: undefined
})
const toast = useToast()
async function onSubmit(event: FormSubmitEvent<Schema>) {
toast.add({ title: 'Success', description: `New customer ${event.data.name} added`, color: 'success' })
open.value = false
}
</script>
<template>
<UModal v-model:open="open" title="New customer" description="Add a new customer to the database">
<UButton label="New customer" icon="i-lucide-plus" />
<template #body>
<UForm :schema="schema" :state="state" class="space-y-4" @submit="onSubmit">
<UFormField label="Name" placeholder="John Doe" name="name">
<UInput v-model="state.name" class="w-full" />
</UFormField>
<UFormField label="Email" placeholder="john.doe@example.com" name="email">
<UInput v-model="state.email" class="w-full" />
</UFormField>
<div class="flex justify-end gap-2">
<UButton label="Cancel" color="neutral" variant="subtle" @click="open = false" />
<UButton label="Create" color="primary" variant="solid" type="submit" />
</div>
</UForm>
</template>
</UModal>
</template>

View File

@@ -1,28 +0,0 @@
<script setup lang="ts">
withDefaults(defineProps<{
count?: number
}>(), {
count: 0
})
const open = ref(false)
async function onSubmit() {
await new Promise(resolve => setTimeout(resolve, 1000))
open.value = false
}
</script>
<template>
<UModal v-model:open="open" :title="`Delete ${count} customer${count > 1 ? 's' : ''}`"
:description="`Are you sure, this action cannot be undone.`">
<slot />
<template #body>
<div class="flex justify-end gap-2">
<UButton label="Cancel" color="neutral" variant="subtle" @click="open = false" />
<UButton label="Delete" color="error" variant="solid" loading-auto @click="onSubmit" />
</div>
</template>
</UModal>
</template>

View File

@@ -1,3 +0,0 @@
export function useAppHead() {
useHead({ titleTemplate: 'NautilusDesk · Dive School' })
}

View File

@@ -1,25 +0,0 @@
import { createSharedComposable } from '@vueuse/core'
const _useDashboard = () => {
const route = useRoute()
const router = useRouter()
const isNotificationsSlideoverOpen = ref(false)
defineShortcuts({
'g-h': () => router.push('/'),
'g-i': () => router.push('/inbox'),
'g-c': () => router.push('/customers'),
'g-s': () => router.push('/settings'),
'n': () => isNotificationsSlideoverOpen.value = !isNotificationsSlideoverOpen.value
})
watch(() => route.fullPath, () => {
isNotificationsSlideoverOpen.value = false
})
return {
isNotificationsSlideoverOpen
}
}
export const useDashboard = createSharedComposable(_useDashboard)

View File

@@ -1,59 +0,0 @@
export type ShiftAssignmentPayload = {
employee_id: string
}
export type CreateShiftPayload = {
date: string
type: string
start_time: string
end_time: string
assignments?: ShiftAssignmentPayload[]
}
export type ShiftResponse = {
id: string
date: string
type: string
start_time: string
end_time: string
assignments: {
id: string
name: string
}[]
}
const buildHeaders = (orgId: string) => ({
'X-Org-Id': orgId
})
export const useShiftApi = () => {
const getShifts = (branchId: string, from: string, to: string, orgId: string) => {
return $fetch<ShiftResponse[]>(`/shifts/branches/${branchId}`, {
method: 'GET',
headers: buildHeaders(orgId),
query: { from, to }
})
}
const createShift = (branchId: string, payload: CreateShiftPayload, orgId: string) => {
return $fetch<ShiftResponse>(`/shifts/branches/${branchId}`, {
method: 'POST',
headers: buildHeaders(orgId),
body: payload
})
}
const assignEmployees = (shiftId: string, payload: ShiftAssignmentPayload[], orgId: string) => {
return $fetch<ShiftResponse>(`/shifts/${shiftId}/assignments`, {
method: 'POST',
headers: buildHeaders(orgId),
body: { assignments: payload }
})
}
return {
getShifts,
createShift,
assignEmployees
}
}

View File

@@ -1,166 +0,0 @@
<script setup lang="ts">
import type { NavigationMenuItem } from '@nuxt/ui'
import LocationsMenu from '~/components/Menus/LocationsMenu.vue'
import UserMenu from '~/components/Menus/UserMenu.vue'
const route = useRoute()
const toast = useToast()
const open = ref(false)
const links = [[{
label: 'Home',
icon: 'i-lucide-house',
to: '/',
onSelect: () => {
open.value = false
}
}, {
label: 'Dienstplan',
icon: 'i-lucide-calendar-days',
to: '/dienstplan',
onSelect: () => {
open.value = false
}
}, {
label: 'Mitarbeiter',
icon: 'i-lucide-users',
to: '/mitarbeiter',
onSelect: () => {
open.value = false
}
}, {
label: 'Abwesenheiten',
icon: 'i-lucide-calendar-off',
to: '/abwesenheiten',
onSelect: () => {
open.value = false
}
}, {
label: 'Verfügbarkeit',
icon: 'i-lucide-calendar-clock',
to: '/verfuegbarkeit',
onSelect: () => {
open.value = false
}
}, {
label: 'Filialen',
icon: 'i-lucide-map-pin',
to: '/filialen',
onSelect: () => {
open.value = false
}
}, {
label: 'Einstellungen',
to: '/settings',
icon: 'i-lucide-settings',
defaultOpen: true,
type: 'trigger',
children: [{
label: 'General',
to: '/settings',
exact: true,
onSelect: () => {
open.value = false
}
}, {
label: 'Members',
to: '/settings/members',
onSelect: () => {
open.value = false
}
}, {
label: 'Notifications',
to: '/settings/notifications',
onSelect: () => {
open.value = false
}
}, {
label: 'Security',
to: '/settings/security',
onSelect: () => {
open.value = false
}
}]
}], [{
label: 'Feedback',
icon: 'i-lucide-message-circle',
to: 'https://github.com/nuxt-ui-templates/dashboard',
target: '_blank'
}, {
label: 'Help & Support',
icon: 'i-lucide-info',
to: 'https://github.com/nuxt-ui-templates/dashboard',
target: '_blank'
}]] satisfies NavigationMenuItem[][]
const groups = computed(() => [{
id: 'links',
label: 'Go to',
items: links.flat()
}, {
id: 'code',
label: 'Code',
items: [{
id: 'source',
label: 'View page source',
icon: 'i-simple-icons-github',
to: `https://github.com/nuxt-ui-templates/dashboard/blob/main/app/pages${route.path === '/' ? '/index' : route.path}.vue`,
target: '_blank'
}]
}])
onMounted(async () => {
const cookie = useCookie('cookie-consent')
if (cookie.value === 'accepted') {
return
}
toast.add({
title: 'We use first-party cookies to enhance your experience on our website.',
duration: 0,
close: false,
actions: [{
label: 'Accept',
color: 'neutral',
variant: 'outline',
onClick: () => {
cookie.value = 'accepted'
}
}, {
label: 'Opt out',
color: 'neutral',
variant: 'ghost'
}]
})
})
</script>
<template>
<UDashboardGroup unit="rem">
<UDashboardSidebar id="default" v-model:open="open" collapsible resizable class="bg-elevated/25"
:ui="{ footer: 'lg:border-t lg:border-default' }">
<template #header="{ collapsed }">
<LocationsMenu :collapsed="collapsed" />
</template>
<template #default="{ collapsed }">
<UNavigationMenu :collapsed="collapsed" :items="links[0]" orientation="vertical" tooltip popover />
<UNavigationMenu :collapsed="collapsed" :items="links[1]" orientation="vertical" tooltip
class="mt-auto" />
</template>
<template #footer="{ collapsed }">
<UserMenu :collapsed="collapsed" />
</template>
</UDashboardSidebar>
<UDashboardSearch :groups="groups" />
<slot />
<NotificationsSlideover />
</UDashboardGroup>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<UDashboardPanel id="abwesenheiten">
<template #header>
<UDashboardNavbar title="Abwesenheiten">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="px-4 py-6 sm:px-6">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-lg text-slate-700">
Diese Seite ist in Vorbereitung.
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,848 +0,0 @@
<script setup lang="ts">
import { format } from 'date-fns'
// Simple, API-ready Dienstplan view without fixed shift types.
type Employee = {
id: string
name: string
}
type ShiftAssignment = {
employee_id: string
name: string
}
type Shift = {
id: string
date: string // YYYY-MM-DD
start_time: string // HH:mm
end_time: string // HH:mm
label?: string
notes?: string
assignments: ShiftAssignment[]
}
type Branch = {
id: string
name: string
}
type AvailabilityWindow = {
start_time: string
end_time: string
}
type ScheduleResult = {
assigned: { shift_id: string; employee_id: string }[]
unfilled: { shift_id: string; missing: number }[]
conflicts: { shift_id: string; employee_id: string; reason: string }[]
}
const employees: Employee[] = [
{ id: 'emp_anna', name: 'Anna Keller' },
{ id: 'emp_mehmet', name: 'Mehmet Aydin' },
{ id: 'emp_lisa', name: 'Lisa Schmidt' },
{ id: 'emp_anna2', name: 'Anna Keller2' },
{ id: 'emp_mehmet2', name: 'Mehmet Aydin2' },
{ id: 'emp_lisa2', name: 'Lisa Schmidt2' },
{ id: 'emp_anna3', name: 'Anna Keller3' },
{ id: 'emp_mehmet3', name: 'Mehmet Aydin3' },
{ id: 'emp_lisa3', name: 'Lisa Schmidt3' }
]
const mockAvailabilityRules: Record<string, Record<number, AvailabilityWindow[]>> = {
emp_anna: {
0: [{ start_time: '08:00', end_time: '16:00' }],
1: [{ start_time: '08:00', end_time: '16:00' }],
2: [{ start_time: '10:00', end_time: '18:00' }],
4: [{ start_time: '08:00', end_time: '14:00' }]
},
emp_mehmet: {
0: [{ start_time: '12:00', end_time: '20:00' }],
2: [{ start_time: '08:00', end_time: '12:00' }],
3: [{ start_time: '08:00', end_time: '16:00' }]
},
emp_lisa: {
1: [{ start_time: '06:00', end_time: '12:00' }],
4: [{ start_time: '12:00', end_time: '20:00' }]
}
}
const selectedBranchId = ref('branch_berlin')
const today = new Date()
const weekStart = ref(getWeekStart(today))
const selectedMobileDayIndex = ref(0)
const shifts = ref<Shift[]>([])
const weekDays = computed(() => {
const days: { date: Date; key: string; label: string }[] = []
for (let i = 0; i < 7; i += 1) {
const date = addDays(weekStart.value, i)
days.push({
date,
key: toISODate(date),
label: formatDayHeader(date)
})
}
return days
})
const weekLabel = computed(() => {
const start = weekStart.value
const end = addDays(start, 6)
return formatWeekRange(start, end)
})
const shiftsByDay = computed(() => {
const map = new Map<string, Shift[]>()
for (const day of weekDays.value) {
map.set(day.key, [])
}
for (const shift of shifts.value) {
const list = map.get(shift.date)
if (list) {
list.push(shift)
}
}
for (const list of map.values()) {
list.sort((a, b) => timeToMinutes(a.start_time) - timeToMinutes(b.start_time))
}
return map
})
const conflictsByShiftId = computed(() => {
const map = new Map<string, Set<string>>()
const byDate = new Map<string, Shift[]>()
for (const shift of shifts.value) {
const list = byDate.get(shift.date) ?? []
list.push(shift)
byDate.set(shift.date, list)
}
for (const dayShifts of byDate.values()) {
const employeeAssignments = new Map<string, Shift[]>()
for (const shift of dayShifts) {
for (const assignment of shift.assignments) {
const list = employeeAssignments.get(assignment.employee_id) ?? []
list.push(shift)
employeeAssignments.set(assignment.employee_id, list)
}
}
for (const [employeeId, assignedShifts] of employeeAssignments.entries()) {
for (let i = 0; i < assignedShifts.length; i += 1) {
for (let j = i + 1; j < assignedShifts.length; j += 1) {
if (rangesOverlap(assignedShifts[i], assignedShifts[j])) {
const firstSet = map.get(assignedShifts[i].id) ?? new Set<string>()
const secondSet = map.get(assignedShifts[j].id) ?? new Set<string>()
firstSet.add(employeeId)
secondSet.add(employeeId)
map.set(assignedShifts[i].id, firstSet)
map.set(assignedShifts[j].id, secondSet)
}
}
}
}
}
return map
})
const isShiftConflicted = (shift: Shift) => {
return Boolean(conflictsByShiftId.value.get(shift.id)?.size)
}
const isEmployeeConflicted = (shift: Shift, employeeId: string) => {
return conflictsByShiftId.value.get(shift.id)?.has(employeeId) ?? false
}
const goToPreviousWeek = () => {
weekStart.value = addDays(weekStart.value, -7)
selectedMobileDayIndex.value = 0
loadShifts()
}
const goToCurrentWeek = () => {
weekStart.value = getWeekStart(new Date())
selectedMobileDayIndex.value = 0
loadShifts()
}
const goToNextWeek = () => {
weekStart.value = addDays(weekStart.value, 7)
selectedMobileDayIndex.value = 0
loadShifts()
}
const isAutoScheduleModalOpen = ref(false)
const autoScheduleForm = reactive({
from: toISODate(weekStart.value),
to: toISODate(addDays(weekStart.value, 6)),
options: {
respect_availability: true,
avoid_overlaps: true,
fair_distribution: true
}
})
const autoScheduleResult = ref<ScheduleResult | null>(null)
const isAutoScheduleLoading = ref(false)
const mockScheduleResult = (currentShifts: Shift[]): ScheduleResult => {
const assigned = currentShifts
.filter((shift) => shift.assignments.length === 0)
.map((shift) => ({ shift_id: shift.id, employee_id: employees[0]?.id ?? 'emp_anna' }))
const unfilled = currentShifts
.filter((shift, index) => index % 5 === 0)
.map((shift) => ({ shift_id: shift.id, missing: 1 }))
const conflicts = currentShifts
.filter((shift, index) => index % 7 === 0)
.map((shift) => ({ shift_id: shift.id, employee_id: employees[1]?.id ?? 'emp_mehmet', reason: 'OVERLAP' }))
return { assigned, unfilled, conflicts }
}
const openAutoScheduleModal = () => {
autoScheduleForm.from = toISODate(weekStart.value)
autoScheduleForm.to = toISODate(addDays(weekStart.value, 6))
autoScheduleResult.value = null
isAutoScheduleModalOpen.value = true
}
const runAutoSchedule = async (apply: boolean) => {
isAutoScheduleLoading.value = true
try {
const result = mockScheduleResult(shifts.value)
autoScheduleResult.value = result
if (apply) {
for (const item of result.assigned) {
const shift = shifts.value.find((entry) => entry.id === item.shift_id)
if (!shift) {
continue
}
if (shift.assignments.some((assignment) => assignment.employee_id === item.employee_id)) {
continue
}
const employee = employees.find((entry) => entry.id === item.employee_id)
shift.assignments.push({
employee_id: item.employee_id,
name: employee?.name ?? 'Mitarbeiter'
})
}
}
} finally {
isAutoScheduleLoading.value = false
}
}
const unfilledShiftIds = computed(() => new Set(autoScheduleResult.value?.unfilled.map((item) => item.shift_id) ?? []))
const conflictShiftIds = computed(() => new Set(autoScheduleResult.value?.conflicts.map((item) => item.shift_id) ?? []))
watch(selectedBranchId, () => {
loadShifts()
})
const loadShifts = () => {
// TODO: Replace with API call when backend is ready.
// Example: GET /shifts/branches/{branch_id}?from=YYYY-MM-DD&to=YYYY-MM-DD
shifts.value = buildMockShifts(weekStart.value)
}
loadShifts()
const isShiftModalOpen = ref(false)
const shiftForm = reactive({
date: toISODate(today),
start_time: '08:00',
end_time: '16:00',
label: '',
notes: ''
})
const openShiftModal = (date?: string) => {
shiftForm.date = date ?? toISODate(weekStart.value)
shiftForm.start_time = '08:00'
shiftForm.end_time = '16:00'
shiftForm.label = ''
shiftForm.notes = ''
isShiftModalOpen.value = true
}
const saveShift = () => {
const newShift: Shift = {
id: `shift_${Math.random().toString(36).slice(2, 9)}`,
date: shiftForm.date,
start_time: shiftForm.start_time,
end_time: shiftForm.end_time,
label: shiftForm.label || undefined,
notes: shiftForm.notes || undefined,
assignments: []
}
shifts.value.push(newShift)
isShiftModalOpen.value = false
}
const isAssignModalOpen = ref(false)
const assignContext = ref<Shift | null>(null)
const employeeSearch = ref('')
const selectedEmployeeIds = ref<string[]>([])
const openAssignModal = (shift: Shift) => {
assignContext.value = shift
selectedEmployeeIds.value = shift.assignments.map((assignment) => assignment.employee_id)
employeeSearch.value = ''
isAssignModalOpen.value = true
}
const closeAssignModal = () => {
isAssignModalOpen.value = false
assignContext.value = null
selectedEmployeeIds.value = []
}
const filteredEmployees = computed(() => {
const query = employeeSearch.value.trim().toLowerCase()
if (!query) {
return employees
}
return employees.filter((employee) => employee.name.toLowerCase().includes(query))
})
const weekdayIndex = (value: string) => {
const date = new Date(`${value}T00:00:00`)
const day = date.getDay()
return (day + 6) % 7
}
const availabilityStatus = (employeeId: string, shift: Shift | null) => {
if (!shift) {
return { status: 'unknown', partial: [] as AvailabilityWindow[] }
}
const rules = mockAvailabilityRules[employeeId]
if (!rules) {
return { status: 'unknown', partial: [] as AvailabilityWindow[] }
}
const dayRules = rules[weekdayIndex(shift.date)]
if (!dayRules || dayRules.length === 0) {
return { status: 'unknown', partial: [] as AvailabilityWindow[] }
}
const shiftStart = timeToMinutes(shift.start_time)
let shiftEnd = timeToMinutes(shift.end_time)
if (shiftEnd <= shiftStart) {
shiftEnd += 24 * 60
}
const partial: AvailabilityWindow[] = []
for (const window of dayRules) {
let windowStart = timeToMinutes(window.start_time)
let windowEnd = timeToMinutes(window.end_time)
if (windowEnd <= windowStart) {
windowEnd += 24 * 60
}
if (windowStart <= shiftStart && windowEnd >= shiftEnd) {
return { status: 'available', partial: [] }
}
if (windowStart < shiftEnd && shiftStart < windowEnd) {
const overlapStart = Math.max(windowStart, shiftStart)
const overlapEnd = Math.min(windowEnd, shiftEnd)
const formatMinutes = (value: number) => {
const normalized = value % (24 * 60)
const hours = Math.floor(normalized / 60)
const minutes = normalized % 60
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`
}
partial.push({
start_time: formatMinutes(overlapStart),
end_time: formatMinutes(overlapEnd)
})
}
}
return { status: partial.length ? 'partial' : 'unavailable', partial }
}
const groupedEmployees = computed(() => {
const groups = {
available: [] as Employee[],
partial: [] as (Employee & { partial: AvailabilityWindow[] })[],
unavailable: [] as Employee[],
unknown: [] as Employee[]
}
for (const employee of filteredEmployees.value) {
const result = availabilityStatus(employee.id, assignContext.value)
if (result.status === 'partial') {
groups.partial.push({ ...employee, partial: result.partial })
} else {
groups[result.status].push(employee)
}
}
return groups
})
const saveAssignments = () => {
if (!assignContext.value) {
return
}
const updatedAssignments = selectedEmployeeIds.value
.map((id) => {
const employee = employees.find((item) => item.id === id)
return employee ? { employee_id: employee.id, name: employee.name } : null
})
.filter(Boolean) as ShiftAssignment[]
assignContext.value.assignments = updatedAssignments
// TODO: Replace with API call when backend is ready.
// Example: POST /shifts/{shift_id}/assignments with X-Org-Id header.
closeAssignModal()
}
const removeAssignment = (shift: Shift, employeeId: string) => {
shift.assignments = shift.assignments.filter((assignment) => assignment.employee_id !== employeeId)
// TODO: Replace with API call when backend is ready.
// Example: POST /shifts/{shift_id}/assignments with updated list or a DELETE endpoint.
}
const mobileDay = computed(() => weekDays.value[selectedMobileDayIndex.value])
const goToPreviousDay = () => {
if (selectedMobileDayIndex.value > 0) {
selectedMobileDayIndex.value -= 1
}
}
const goToNextDay = () => {
if (selectedMobileDayIndex.value < 6) {
selectedMobileDayIndex.value += 1
}
}
function buildMockShifts(start: Date): Shift[] {
const monday = toISODate(start)
const tuesday = toISODate(addDays(start, 1))
const wednesday = toISODate(addDays(start, 2))
return [
{
id: 'shift_early',
date: monday,
start_time: '04:00',
end_time: '12:00',
label: 'Früh',
assignments: [{ employee_id: 'emp_anna', name: 'Anna Keller' }]
},
{
id: 'shift_late',
date: monday,
start_time: '12:00',
end_time: '20:00',
label: 'Spät',
assignments: [{ employee_id: 'emp_lisa', name: 'Lisa Schmidt' }]
},
{
id: 'shift_sale',
date: wednesday,
start_time: '12:00',
end_time: '18:00',
label: 'Verkauf',
assignments: [{ employee_id: 'emp_mehmet', name: 'Mehmet Aydin' }]
},
{
id: 'shift_mid',
date: wednesday,
start_time: '12:00',
end_time: '20:00',
label: 'Spät',
assignments: [{ employee_id: 'emp_mehmet', name: 'Mehmet Aydin' }]
},
{
id: 'shift_short',
date: "2026-01-29",
start_time: '09:00',
end_time: '15:00',
label: 'Service',
assignments: []
}
]
}
function addDays(date: Date, days: number) {
const result = new Date(date)
result.setDate(result.getDate() + days)
return result
}
function getWeekStart(date: Date) {
const copy = new Date(date)
const day = copy.getDay()
const diff = (day === 0 ? -6 : 1) - day
copy.setDate(copy.getDate() + diff)
copy.setHours(0, 0, 0, 0)
return copy
}
function toISODate(date: Date) {
return format(date, 'yyyy-MM-dd')
}
function formatWeekRange(start: Date, end: Date) {
const startLabel = start.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
const endLabel = end.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' })
return `${startLabel} ${endLabel}`
}
function formatDayHeader(date: Date) {
return date.toLocaleDateString('de-DE', {
weekday: 'long',
day: '2-digit',
month: '2-digit'
})
}
function timeToMinutes(value: string) {
const [hours, minutes] = value.split(':').map((part) => Number(part))
return hours * 60 + minutes
}
function rangesOverlap(a: Shift, b: Shift) {
const startA = timeToMinutes(a.start_time)
let endA = timeToMinutes(a.end_time)
const startB = timeToMinutes(b.start_time)
let endB = timeToMinutes(b.end_time)
if (endA <= startA) {
endA += 24 * 60
}
if (endB <= startB) {
endB += 24 * 60
}
return startA < endB && startB < endA
}
</script>
<template>
<UDashboardPanel id="dienstplan">
<template #header>
<UDashboardNavbar title="Dienstplan">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<div class="border-b border-default px-4 py-5 sm:px-6">
<!-- WEB VIEW-->
<div class="hidden lg:block">
<div class="rounded-2xl border border-default bg-elevated p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-4">
<div>
<div class="text-sm font-semibold text-muted">Woche</div>
<div class="text-2xl font-semibold text-default">{{ weekLabel }}</div>
</div>
<div class="flex flex-wrap items-center gap-3">
<UButton size="lg" color="neutral" variant="outline" label="Vorige Woche"
@click="goToPreviousWeek" />
<UButton size="lg" color="neutral" variant="outline" label="Heute"
@click="goToCurrentWeek" />
<UButton size="lg" color="neutral" variant="outline" label="Nächste Woche"
@click="goToNextWeek" />
<UButton size="lg" color="neutral" variant="outline" label="Plan automatisch erstellen"
@click="openAutoScheduleModal" />
</div>
</div>
</div>
</div>
<!-- Mobile VIEW-->
<div class="space-y-4 lg:hidden">
<div class="rounded-2xl border border-default bg-elevated p-4">
<div class="flex items-center justify-between gap-3">
<UButton size="lg" color="neutral" variant="outline" label="Voriger Tag"
@click="goToPreviousDay" />
<div class="text-lg font-semibold text-default text-center">{{ mobileDay?.label }}</div>
<UButton size="lg" color="neutral" variant="outline" label="Nächster Tag"
@click="goToNextDay" />
</div>
</div>
</div>
</div>
</template>
<template #body>
<!-- WEB VIEW-->
<div class="space-y-6 px-4 py-1 sm:px-6 lg:block">
<div class="hidden lg:block">
<div class="max-h-[82vh] overflow-x-auto overflow-y-auto rounded-2xl p-4">
<!-- border border-default bg-elevated-->
<div class="min-w-490">
<div class="grid grid-cols-7 gap-4">
<div v-for="day in weekDays" :key="day.key"
class="min-w-[270px] rounded-2xl border border-default bg-elevated p-4">
<div class="border-b border-default pb-3 text-lg font-semibold text-default">
{{ day.label }}
</div>
<div class="mt-3">
<UButton size="lg" color="neutral" variant="outline" label="Schicht hinzufügen"
class="w-full" @click="openShiftModal(day.key)" />
</div>
<div class="mt-4 space-y-4">
<div v-for="shift in shiftsByDay.get(day.key) ?? []" :key="shift.id" :class="[
'rounded-xl border border-zinc-700/70 bg-zinc-900/70 p-4',
{
'border-yellow-400': unfilledShiftIds.has(shift.id),
'border-red-500': conflictShiftIds.has(shift.id)
}
]">
<div class="text-xl font-semibold text-default">
{{ shift.start_time }} {{ shift.end_time }}
</div>
<div v-if="shift.label" class="text-base font-semibold text-muted">
{{ shift.label }}
</div>
<div class="mt-3 flex flex-wrap gap-2">
<span v-for="assignment in shift.assignments"
:key="assignment.employee_id"
class="rounded-full border border-zinc-700/70 bg-muted/20 px-3 py-1 text-sm font-medium text-default">
{{ assignment.name }}
<span v-if="isEmployeeConflicted(shift, assignment.employee_id)"
class="ml-1 text-red-500"></span>
</span>
<span v-if="shift.assignments.length === 0"
class="text-sm text-muted">Noch
niemand
zugewiesen.</span>
</div>
<button type="button"
class="mt-3 text-sm font-semibold text-primary-400 underline"
@click="openAssignModal(shift)">
+ Mitarbeiter hinzufügen
</button>
<div v-if="isShiftConflicted(shift)"
class="mt-2 text-sm font-semibold text-red-500">
Überschneidung im Dienstplan
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- MOBILE VIEW-->
<div class="rounded-2xl border border-default bg-elevated p-4 lg:hidden">
<UButton size="lg" color="primary" label="Schicht hinzufügen" class="w-full"
@click="openShiftModal(mobileDay?.key)" />
<UButton size="lg" color="neutral" variant="outline" label="Plan automatisch erstellen"
class="mt-3 w-full" @click="openAutoScheduleModal" />
<div class="mt-4 space-y-4">
<div v-for="shift in shiftsByDay.get(mobileDay?.key ?? '') ?? []" :key="shift.id" :class="[
'rounded-xl border border-zinc-700/70 bg-zinc-900/70 p-4',
{
'border-yellow-400': unfilledShiftIds.has(shift.id),
'border-red-500': conflictShiftIds.has(shift.id)
}
]">
<div class="text-xl font-semibold text-default">
{{ shift.start_time }} {{ shift.end_time }}
</div>
<div v-if="shift.label" class="text-base font-semibold text-muted">
{{ shift.label }}
</div>
<div class="mt-3 flex flex-wrap gap-2">
<span v-for="assignment in shift.assignments" :key="assignment.employee_id"
class="rounded-full border border-zinc-700/70 bg-muted/20 px-3 py-1 text-sm font-medium text-default">
{{ assignment.name }}
<span v-if="isEmployeeConflicted(shift, assignment.employee_id)"
class="ml-1 text-red-500"></span>
</span>
<span v-if="shift.assignments.length === 0" class="text-sm text-muted">Noch niemand
zugewiesen.</span>
</div>
<button type="button" class="mt-3 text-sm font-semibold text-primary-400 underline"
@click="openAssignModal(shift)">
+ Mitarbeiter hinzufügen
</button>
<div v-if="isShiftConflicted(shift)" class="mt-2 text-sm font-semibold text-red-500">
Überschneidung im Dienstplan
</div>
</div>
</div>
</div>
<UModal v-model:open="isShiftModalOpen" title="Schicht hinzufügen">
<template #body>
<div class="space-y-4 text-base">
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Datum
<UInput v-model="shiftForm.date" type="date" size="lg" />
</label>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Beginn
<UInput v-model="shiftForm.start_time" type="time" size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Ende
<UInput v-model="shiftForm.end_time" type="time" size="lg" />
</label>
</div>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Bezeichnung (optional)
<UInput v-model="shiftForm.label" placeholder="z.B. Verkauf" size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Notiz (optional)
<textarea v-model="shiftForm.notes" rows="3"
class="w-full rounded-lg border border-default bg-elevated px-3 py-2 text-base text-default"></textarea>
</label>
<div class="flex flex-wrap justify-end gap-3">
<UButton size="lg" color="neutral" variant="outline" label="Abbrechen"
@click="isShiftModalOpen = false" />
<UButton size="lg" color="primary" label="Speichern" @click="saveShift" />
</div>
</div>
</template>
</UModal>
<UModal v-model:open="isAssignModalOpen" title="Mitarbeiter hinzufügen">
<template #body>
<div class="space-y-4 text-base">
<div class="rounded-xl border border-default bg-muted/30 px-4 py-3">
<div class="text-sm text-muted">Schicht</div>
<div class="text-lg font-semibold text-default">
{{ assignContext?.date }} · {{ assignContext?.start_time }} {{ assignContext?.end_time
}}
</div>
<div v-if="assignContext?.label" class="text-sm text-muted">{{ assignContext?.label }}</div>
</div>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Mitarbeiter suchen
<UInput v-model="employeeSearch" placeholder="Name eingeben" size="lg" />
</label>
<div
class="max-h-56 space-y-4 overflow-y-auto rounded-xl border border-default bg-elevated p-3">
<div v-if="groupedEmployees.available.length">
<div class="text-sm font-semibold text-muted">Verfügbar</div>
<label v-for="employee in groupedEmployees.available" :key="employee.id"
class="mt-2 flex items-center gap-3 rounded-lg px-2 py-2 text-base text-default">
<input v-model="selectedEmployeeIds" type="checkbox" :value="employee.id"
class="h-5 w-5 rounded border-default" />
<span>{{ employee.name }}</span>
</label>
</div>
<div v-if="groupedEmployees.partial.length">
<div class="text-sm font-semibold text-muted">Teilweise verfügbar</div>
<label v-for="employee in groupedEmployees.partial" :key="employee.id"
class="mt-2 flex items-center gap-3 rounded-lg px-2 py-2 text-base text-default">
<input v-model="selectedEmployeeIds" type="checkbox" :value="employee.id"
class="h-5 w-5 rounded border-default" />
<span>{{ employee.name }}</span>
<span class="text-sm text-muted">
({{ employee.partial.map((item) => `${item.start_time}${item.end_time}`).join(', ') }})
</span>
</label>
</div>
<div v-if="groupedEmployees.unavailable.length">
<div class="text-sm font-semibold text-muted">Nicht verfügbar</div>
<label v-for="employee in groupedEmployees.unavailable" :key="employee.id"
class="mt-2 flex items-center gap-3 rounded-lg px-2 py-2 text-base text-default">
<input v-model="selectedEmployeeIds" type="checkbox" :value="employee.id"
class="h-5 w-5 rounded border-default" />
<span>{{ employee.name }}</span>
</label>
</div>
<div v-if="groupedEmployees.unknown.length">
<div class="text-sm font-semibold text-muted">Unbekannt</div>
<label v-for="employee in groupedEmployees.unknown" :key="employee.id"
class="mt-2 flex items-center gap-3 rounded-lg px-2 py-2 text-base text-default">
<input v-model="selectedEmployeeIds" type="checkbox" :value="employee.id"
class="h-5 w-5 rounded border-default" />
<span>{{ employee.name }}</span>
</label>
</div>
</div>
<div class="flex flex-wrap justify-end gap-3">
<UButton size="lg" color="neutral" variant="outline" label="Abbrechen"
@click="closeAssignModal" />
<UButton size="lg" color="primary" label="Speichern" @click="saveAssignments" />
</div>
</div>
</template>
</UModal>
<UModal v-model:open="isAutoScheduleModalOpen" title="Plan automatisch erstellen">
<template #body>
<div class="space-y-6">
<div class="flex flex-wrap gap-4">
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Von
<UInput v-model="autoScheduleForm.from" type="date" size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Bis
<UInput v-model="autoScheduleForm.to" type="date" size="lg" />
</label>
</div>
<div class="rounded-xl border border-default bg-muted/30 p-4">
<div class="text-base font-semibold text-default">Optionen</div>
<div class="mt-3 space-y-3 text-base text-default">
<label class="flex items-center gap-3">
<input v-model="autoScheduleForm.options.respect_availability" type="checkbox"
class="h-5 w-5" />
Verfügbarkeiten beachten
</label>
<label class="flex items-center gap-3">
<input v-model="autoScheduleForm.options.avoid_overlaps" type="checkbox"
class="h-5 w-5" />
Überschneidungen vermeiden
</label>
<label class="flex items-center gap-3">
<input v-model="autoScheduleForm.options.fair_distribution" type="checkbox"
class="h-5 w-5" />
Fair verteilen
</label>
</div>
</div>
<div v-if="autoScheduleResult" class="rounded-xl border border-default bg-elevated p-4">
<div class="text-base font-semibold text-default">Ergebnis</div>
<div class="mt-2 text-lg font-semibold text-default">
{{ autoScheduleResult.assigned.length }} Schichten belegt
</div>
<div class="text-lg font-semibold text-default">
{{ autoScheduleResult.unfilled.length }} Schichten offen
</div>
</div>
<div class="flex flex-wrap gap-3">
<UButton size="lg" color="neutral" variant="outline" label="Abbrechen"
@click="isAutoScheduleModalOpen = false" />
<UButton size="lg" color="primary" label="Vorschlag erstellen"
:loading="isAutoScheduleLoading" @click="runAutoSchedule(false)" />
<UButton v-if="autoScheduleResult" size="lg" color="primary" variant="outline"
label="Übernehmen" :loading="isAutoScheduleLoading" @click="runAutoSchedule(true)" />
</div>
</div>
</template>
</UModal>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<UDashboardPanel id="filialen">
<template #header>
<UDashboardNavbar title="Filialen">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="px-4 py-6 sm:px-6">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-lg text-slate-700">
Diese Seite ist in Vorbereitung.
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,15 +1,120 @@
<template>
<UDashboardPanel id="home">
<template #header>
<UDashboardNavbar title="Home">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="p-4">
</div>
</template>
</UDashboardPanel>
<div class="relative min-h-screen overflow-hidden bg-slate-950 text-white">
<div class="pointer-events-none absolute inset-0">
<div class="absolute -left-32 top-24 h-80 w-80 rounded-full bg-emerald-400/20 blur-3xl" />
<div class="absolute -right-40 top-10 h-96 w-96 rounded-full bg-cyan-400/20 blur-3xl" />
<div class="absolute bottom-0 left-1/3 h-72 w-72 rounded-full bg-sky-500/10 blur-[110px]" />
<div class="absolute inset-0 bg-[radial-gradient(circle_at_top,_rgba(15,23,42,0.2),_rgba(2,6,23,0.9))]" />
</div>
<div class="relative mx-auto flex min-h-screen max-w-6xl flex-col justify-center gap-12 px-6 py-12 lg:flex-row lg:items-center">
<section class="flex-1 space-y-6 animate-rise">
<div class="inline-flex items-center gap-3 rounded-full border border-white/10 bg-white/5 px-4 py-2 text-xs uppercase tracking-[0.2em] text-white/70">
<span class="h-2 w-2 rounded-full bg-emerald-400 shadow-[0_0_16px_rgba(52,211,153,0.9)]" />
Shift-Ready
</div>
<h1 class="text-4xl font-semibold leading-tight sm:text-5xl">
Keep every shift aligned, effortless, and on time.
</h1>
<p class="max-w-xl text-base text-white/70">
Nautilus DeskTime is the calm command center for employee schedules. Secure access keeps the crew in sync while the app does the heavy lifting.
</p>
<div class="flex flex-wrap items-center gap-4 text-sm text-white/70">
<div class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1">
<span class="h-1.5 w-1.5 rounded-full bg-emerald-300" />
Live shift views
</div>
<div class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1">
<span class="h-1.5 w-1.5 rounded-full bg-sky-300" />
Rapid handoffs
</div>
<div class="flex items-center gap-2 rounded-full border border-white/10 bg-white/5 px-3 py-1">
<span class="h-1.5 w-1.5 rounded-full bg-cyan-300" />
Role clarity
</div>
</div>
</section>
<section class="flex w-full max-w-md flex-1 items-center justify-center">
<div class="w-full rounded-3xl border border-white/10 bg-white/10 p-8 shadow-[0_30px_60px_rgba(2,6,23,0.6)] backdrop-blur animate-rise-delayed">
<div class="space-y-2">
<p class="text-sm uppercase tracking-[0.3em] text-white/60">Welcome back</p>
<h2 class="text-2xl font-semibold">Log in to your shift hub</h2>
<p class="text-sm text-white/60">Use your company email and password to continue.</p>
</div>
<form class="mt-8 space-y-5">
<label class="block space-y-2 text-sm text-white/70">
<span>Email address</span>
<input
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-base text-white placeholder:text-white/40 focus:border-emerald-300 focus:outline-none focus:ring-2 focus:ring-emerald-300/40"
type="email"
name="email"
placeholder="you@company.com"
autocomplete="email"
required
/>
</label>
<label class="block space-y-2 text-sm text-white/70">
<span>Password</span>
<input
class="w-full rounded-2xl border border-white/10 bg-white/5 px-4 py-3 text-base text-white placeholder:text-white/40 focus:border-cyan-300 focus:outline-none focus:ring-2 focus:ring-cyan-300/40"
type="password"
name="password"
placeholder="••••••••"
autocomplete="current-password"
required
/>
</label>
<div class="flex items-center justify-between text-sm text-white/60">
<label class="inline-flex items-center gap-2">
<input class="h-4 w-4 rounded border-white/20 bg-white/10 text-emerald-300 focus:ring-emerald-300/40" type="checkbox" />
Remember me
</label>
<button class="text-sm font-medium text-emerald-200 hover:text-emerald-100" type="button">
Forgot password?
</button>
</div>
<button
class="group flex w-full items-center justify-center gap-3 rounded-2xl bg-emerald-300 px-4 py-3 text-sm font-semibold uppercase tracking-[0.25em] text-slate-900 transition hover:bg-emerald-200"
type="submit"
>
Log in
<span class="inline-flex h-7 w-7 items-center justify-center rounded-full bg-slate-900/10 text-slate-900 transition group-hover:translate-x-1">
</span>
</button>
</form>
<div class="mt-6 flex items-center justify-between text-xs text-white/50">
<span>Need access?</span>
<span class="text-white/70">Contact your manager</span>
</div>
</div>
</section>
</div>
</div>
</template>
<style scoped>
@keyframes rise-in {
from {
opacity: 0;
transform: translateY(18px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-rise {
animation: rise-in 0.8s ease-out both;
}
.animate-rise-delayed {
animation: rise-in 0.9s ease-out 0.2s both;
}
</style>

View File

@@ -1,12 +0,0 @@
<template>
<UDashboardPanel id="login">
<template #header>
<UDashboardNavbar title="Login">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body></template>
</UDashboardPanel>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<UDashboardPanel id="mitarbeiter">
<template #header>
<UDashboardNavbar title="Mitarbeiter">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="px-4 py-6 sm:px-6">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-lg text-slate-700">
Diese Seite ist in Vorbereitung.
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,19 +0,0 @@
<template>
<UDashboardPanel id="settings">
<template #header>
<UDashboardNavbar title="Einstellungen">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="px-4 py-6 sm:px-6">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-lg text-slate-700">
Allgemeine Einstellungen kommen hier hin.
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<UDashboardPanel id="settings-members">
<template #header>
<UDashboardNavbar title="Mitglieder">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="px-4 py-6 sm:px-6">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-lg text-slate-700">
Mitgliederverwaltung kommt hier hin.
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<UDashboardPanel id="settings-notifications">
<template #header>
<UDashboardNavbar title="Benachrichtigungen">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="px-4 py-6 sm:px-6">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-lg text-slate-700">
Benachrichtigungseinstellungen kommen hier hin.
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,18 +0,0 @@
<template>
<UDashboardPanel id="settings-security">
<template #header>
<UDashboardNavbar title="Sicherheit">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
</template>
<template #body>
<div class="px-4 py-6 sm:px-6">
<div class="rounded-xl border border-slate-200 bg-white p-6 text-lg text-slate-700">
Sicherheitseinstellungen kommen hier hin.
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,304 +0,0 @@
<script setup lang="ts">
import { addDays, format } from 'date-fns'
const buildMockRules = (employeeId: string): AvailabilityRule[] => [
{ id: `rule_${employeeId}_mon`, weekday: 0, start_time: '08:00', end_time: '16:00', is_available: true },
{ id: `rule_${employeeId}_tue`, weekday: 1, start_time: '08:00', end_time: '16:00', is_available: true },
{ id: `rule_${employeeId}_fri`, weekday: 4, start_time: '10:00', end_time: '18:00', is_available: true }
]
const buildMockOverrides = (): AvailabilityOverride[] => [
{ id: 'override_1', date: format(addDays(today, 2), 'yyyy-MM-dd'), start_time: '12:00', end_time: '16:00', is_available: false },
{ id: 'override_2', date: format(addDays(today, 5), 'yyyy-MM-dd'), start_time: '09:00', end_time: '14:00', is_available: true }
]
type AvailabilityRule = {
id: string
weekday: number
start_time: string
end_time: string
is_available: boolean
}
type AvailabilityOverride = {
id: string
date: string
start_time: string
end_time: string
is_available: boolean
}
type Branch = {
id: string
name: string
}
type Employee = {
id: string
name: string
}
const orgId = 'org_demo_001'
const branches: Branch[] = [
{ id: 'branch_berlin', name: 'Filiale Berlin Mitte' },
{ id: 'branch_hamburg', name: 'Filiale Hamburg Hafen' },
{ id: 'branch_munich', name: 'Filiale München Süd' }
]
const employees: Employee[] = [
{ id: 'emp_anna', name: 'Anna Keller' },
{ id: 'emp_mehmet', name: 'Mehmet Aydin' },
{ id: 'emp_lisa', name: 'Lisa Schmidt' }
]
const role = 'MANAGER'
const isManager = computed(() => role === 'MANAGER' || role === 'OWNER')
const selectedBranchId = ref(branches[0].id)
const selectedEmployeeId = ref(employees[0].id)
const activeTab = ref<'rules' | 'overrides'>('rules')
const rules = ref<AvailabilityRule[]>([])
const overrides = ref<AvailabilityOverride[]>([])
const today = new Date()
const overrideRange = reactive({
from: format(today, 'yyyy-MM-dd'),
to: format(addDays(today, 30), 'yyyy-MM-dd')
})
const weekdays = [
{ value: 0, label: 'Montag', short: 'Mo' },
{ value: 1, label: 'Dienstag', short: 'Di' },
{ value: 2, label: 'Mittwoch', short: 'Mi' },
{ value: 3, label: 'Donnerstag', short: 'Do' },
{ value: 4, label: 'Freitag', short: 'Fr' },
{ value: 5, label: 'Samstag', short: 'Sa' },
{ value: 6, label: 'Sonntag', short: 'So' }
]
const newRuleByWeekday = reactive<Record<number, { start_time: string; end_time: string }>>({
0: { start_time: '08:00', end_time: '16:00' },
1: { start_time: '08:00', end_time: '16:00' },
2: { start_time: '08:00', end_time: '16:00' },
3: { start_time: '08:00', end_time: '16:00' },
4: { start_time: '08:00', end_time: '16:00' },
5: { start_time: '08:00', end_time: '16:00' },
6: { start_time: '08:00', end_time: '16:00' }
})
const newOverride = reactive({
date: format(today, 'yyyy-MM-dd'),
start_time: '08:00',
end_time: '16:00',
is_available: true
})
const loadAvailability = async () => {
if (!selectedEmployeeId.value) {
return
}
rules.value = buildMockRules(selectedEmployeeId.value)
overrides.value = buildMockOverrides()
}
const addRule = async (weekday: number) => {
const entry = newRuleByWeekday[weekday]
rules.value.push({
id: `rule_${Math.random().toString(36).slice(2, 9)}`,
weekday,
start_time: entry.start_time,
end_time: entry.end_time,
is_available: true
})
}
const removeRule = async (ruleId: string) => {
rules.value = rules.value.filter((rule) => rule.id !== ruleId)
}
const addOverride = async () => {
overrides.value.push({
id: `override_${Math.random().toString(36).slice(2, 9)}`,
date: newOverride.date,
start_time: newOverride.start_time,
end_time: newOverride.end_time,
is_available: newOverride.is_available
})
}
const removeOverride = async (overrideId: string) => {
overrides.value = overrides.value.filter((entry) => entry.id !== overrideId)
}
watch([selectedEmployeeId, () => overrideRange.from, () => overrideRange.to], () => {
loadAvailability()
})
onMounted(() => {
loadAvailability()
})
</script>
<template>
<UDashboardPanel id="verfuegbarkeit">
<template #header>
<UDashboardNavbar title="Verfügbarkeit">
<template #leading>
<UDashboardSidebarCollapse />
</template>
</UDashboardNavbar>
<div class="border-b border-default px-4 py-5 sm:px-6">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<div class="text-base text-muted">Standardzeiten und Tagesausnahmen pflegen.</div>
</div>
</div>
<div class="mt-5 rounded-2xl border border-default bg-elevated p-4 shadow-sm">
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Filiale auswählen
<select v-model="selectedBranchId"
class="h-12 rounded-lg border border-default bg-elevated px-3 text-lg text-default focus:border-primary-400 focus:outline-none focus:ring-4 focus:ring-primary-900/30">
<option v-for="branch in branches" :key="branch.id" :value="branch.id">
{{ branch.name }}
</option>
</select>
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Mitarbeiter auswählen
<select v-model="selectedEmployeeId" :disabled="!isManager"
class="h-12 rounded-lg border border-default bg-elevated px-3 text-lg text-default focus:border-primary-400 focus:outline-none focus:ring-4 focus:ring-primary-900/30 disabled:opacity-60">
<option v-for="employee in employees" :key="employee.id" :value="employee.id">
{{ employee.name }}
</option>
</select>
</label>
<div class="flex flex-wrap gap-3">
<UButton size="lg" color="neutral" variant="outline" label="Aktualisieren"
@click="loadAvailability" />
</div>
</div>
</div>
</div>
</template>
<template #body>
<div class="max-h-[calc(100vh-260px)] space-y-6 overflow-y-auto px-4 py-6 sm:px-6">
<div class="flex flex-wrap gap-3">
<UButton size="lg" :color="activeTab === 'rules' ? 'primary' : 'neutral'"
:variant="activeTab === 'rules' ? 'solid' : 'outline'" label="Standardzeiten"
@click="activeTab = 'rules'" />
<UButton size="lg" :color="activeTab === 'overrides' ? 'primary' : 'neutral'"
:variant="activeTab === 'overrides' ? 'solid' : 'outline'" label="Tagesausnahmen"
@click="activeTab = 'overrides'" />
</div>
<div v-if="activeTab === 'rules'" class="space-y-6">
<div v-for="day in weekdays" :key="day.value"
class="rounded-2xl border border-default bg-elevated p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-muted">{{ day.short }}</div>
<div class="text-2xl font-semibold text-default">{{ day.label }}</div>
</div>
</div>
<div class="mt-4 space-y-3">
<div v-for="rule in rules.filter((entry) => entry.weekday === day.value)" :key="rule.id"
class="flex flex-col gap-3 rounded-xl border border-default bg-muted/30 p-3 lg:flex-row lg:items-center lg:justify-between">
<div class="text-lg font-semibold text-default">
{{ rule.start_time }} {{ rule.end_time }}
</div>
<UButton size="lg" color="neutral" variant="outline" label="Löschen"
@click="removeRule(rule.id)" />
</div>
<div
class="flex flex-col gap-3 rounded-xl border border-dashed border-default p-3 lg:flex-row lg:items-center lg:justify-between">
<div class="flex flex-wrap gap-3">
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Start
<UInput v-model="newRuleByWeekday[day.value].start_time" type="time"
size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Ende
<UInput v-model="newRuleByWeekday[day.value].end_time" type="time" size="lg" />
</label>
</div>
<UButton size="lg" color="primary" label="Zeit hinzufügen"
@click="addRule(day.value)" />
</div>
</div>
</div>
</div>
<div v-else class="space-y-6">
<div class="rounded-2xl border border-default bg-elevated p-4 shadow-sm">
<div class="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div class="flex flex-wrap gap-4">
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Von
<UInput v-model="overrideRange.from" type="date" size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Bis
<UInput v-model="overrideRange.to" type="date" size="lg" />
</label>
</div>
<UButton size="lg" color="neutral" variant="outline" label="Bereich laden"
@click="loadAvailability" />
</div>
</div>
<div class="rounded-2xl border border-default bg-elevated p-4 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<div class="text-sm font-semibold text-muted">Neue Ausnahme</div>
<div class="text-2xl font-semibold text-default">Tagesausnahme hinzufügen</div>
</div>
<UButton size="lg" color="primary" label="+ Ausnahme hinzufügen" @click="addOverride" />
</div>
<div class="mt-4 flex flex-wrap gap-4">
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Datum
<UInput v-model="newOverride.date" type="date" size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Start
<UInput v-model="newOverride.start_time" type="time" size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Ende
<UInput v-model="newOverride.end_time" type="time" size="lg" />
</label>
<label class="flex flex-col gap-2 text-base font-semibold text-default">
Typ
<select v-model="newOverride.is_available"
class="h-12 rounded-lg border border-default bg-elevated px-3 text-lg text-default focus:border-primary-400 focus:outline-none focus:ring-4 focus:ring-primary-900/30">
<option :value="true">Verfügbar</option>
<option :value="false">Nicht verfügbar</option>
</select>
</label>
</div>
</div>
<div class="space-y-3">
<div v-for="entry in overrides" :key="entry.id"
class="flex flex-col gap-3 rounded-xl border border-default bg-elevated p-4 shadow-sm lg:flex-row lg:items-center lg:justify-between">
<div class="text-lg font-semibold text-default">
{{ entry.date }} · {{ entry.start_time }} {{ entry.end_time }}
</div>
<div class="text-base font-semibold text-muted">
{{ entry.is_available ? 'Verfügbar' : 'Nicht verfügbar' }}
</div>
<UButton size="lg" color="neutral" variant="outline" label="Löschen"
@click="removeOverride(entry.id)" />
</div>
</div>
</div>
</div>
</template>
</UDashboardPanel>
</template>

View File

@@ -1,60 +0,0 @@
import type { AvatarProps } from '@nuxt/ui'
export type UserStatus = 'subscribed' | 'unsubscribed' | 'bounced'
export type SaleStatus = 'paid' | 'failed' | 'refunded'
export interface User {
id: number
name: string
email: string
avatar?: AvatarProps
status: UserStatus
location: string
}
export interface Mail {
id: number
unread?: boolean
from: User
subject: string
body: string
date: string
}
export interface Member {
name: string
username: string
role: 'member' | 'owner'
avatar: AvatarProps
}
export interface Stat {
title: string
icon: string
value: number | string
variation: number
formatter?: (value: number) => string
}
export interface Sale {
id: string
date: string
status: SaleStatus
email: string
amount: number
}
export interface Notification {
id: number
unread?: boolean
sender: User
body: string
date: string
}
export type Period = 'daily' | 'weekly' | 'monthly'
export interface Range {
start: Date
end: Date
}

View File

@@ -1,81 +0,0 @@
type ScheduleOptions = {
respect_availability: boolean
avoid_overlaps: boolean
fair_distribution: boolean
}
const getAuthToken = () => {
if (typeof window !== 'undefined') {
const token = window.localStorage.getItem('auth_token')
if (token) {
return token
}
}
return 'placeholder-token'
}
const buildHeaders = (orgId: string) => ({
'X-Org-Id': orgId,
Authorization: `Bearer ${getAuthToken()}`
})
export const getAvailabilityRules = (employeeId: string, orgId: string) => {
return $fetch(`/availability/employees/${employeeId}/rules`, {
method: 'GET',
headers: buildHeaders(orgId)
})
}
export const addAvailabilityRule = (employeeId: string, payload: any, orgId: string) => {
return $fetch(`/availability/employees/${employeeId}/rules`, {
method: 'POST',
headers: buildHeaders(orgId),
body: payload
})
}
export const deleteAvailabilityRule = (ruleId: string, orgId: string) => {
return $fetch(`/availability/rules/${ruleId}`, {
method: 'DELETE',
headers: buildHeaders(orgId)
})
}
export const getAvailabilityOverrides = (employeeId: string, from: string, to: string, orgId: string) => {
return $fetch(`/availability/employees/${employeeId}/overrides`, {
method: 'GET',
headers: buildHeaders(orgId),
query: { from, to }
})
}
export const addAvailabilityOverride = (employeeId: string, payload: any, orgId: string) => {
return $fetch(`/availability/employees/${employeeId}/overrides`, {
method: 'POST',
headers: buildHeaders(orgId),
body: payload
})
}
export const deleteAvailabilityOverride = (overrideId: string, orgId: string) => {
return $fetch(`/availability/overrides/${overrideId}`, {
method: 'DELETE',
headers: buildHeaders(orgId)
})
}
export const generateSchedule = (
branchId: string,
from: string,
to: string,
options: ScheduleOptions,
apply: boolean,
orgId: string
) => {
return $fetch(`/scheduling/branches/${branchId}/generate`, {
method: 'POST',
headers: buildHeaders(orgId),
query: { apply },
body: { from, to, mode: 'DRAFT', options }
})
}