init
This commit is contained in:
26
.gitignore
vendored
Normal file
26
.gitignore
vendored
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
# Nuxt dev/build outputs
|
||||||
|
.output
|
||||||
|
.data
|
||||||
|
.nuxt
|
||||||
|
.nitro
|
||||||
|
.cache
|
||||||
|
dist
|
||||||
|
|
||||||
|
# Node dependencies
|
||||||
|
node_modules
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Misc
|
||||||
|
.DS_Store
|
||||||
|
.fleet
|
||||||
|
.idea
|
||||||
|
|
||||||
|
# Local env files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
|
.DS_Store
|
||||||
245
AGENTS.md
Normal file
245
AGENTS.md
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
# Agent Instructions — Codex (Nuxt 3 + FastAPI, Greenfield, Mock-First)
|
||||||
|
|
||||||
|
This repository contains a **Nuxt 3 frontend**.
|
||||||
|
A **FastAPI** backend will be developed in parallel (may be in a separate repo/service).
|
||||||
|
|
||||||
|
You are an automated coding agent (Codex).
|
||||||
|
|
||||||
|
Your job is to make **precise, minimal, correct changes** while strictly following these rules.
|
||||||
|
Do not guess. If required information is missing, stop and ask one clear question.
|
||||||
|
|
||||||
|
This is an early-stage project. Prefer **simple, explicit patterns**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Repository Structure
|
||||||
|
|
||||||
|
- Root directory:
|
||||||
|
- Deployment artifacts: `Dockerfile`, `deploy/docker-compose.yml`
|
||||||
|
- Nuxt application lives in `NautilusDeskTimeFrontend/`
|
||||||
|
|
||||||
|
- `NautilusDeskTimeFrontend/app/`
|
||||||
|
- Nuxt application source (pages, layouts, components, composables)
|
||||||
|
|
||||||
|
- `NautilusDeskTimeFrontend/server/`
|
||||||
|
- Nuxt/Nitro server code may exist
|
||||||
|
- Backend business logic belongs in FastAPI, not here
|
||||||
|
|
||||||
|
- `NautilusDeskTimeFrontend/public/`
|
||||||
|
- Static assets served as-is
|
||||||
|
|
||||||
|
- Generated output (DO NOT EDIT):
|
||||||
|
- `NautilusDeskTimeFrontend/.nuxt/`
|
||||||
|
- `NautilusDeskTimeFrontend/.output/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Commands (Frontend)
|
||||||
|
|
||||||
|
Run all frontend commands from `NautilusDeskTimeFrontend/`.
|
||||||
|
|
||||||
|
- `npm install`
|
||||||
|
- `npm run dev` (`http://localhost:3000`)
|
||||||
|
- `npm run build`
|
||||||
|
- `npm run preview`
|
||||||
|
- `npm run generate` (if used)
|
||||||
|
|
||||||
|
Do not modify build scripts unless explicitly instructed.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
Frontend:
|
||||||
|
- Nuxt 3
|
||||||
|
- Vue 3 (Composition API only)
|
||||||
|
- TypeScript
|
||||||
|
- Vite
|
||||||
|
- Tailwind CSS
|
||||||
|
- Plain CSS is allowed when needed
|
||||||
|
|
||||||
|
Backend (separate service):
|
||||||
|
- FastAPI
|
||||||
|
- JWT authentication
|
||||||
|
|
||||||
|
Do not introduce new dependencies unless explicitly requested.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Global Coding Rules (Strict)
|
||||||
|
|
||||||
|
- Prefer clarity over abstraction
|
||||||
|
- Keep diffs small and scoped
|
||||||
|
- Do not refactor unless asked
|
||||||
|
- Do not change behavior unless asked
|
||||||
|
- Do not remove code unless instructed
|
||||||
|
- Avoid speculative features
|
||||||
|
- Do not add comments explaining obvious code
|
||||||
|
|
||||||
|
When no established pattern exists yet:
|
||||||
|
- introduce a simple, conventional pattern
|
||||||
|
- apply it consistently
|
||||||
|
- do not over-engineer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Vue & Nuxt Conventions
|
||||||
|
|
||||||
|
### Components
|
||||||
|
- Use `<script setup lang="ts">`
|
||||||
|
- No Options API
|
||||||
|
- No class-based components
|
||||||
|
- Props and emits must be typed
|
||||||
|
- Avoid `any` and unsafe casts
|
||||||
|
- Components must do one job
|
||||||
|
|
||||||
|
### Pages & Routing
|
||||||
|
- Use Nuxt file-based routing
|
||||||
|
- Page files in `app/pages/` use lowercase filenames
|
||||||
|
- Use `definePageMeta` for layout, middleware, SEO
|
||||||
|
- Do not manually configure routes
|
||||||
|
|
||||||
|
### Composables
|
||||||
|
- Prefix composables with `use`
|
||||||
|
- No side effects at import time
|
||||||
|
- Return only what is required
|
||||||
|
- Keep UI concerns out of composables
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Backend Integration (FastAPI)
|
||||||
|
|
||||||
|
### Source of Truth
|
||||||
|
- Backend business logic will live in FastAPI
|
||||||
|
- Do not create new `/server/api/*` endpoints in Nuxt unless explicitly instructed
|
||||||
|
- Nuxt server code is infrastructure only (optional proxy/SSR helpers)
|
||||||
|
|
||||||
|
### API Contract & Mocking (IMPORTANT)
|
||||||
|
- The FastAPI API may not exist yet.
|
||||||
|
- If OpenAPI schema is not available, use **mock data** to build the frontend.
|
||||||
|
|
||||||
|
Rules for mock data:
|
||||||
|
- Mock data must be centralized in a single place (e.g. `app/mocks/`).
|
||||||
|
- Mocks should match likely real shapes (typed interfaces).
|
||||||
|
- Switching from mock to real API must be easy:
|
||||||
|
- Use a single API layer (e.g. `app/composables/useApi.ts` or `app/services/api/`).
|
||||||
|
- Pages/components must not directly embed mock fixtures.
|
||||||
|
- Prefer feature flags via runtime config (e.g. `useMockApi`) only if needed.
|
||||||
|
- Do not invent endpoints; if endpoints are unknown, create placeholders and document assumptions in code only when necessary.
|
||||||
|
|
||||||
|
### API Access
|
||||||
|
- Use `$fetch` for HTTP calls from composables/stores.
|
||||||
|
- Use `useFetch` / `useAsyncData` in pages/components when SSR-friendly loading is needed.
|
||||||
|
- Do not hardcode hostnames/ports.
|
||||||
|
|
||||||
|
### Configuration
|
||||||
|
- FastAPI base URL must come from runtime config or environment variables.
|
||||||
|
- If config keys do not exist yet, create them in `nuxt.config` using conventional names:
|
||||||
|
- `runtimeConfig.public.apiBaseUrl`
|
||||||
|
- Keep client-only values in `runtimeConfig.public`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Authentication (JWT)
|
||||||
|
|
||||||
|
JWT will be used for authentication.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- Do not assume token storage strategy unless specified.
|
||||||
|
- Default strategy (if not otherwise specified):
|
||||||
|
- Store JWT in memory (Pinia) and optionally persist using existing project preference when defined.
|
||||||
|
- Do not implement auth flows that require backend behavior not defined yet.
|
||||||
|
- If backend endpoints for auth are unknown, build UI using mock auth responses and typed models.
|
||||||
|
|
||||||
|
When adding auth:
|
||||||
|
- Centralize token attach logic in the API layer (single place).
|
||||||
|
- Do not sprinkle Authorization header logic across components.
|
||||||
|
- Use `Authorization: Bearer <token>` when making authenticated requests.
|
||||||
|
|
||||||
|
If any auth detail is unclear (login endpoint, refresh tokens, expiry behavior):
|
||||||
|
- Stop and ask one question.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
- Use Pinia only if needed and keep it minimal
|
||||||
|
- One store per domain
|
||||||
|
- Setup-style stores only
|
||||||
|
- Handle async errors explicitly
|
||||||
|
- Stores must not manipulate the DOM
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Styling
|
||||||
|
|
||||||
|
- Use Tailwind by default
|
||||||
|
- Plain CSS is allowed when needed
|
||||||
|
- Avoid inline styles unless unavoidable
|
||||||
|
- Keep `.vue` indentation at **4 spaces**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TypeScript Rules
|
||||||
|
|
||||||
|
- Be explicit with types
|
||||||
|
- Prefer narrowing over casting
|
||||||
|
- Avoid `any`
|
||||||
|
- Avoid `as unknown as`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing & Validation
|
||||||
|
|
||||||
|
- No test framework is configured yet
|
||||||
|
- Validate changes by running `npm run dev`
|
||||||
|
- Manually exercise affected routes/features
|
||||||
|
- If tests are added, document how to run them
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Git Rules (MANDATORY)
|
||||||
|
|
||||||
|
For every logical change:
|
||||||
|
1. Make the change
|
||||||
|
2. `git status` must be clean after commit
|
||||||
|
3. Commit with a concise imperative message
|
||||||
|
4. Push the commit
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- One logical change per commit
|
||||||
|
- Do not mix unrelated changes
|
||||||
|
- Do not leave uncommitted changes
|
||||||
|
|
||||||
|
If commit or push cannot be completed:
|
||||||
|
- Stop
|
||||||
|
- Explain why
|
||||||
|
- Ask one concise question
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Output Requirements
|
||||||
|
|
||||||
|
When responding with code changes:
|
||||||
|
- Modify only relevant files
|
||||||
|
- Do not include unchanged files
|
||||||
|
- Do not include explanations unless explicitly requested
|
||||||
|
- Do not include emojis or conversational text
|
||||||
|
- Prefer minimal diffs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Failure Handling
|
||||||
|
|
||||||
|
If a task cannot be completed safely:
|
||||||
|
- Stop
|
||||||
|
- State what is missing
|
||||||
|
- Ask one clear question
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a deterministic Nuxt 3 frontend coding agent.
|
||||||
|
Build against mock data when the API is not available.
|
||||||
|
Integrate with FastAPI via runtime-configured base URLs.
|
||||||
|
Use JWT auth via Bearer tokens when endpoints are defined.
|
||||||
|
Maintain clean git history with commit+push for each change.
|
||||||
31
Dockerfile
Normal file
31
Dockerfile
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# ---- build stage ----
|
||||||
|
FROM node:20-alpine AS build
|
||||||
|
WORKDIR /NautilusDesk
|
||||||
|
|
||||||
|
# Needed for node-gyp / native deps on Alpine
|
||||||
|
RUN apk add --no-cache python3 make g++ git
|
||||||
|
|
||||||
|
# Install deps
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install
|
||||||
|
|
||||||
|
# Copy source + build
|
||||||
|
COPY . .
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---- runtime stage ----
|
||||||
|
FROM node:20-alpine AS runtime
|
||||||
|
WORKDIR /NautilusDesk/app
|
||||||
|
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV PORT=3000
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
|
||||||
|
RUN addgroup -S nodegroup && adduser -S nodeuser -G nodegroup
|
||||||
|
|
||||||
|
COPY --from=build /NautilusDesk/.output ./.output
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
USER nodeuser
|
||||||
|
|
||||||
|
CMD ["node", ".output/server/index.mjs"]
|
||||||
75
NautilusDeskTimeFrontend/README.md
Normal file
75
NautilusDeskTimeFrontend/README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Nuxt Minimal Starter
|
||||||
|
|
||||||
|
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
Make sure to install dependencies:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn install
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Server
|
||||||
|
|
||||||
|
Start the development server on `http://localhost:3000`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn dev
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production
|
||||||
|
|
||||||
|
Build the application for production:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn build
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Locally preview production build:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# npm
|
||||||
|
npm run preview
|
||||||
|
|
||||||
|
# pnpm
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# yarn
|
||||||
|
yarn preview
|
||||||
|
|
||||||
|
# bun
|
||||||
|
bun run preview
|
||||||
|
```
|
||||||
|
|
||||||
|
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||||
8
NautilusDeskTimeFrontend/app/app.config.ts
Normal file
8
NautilusDeskTimeFrontend/app/app.config.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'zinc',
|
||||||
|
neutral: 'zinc'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
13
NautilusDeskTimeFrontend/app/app.vue
Normal file
13
NautilusDeskTimeFrontend/app/app.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<UMain>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</UMain>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
|
||||||
|
</script>
|
||||||
2
NautilusDeskTimeFrontend/app/assets/css/main.css
Normal file
2
NautilusDeskTimeFrontend/app/assets/css/main.css
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
28
NautilusDeskTimeFrontend/app/components/Header.vue
Normal file
28
NautilusDeskTimeFrontend/app/components/Header.vue
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
||||||
5
NautilusDeskTimeFrontend/app/components/Logo.vue
Normal file
5
NautilusDeskTimeFrontend/app/components/Logo.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink to="/" class="flex items-center gap-2">
|
||||||
|
<img src="/icon.png" alt="Logo" class="h-full w-auto" />
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<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>
|
||||||
81
NautilusDeskTimeFrontend/app/components/Menus/UserMenu.vue
Normal file
81
NautilusDeskTimeFrontend/app/components/Menus/UserMenu.vue
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<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>
|
||||||
3
NautilusDeskTimeFrontend/app/composables/useAppHead.ts
Normal file
3
NautilusDeskTimeFrontend/app/composables/useAppHead.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export function useAppHead() {
|
||||||
|
useHead({ titleTemplate: 'NautilusDesk · Dive School' })
|
||||||
|
}
|
||||||
25
NautilusDeskTimeFrontend/app/composables/useDashboard.ts
Normal file
25
NautilusDeskTimeFrontend/app/composables/useDashboard.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
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)
|
||||||
59
NautilusDeskTimeFrontend/app/composables/useShiftApi.ts
Normal file
59
NautilusDeskTimeFrontend/app/composables/useShiftApi.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
13
NautilusDeskTimeFrontend/app/error.vue
Normal file
13
NautilusDeskTimeFrontend/app/error.vue
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NuxtError } from '#app'
|
||||||
|
|
||||||
|
const props = defineProps<{ error: NuxtError }>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UError :error="{
|
||||||
|
statusCode: 404,
|
||||||
|
statusMessage: 'Page not found',
|
||||||
|
message: 'The page you are looking for does not exist.'
|
||||||
|
}" />
|
||||||
|
</template>
|
||||||
166
NautilusDeskTimeFrontend/app/layouts/default.vue
Normal file
166
NautilusDeskTimeFrontend/app/layouts/default.vue
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
|
// runs on every navigation
|
||||||
|
})
|
||||||
5
NautilusDeskTimeFrontend/app/middleware/auth.ts
Normal file
5
NautilusDeskTimeFrontend/app/middleware/auth.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
})
|
||||||
3
NautilusDeskTimeFrontend/app/middleware/setups.global.ts
Normal file
3
NautilusDeskTimeFrontend/app/middleware/setups.global.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export default defineNuxtRouteMiddleware(() => {
|
||||||
|
// runs on every navigation
|
||||||
|
})
|
||||||
18
NautilusDeskTimeFrontend/app/pages/abwesenheiten.vue
Normal file
18
NautilusDeskTimeFrontend/app/pages/abwesenheiten.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
848
NautilusDeskTimeFrontend/app/pages/dienstplan.vue
Normal file
848
NautilusDeskTimeFrontend/app/pages/dienstplan.vue
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
<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>
|
||||||
18
NautilusDeskTimeFrontend/app/pages/filialen.vue
Normal file
18
NautilusDeskTimeFrontend/app/pages/filialen.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
15
NautilusDeskTimeFrontend/app/pages/index.vue
Normal file
15
NautilusDeskTimeFrontend/app/pages/index.vue
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<template>
|
||||||
|
<UDashboardPanel id="home">
|
||||||
|
<template #header>
|
||||||
|
<UDashboardNavbar title="Home">
|
||||||
|
<template #leading>
|
||||||
|
<UDashboardSidebarCollapse />
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
</template>
|
||||||
|
<template #body>
|
||||||
|
<div class="p-4">
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UDashboardPanel>
|
||||||
|
</template>
|
||||||
12
NautilusDeskTimeFrontend/app/pages/login.vue
Normal file
12
NautilusDeskTimeFrontend/app/pages/login.vue
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<template>
|
||||||
|
<UDashboardPanel id="login">
|
||||||
|
<template #header>
|
||||||
|
<UDashboardNavbar title="Login">
|
||||||
|
<template #leading>
|
||||||
|
<UDashboardSidebarCollapse />
|
||||||
|
</template>
|
||||||
|
</UDashboardNavbar>
|
||||||
|
</template>
|
||||||
|
<template #body></template>
|
||||||
|
</UDashboardPanel>
|
||||||
|
</template>
|
||||||
18
NautilusDeskTimeFrontend/app/pages/mitarbeiter.vue
Normal file
18
NautilusDeskTimeFrontend/app/pages/mitarbeiter.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
19
NautilusDeskTimeFrontend/app/pages/settings/index.vue
Normal file
19
NautilusDeskTimeFrontend/app/pages/settings/index.vue
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<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>
|
||||||
18
NautilusDeskTimeFrontend/app/pages/settings/members.vue
Normal file
18
NautilusDeskTimeFrontend/app/pages/settings/members.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
18
NautilusDeskTimeFrontend/app/pages/settings/security.vue
Normal file
18
NautilusDeskTimeFrontend/app/pages/settings/security.vue
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
<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>
|
||||||
304
NautilusDeskTimeFrontend/app/pages/verfuegbarkeit.vue
Normal file
304
NautilusDeskTimeFrontend/app/pages/verfuegbarkeit.vue
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
<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
Normal file
60
NautilusDeskTimeFrontend/app/types/index.d.ts
vendored
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
81
NautilusDeskTimeFrontend/app/utils/api.ts
Normal file
81
NautilusDeskTimeFrontend/app/utils/api.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
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 }
|
||||||
|
})
|
||||||
|
}
|
||||||
34
NautilusDeskTimeFrontend/nuxt.config.ts
Normal file
34
NautilusDeskTimeFrontend/nuxt.config.ts
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
compatibilityDate: '2025-07-15',
|
||||||
|
devtools: {
|
||||||
|
enabled: false,
|
||||||
|
|
||||||
|
timeline: {
|
||||||
|
enabled: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
colorMode: {
|
||||||
|
preference: 'dark',
|
||||||
|
fallback: 'dark',
|
||||||
|
classSuffix: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
imports: {
|
||||||
|
presets: [
|
||||||
|
{
|
||||||
|
from: 'vue-i18n',
|
||||||
|
imports: ['useI18n'],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
modules: ['@nuxtjs/i18n', '@pinia/nuxt', '@nuxt/ui'],
|
||||||
|
css: ['~/assets/css/main.css'],
|
||||||
|
i18n: {
|
||||||
|
locales: [
|
||||||
|
{ code: 'en', language: 'en-US' },
|
||||||
|
{ code: 'de', language: 'de-DE' }
|
||||||
|
],
|
||||||
|
defaultLocale: 'en',
|
||||||
|
}
|
||||||
|
})
|
||||||
16586
NautilusDeskTimeFrontend/package-lock.json
generated
Normal file
16586
NautilusDeskTimeFrontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
NautilusDeskTimeFrontend/package.json
Normal file
27
NautilusDeskTimeFrontend/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "NautilusDesk",
|
||||||
|
"type": "module",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "nuxt build",
|
||||||
|
"dev": "nuxt dev",
|
||||||
|
"generate": "nuxt generate",
|
||||||
|
"preview": "nuxt preview",
|
||||||
|
"postinstall": "nuxt prepare"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@nuxt/ui": "^4.3.0",
|
||||||
|
"@nuxtjs/i18n": "^10.2.1",
|
||||||
|
"@pinia/nuxt": "^0.11.3",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"nuxt": "^4.2.2",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.25",
|
||||||
|
"vue-i18n": "^11.2.7",
|
||||||
|
"vue-router": "^4.6.4",
|
||||||
|
"zod": "^4.2.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@iconify-json/lucide": "^1.2.87"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
NautilusDeskTimeFrontend/public/favicon.ico
Normal file
BIN
NautilusDeskTimeFrontend/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
NautilusDeskTimeFrontend/public/icon.png
Normal file
BIN
NautilusDeskTimeFrontend/public/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.1 MiB |
2
NautilusDeskTimeFrontend/public/robots.txt
Normal file
2
NautilusDeskTimeFrontend/public/robots.txt
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
User-Agent: *
|
||||||
|
Disallow:
|
||||||
38
NautilusDeskTimeFrontend/server/api/notifications.get.ts
Normal file
38
NautilusDeskTimeFrontend/server/api/notifications.get.ts
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
export default defineEventHandler(() => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
unread: true,
|
||||||
|
sender: {
|
||||||
|
id: 1,
|
||||||
|
name: 'Anna Keller',
|
||||||
|
email: 'anna.keller@example.com',
|
||||||
|
avatar: {
|
||||||
|
src: '',
|
||||||
|
alt: 'Anna Keller'
|
||||||
|
},
|
||||||
|
status: 'subscribed',
|
||||||
|
location: 'Berlin'
|
||||||
|
},
|
||||||
|
body: 'Bitte die Frühschicht am Donnerstag prüfen.',
|
||||||
|
date: new Date().toISOString()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
unread: false,
|
||||||
|
sender: {
|
||||||
|
id: 2,
|
||||||
|
name: 'David Fischer',
|
||||||
|
email: 'david.fischer@example.com',
|
||||||
|
avatar: {
|
||||||
|
src: '',
|
||||||
|
alt: 'David Fischer'
|
||||||
|
},
|
||||||
|
status: 'subscribed',
|
||||||
|
location: 'Hamburg'
|
||||||
|
},
|
||||||
|
body: 'Neuer Dienstplan für nächste Woche verfügbar.',
|
||||||
|
date: new Date(Date.now() - 3600 * 1000 * 6).toISOString()
|
||||||
|
}
|
||||||
|
]
|
||||||
|
})
|
||||||
18
NautilusDeskTimeFrontend/tsconfig.json
Normal file
18
NautilusDeskTimeFrontend/tsconfig.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
// https://nuxt.com/docs/guide/concepts/typescript
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.server.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.shared.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./.nuxt/tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
11
deploy/docker-compose.yml
Normal file
11
deploy/docker-compose.yml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
services:
|
||||||
|
nuxt-app:
|
||||||
|
image: ${IMAGE}
|
||||||
|
container_name: nautilusdesk
|
||||||
|
restart: unless-stopped
|
||||||
|
networks:
|
||||||
|
- proxy
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy:
|
||||||
|
external: true
|
||||||
Reference in New Issue
Block a user