Compare commits
2 Commits
68c9ba90c5
...
1dcc49bdbe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dcc49bdbe | ||
|
|
598ad36146 |
@@ -1,2 +1,7 @@
|
||||
@import "tailwindcss";
|
||||
@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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -1,3 +0,0 @@
|
||||
export function useAppHead() {
|
||||
useHead({ titleTemplate: 'NautilusDesk · Dive School' })
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 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>
|
||||
</UDashboardPanel>
|
||||
</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>
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<template>
|
||||
<UDashboardPanel id="login">
|
||||
<template #header>
|
||||
<UDashboardNavbar title="Login">
|
||||
<template #leading>
|
||||
<UDashboardSidebarCollapse />
|
||||
</template>
|
||||
</UDashboardNavbar>
|
||||
</template>
|
||||
<template #body></template>
|
||||
</UDashboardPanel>
|
||||
</template>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
60
NautilusDeskTimeFrontend/app/types/index.d.ts
vendored
60
NautilusDeskTimeFrontend/app/types/index.d.ts
vendored
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user