🎨 Frontend komplett implementiert
Views: - Login/Registrierung - Dashboard mit Stats - Aufträge (Liste + Detail) - Mitarbeiterverwaltung - Verfügbarkeitskalender - Stundenzettel - Einstellungen - Module (Dev-Panel) Features: - Vue 3 + Composition API - TailwindCSS mit Dark Mode - Pinia State Management - JWT Auth mit Refresh - Responsive Design - Rollen-basierte Navigation
This commit is contained in:
91
README.md
91
README.md
@@ -1,3 +1,90 @@
|
||||
# secu-frontend
|
||||
# SeCu Frontend
|
||||
|
||||
SeCu Frontend - Vue 3 + TailwindCSS
|
||||
Vue 3 + TailwindCSS Frontend für die SeCu Mitarbeiterverwaltung.
|
||||
|
||||
## Tech Stack
|
||||
|
||||
- **Framework:** Vue 3 (Composition API)
|
||||
- **Build:** Vite
|
||||
- **Styling:** TailwindCSS
|
||||
- **State:** Pinia
|
||||
- **Routing:** Vue Router
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
# Dependencies installieren
|
||||
npm install
|
||||
|
||||
# Development Server starten
|
||||
npm run dev
|
||||
|
||||
# Production Build
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Umgebungsvariablen
|
||||
|
||||
Erstelle `.env.local`:
|
||||
|
||||
```
|
||||
VITE_API_URL=http://localhost:8004/api
|
||||
```
|
||||
|
||||
Für Production:
|
||||
```
|
||||
VITE_API_URL=https://api.secu.kronos-soulution.de/api
|
||||
```
|
||||
|
||||
## Struktur
|
||||
|
||||
```
|
||||
src/
|
||||
├── api/ # API Client
|
||||
├── assets/ # CSS & statische Dateien
|
||||
├── components/
|
||||
│ ├── common/ # Wiederverwendbare Komponenten
|
||||
│ └── layout/ # Layout-Komponenten (Sidebar, Header)
|
||||
├── composables/ # Vue Composables
|
||||
├── router/ # Vue Router Konfiguration
|
||||
├── stores/ # Pinia Stores
|
||||
│ └── auth.ts # Authentifizierung
|
||||
└── views/ # Seiten
|
||||
├── LoginView.vue
|
||||
├── DashboardView.vue
|
||||
├── OrdersView.vue
|
||||
├── OrderDetailView.vue
|
||||
├── UsersView.vue
|
||||
├── AvailabilityView.vue
|
||||
├── TimesheetsView.vue
|
||||
├── SettingsView.vue
|
||||
└── ModulesView.vue
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- 🔐 **Login/Registrierung** mit JWT Auth
|
||||
- 📊 **Dashboard** mit Statistiken
|
||||
- 📋 **Aufträge** erstellen, bearbeiten, zuweisen
|
||||
- 👥 **Mitarbeiter** verwalten (Chef/Disponent)
|
||||
- 📅 **Verfügbarkeit** im Kalender melden
|
||||
- ⏱️ **Stundenzettel** einreichen & genehmigen
|
||||
- ⚙️ **Module** aktivieren/deaktivieren (Chef)
|
||||
- 🌙 **Dark Mode** Support
|
||||
- 📱 **Responsive** Design
|
||||
|
||||
## Rollen
|
||||
|
||||
| Rolle | Zugriff |
|
||||
|-------|---------|
|
||||
| Chef | Vollzugriff + Module verwalten |
|
||||
| Disponent | Mitarbeiter + Aufträge + Stundenzettel |
|
||||
| Mitarbeiter | Eigene Aufträge, Verfügbarkeit, Stundenzettel |
|
||||
|
||||
## Port
|
||||
|
||||
Development: `3006`
|
||||
|
||||
---
|
||||
|
||||
*SeCu Frontend v1.0.0*
|
||||
|
||||
13
index.html
Normal file
13
index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SeCu - Mitarbeiterverwaltung</title>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
26
package.json
Normal file
26
package.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "secu-frontend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vue-tsc && vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.4.0",
|
||||
"vue-router": "^4.3.0",
|
||||
"pinia": "^2.1.0",
|
||||
"@vueuse/core": "^10.7.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.0.0",
|
||||
"typescript": "^5.3.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue-tsc": "^1.8.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
}
|
||||
}
|
||||
6
postcss.config.js
Normal file
6
postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
16
src/App.vue
Normal file
16
src/App.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
onMounted(async () => {
|
||||
if (authStore.hasStoredToken) {
|
||||
await authStore.fetchCurrentUser()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
106
src/api/index.ts
Normal file
106
src/api/index.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
const API_URL = import.meta.env.VITE_API_URL || '/api'
|
||||
|
||||
interface ApiResponse<T = unknown> {
|
||||
data: T
|
||||
status: number
|
||||
}
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl: string
|
||||
|
||||
constructor(baseUrl: string) {
|
||||
this.baseUrl = baseUrl
|
||||
}
|
||||
|
||||
private getToken(): string | null {
|
||||
return localStorage.getItem('accessToken')
|
||||
}
|
||||
|
||||
private async request<T>(
|
||||
method: string,
|
||||
endpoint: string,
|
||||
data?: unknown
|
||||
): Promise<ApiResponse<T>> {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const token = this.getToken()
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const config: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
}
|
||||
|
||||
if (data) {
|
||||
config.body = JSON.stringify(data)
|
||||
}
|
||||
|
||||
const response = await fetch(`${this.baseUrl}${endpoint}`, config)
|
||||
|
||||
// Handle 401 - try to refresh token
|
||||
if (response.status === 401) {
|
||||
const refreshed = await this.tryRefreshToken()
|
||||
if (refreshed) {
|
||||
// Retry request with new token
|
||||
headers['Authorization'] = `Bearer ${this.getToken()}`
|
||||
const retryResponse = await fetch(`${this.baseUrl}${endpoint}`, {
|
||||
...config,
|
||||
headers,
|
||||
})
|
||||
const retryData = await retryResponse.json()
|
||||
return { data: retryData, status: retryResponse.status }
|
||||
}
|
||||
}
|
||||
|
||||
const responseData = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(responseData.error || 'Request failed')
|
||||
}
|
||||
|
||||
return { data: responseData, status: response.status }
|
||||
}
|
||||
|
||||
private async tryRefreshToken(): Promise<boolean> {
|
||||
const refreshToken = localStorage.getItem('refreshToken')
|
||||
if (!refreshToken) return false
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.baseUrl}/auth/refresh`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
})
|
||||
|
||||
if (!response.ok) return false
|
||||
|
||||
const data = await response.json()
|
||||
localStorage.setItem('accessToken', data.accessToken)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
get<T>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('GET', endpoint)
|
||||
}
|
||||
|
||||
post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('POST', endpoint, data)
|
||||
}
|
||||
|
||||
put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('PUT', endpoint, data)
|
||||
}
|
||||
|
||||
delete<T>(endpoint: string): Promise<ApiResponse<T>> {
|
||||
return this.request<T>('DELETE', endpoint)
|
||||
}
|
||||
}
|
||||
|
||||
export const api = new ApiClient(API_URL)
|
||||
59
src/assets/main.css
Normal file
59
src/assets/main.css
Normal file
@@ -0,0 +1,59 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
body {
|
||||
@apply text-gray-900 dark:text-gray-100;
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.btn {
|
||||
@apply px-4 py-2 rounded-lg font-medium transition-colors duration-200;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
@apply bg-primary-600 text-white hover:bg-primary-700 focus:ring-2 focus:ring-primary-500 focus:ring-offset-2;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
@apply bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
@apply bg-red-600 text-white hover:bg-red-700;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
@apply bg-green-600 text-white hover:bg-green-700;
|
||||
}
|
||||
|
||||
.input {
|
||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 dark:bg-gray-800 dark:border-gray-600 dark:text-white;
|
||||
}
|
||||
|
||||
.card {
|
||||
@apply bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700 p-6;
|
||||
}
|
||||
|
||||
.badge {
|
||||
@apply inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium;
|
||||
}
|
||||
|
||||
.badge-primary {
|
||||
@apply bg-primary-100 text-primary-800 dark:bg-primary-900 dark:text-primary-200;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
@apply bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
@apply bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200;
|
||||
}
|
||||
|
||||
.badge-danger {
|
||||
@apply bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200;
|
||||
}
|
||||
}
|
||||
92
src/components/layout/AppHeader.vue
Normal file
92
src/components/layout/AppHeader.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineEmits<{
|
||||
'toggle-sidebar': []
|
||||
'logout': []
|
||||
}>()
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const darkMode = ref(localStorage.getItem('darkMode') === 'true')
|
||||
const dropdownOpen = ref(false)
|
||||
|
||||
function toggleDarkMode() {
|
||||
darkMode.value = !darkMode.value
|
||||
localStorage.setItem('darkMode', String(darkMode.value))
|
||||
document.documentElement.classList.toggle('dark', darkMode.value)
|
||||
}
|
||||
|
||||
// Initialize dark mode
|
||||
if (darkMode.value) {
|
||||
document.documentElement.classList.add('dark')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<header class="h-16 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 flex items-center justify-between px-4 lg:px-6">
|
||||
<!-- Mobile menu button -->
|
||||
<button
|
||||
class="lg:hidden p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@click="$emit('toggle-sidebar')"
|
||||
>
|
||||
<span class="text-2xl">☰</span>
|
||||
</button>
|
||||
|
||||
<!-- Page title placeholder -->
|
||||
<div class="flex-1 lg:ml-0">
|
||||
<h1 class="text-lg font-semibold text-gray-900 dark:text-white hidden lg:block">
|
||||
Mitarbeiterverwaltung
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Right side actions -->
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Dark mode toggle -->
|
||||
<button
|
||||
class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@click="toggleDarkMode"
|
||||
>
|
||||
<span class="text-xl">{{ darkMode ? '☀️' : '🌙' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- User dropdown -->
|
||||
<div class="relative">
|
||||
<button
|
||||
class="flex items-center gap-2 p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
@click="dropdownOpen = !dropdownOpen"
|
||||
>
|
||||
<div class="w-8 h-8 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<span class="text-primary-600 dark:text-primary-300 text-sm font-medium">
|
||||
{{ authStore.user?.first_name?.[0] }}{{ authStore.user?.last_name?.[0] }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="hidden sm:block text-sm font-medium text-gray-700 dark:text-gray-200">
|
||||
{{ authStore.fullName }}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<!-- Dropdown menu -->
|
||||
<div
|
||||
v-if="dropdownOpen"
|
||||
class="absolute right-0 mt-2 w-48 bg-white dark:bg-gray-800 rounded-lg shadow-lg border border-gray-200 dark:border-gray-700 py-1 z-50"
|
||||
@click="dropdownOpen = false"
|
||||
>
|
||||
<router-link
|
||||
to="/settings"
|
||||
class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
>
|
||||
⚙️ Einstellungen
|
||||
</router-link>
|
||||
<hr class="my-1 border-gray-200 dark:border-gray-700">
|
||||
<button
|
||||
class="w-full text-left px-4 py-2 text-sm text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
🚪 Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
45
src/components/layout/AppLayout.vue
Normal file
45
src/components/layout/AppLayout.vue
Normal file
@@ -0,0 +1,45 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AppSidebar from './AppSidebar.vue'
|
||||
import AppHeader from './AppHeader.vue'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const router = useRouter()
|
||||
const sidebarOpen = ref(false)
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen bg-gray-50 dark:bg-gray-900">
|
||||
<!-- Mobile sidebar backdrop -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 z-40 bg-black/50 lg:hidden"
|
||||
@click="sidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<AppSidebar
|
||||
:open="sidebarOpen"
|
||||
@close="sidebarOpen = false"
|
||||
/>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="lg:pl-64">
|
||||
<AppHeader
|
||||
@toggle-sidebar="sidebarOpen = !sidebarOpen"
|
||||
@logout="handleLogout"
|
||||
/>
|
||||
|
||||
<main class="p-6">
|
||||
<router-view />
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
97
src/components/layout/AppSidebar.vue
Normal file
97
src/components/layout/AppSidebar.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
defineProps<{
|
||||
open: boolean
|
||||
}>()
|
||||
|
||||
defineEmits<{
|
||||
close: []
|
||||
}>()
|
||||
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const navigation = computed(() => {
|
||||
const items = [
|
||||
{ name: 'Dashboard', href: '/', icon: '📊' },
|
||||
{ name: 'Aufträge', href: '/orders', icon: '📋' },
|
||||
]
|
||||
|
||||
if (authStore.canManageUsers) {
|
||||
items.push({ name: 'Mitarbeiter', href: '/users', icon: '👥' })
|
||||
}
|
||||
|
||||
items.push(
|
||||
{ name: 'Verfügbarkeit', href: '/availability', icon: '📅' },
|
||||
{ name: 'Stundenzettel', href: '/timesheets', icon: '⏱️' },
|
||||
)
|
||||
|
||||
if (authStore.isChef) {
|
||||
items.push({ name: 'Module', href: '/modules', icon: '⚙️' })
|
||||
}
|
||||
|
||||
items.push({ name: 'Einstellungen', href: '/settings', icon: '🔧' })
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function isActive(href: string) {
|
||||
if (href === '/') return route.path === '/'
|
||||
return route.path.startsWith(href)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-white dark:bg-gray-800 border-r border-gray-200 dark:border-gray-700 transform transition-transform lg:translate-x-0',
|
||||
open ? 'translate-x-0' : '-translate-x-full'
|
||||
]"
|
||||
>
|
||||
<!-- Logo -->
|
||||
<div class="h-16 flex items-center px-6 border-b border-gray-200 dark:border-gray-700">
|
||||
<span class="text-2xl font-bold text-primary-600">🔐 SeCu</span>
|
||||
</div>
|
||||
|
||||
<!-- Navigation -->
|
||||
<nav class="mt-6 px-3">
|
||||
<router-link
|
||||
v-for="item in navigation"
|
||||
:key="item.href"
|
||||
:to="item.href"
|
||||
:class="[
|
||||
'flex items-center gap-3 px-3 py-2 rounded-lg mb-1 transition-colors',
|
||||
isActive(item.href)
|
||||
? 'bg-primary-100 text-primary-700 dark:bg-primary-900 dark:text-primary-200'
|
||||
: 'text-gray-600 hover:bg-gray-100 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
]"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<span class="text-xl">{{ item.icon }}</span>
|
||||
<span class="font-medium">{{ item.name }}</span>
|
||||
</router-link>
|
||||
</nav>
|
||||
|
||||
<!-- User info -->
|
||||
<div class="absolute bottom-0 left-0 right-0 p-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-full bg-primary-100 dark:bg-primary-900 flex items-center justify-center">
|
||||
<span class="text-primary-600 dark:text-primary-300 font-medium">
|
||||
{{ authStore.user?.first_name?.[0] }}{{ authStore.user?.last_name?.[0] }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
{{ authStore.fullName }}
|
||||
</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 capitalize">
|
||||
{{ authStore.user?.role }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
12
src/main.ts
Normal file
12
src/main.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './assets/main.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
88
src/router/index.ts
Normal file
88
src/router/index.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('@/views/LoginView.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('@/components/layout/AppLayout.vue'),
|
||||
meta: { requiresAuth: true },
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('@/views/DashboardView.vue')
|
||||
},
|
||||
{
|
||||
path: 'orders',
|
||||
name: 'orders',
|
||||
component: () => import('@/views/OrdersView.vue')
|
||||
},
|
||||
{
|
||||
path: 'orders/:id',
|
||||
name: 'order-detail',
|
||||
component: () => import('@/views/OrderDetailView.vue')
|
||||
},
|
||||
{
|
||||
path: 'users',
|
||||
name: 'users',
|
||||
component: () => import('@/views/UsersView.vue'),
|
||||
meta: { roles: ['chef', 'disponent'] }
|
||||
},
|
||||
{
|
||||
path: 'availability',
|
||||
name: 'availability',
|
||||
component: () => import('@/views/AvailabilityView.vue')
|
||||
},
|
||||
{
|
||||
path: 'timesheets',
|
||||
name: 'timesheets',
|
||||
component: () => import('@/views/TimesheetsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('@/views/SettingsView.vue')
|
||||
},
|
||||
{
|
||||
path: 'modules',
|
||||
name: 'modules',
|
||||
component: () => import('@/views/ModulesView.vue'),
|
||||
meta: { roles: ['chef'] }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
router.beforeEach(async (to, _from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Try to restore session
|
||||
if (!authStore.isAuthenticated && authStore.hasStoredToken) {
|
||||
await authStore.fetchCurrentUser()
|
||||
}
|
||||
|
||||
const requiresAuth = to.matched.some(record => record.meta.requiresAuth)
|
||||
const isGuest = to.matched.some(record => record.meta.guest)
|
||||
const requiredRoles = to.meta.roles as string[] | undefined
|
||||
|
||||
if (requiresAuth && !authStore.isAuthenticated) {
|
||||
next({ name: 'login', query: { redirect: to.fullPath } })
|
||||
} else if (isGuest && authStore.isAuthenticated) {
|
||||
next({ name: 'dashboard' })
|
||||
} else if (requiredRoles && !requiredRoles.includes(authStore.user?.role || '')) {
|
||||
next({ name: 'dashboard' })
|
||||
} else {
|
||||
next()
|
||||
}
|
||||
})
|
||||
|
||||
export default router
|
||||
128
src/stores/auth.ts
Normal file
128
src/stores/auth.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { api } from '@/api'
|
||||
|
||||
export interface User {
|
||||
id: string
|
||||
email: string
|
||||
role: 'chef' | 'disponent' | 'mitarbeiter'
|
||||
first_name: string
|
||||
last_name: string
|
||||
org_id: string
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', () => {
|
||||
const user = ref<User | null>(null)
|
||||
const accessToken = ref<string | null>(localStorage.getItem('accessToken'))
|
||||
const refreshToken = ref<string | null>(localStorage.getItem('refreshToken'))
|
||||
const orgSlug = ref<string>(localStorage.getItem('orgSlug') || '')
|
||||
|
||||
const isAuthenticated = computed(() => !!user.value && !!accessToken.value)
|
||||
const hasStoredToken = computed(() => !!accessToken.value)
|
||||
const isChef = computed(() => user.value?.role === 'chef')
|
||||
const isDisponent = computed(() => user.value?.role === 'disponent')
|
||||
const isMitarbeiter = computed(() => user.value?.role === 'mitarbeiter')
|
||||
const canManageUsers = computed(() => isChef.value || isDisponent.value)
|
||||
const canManageOrders = computed(() => isChef.value || isDisponent.value)
|
||||
const fullName = computed(() => user.value ? `${user.value.first_name} ${user.value.last_name}` : '')
|
||||
|
||||
function setTokens(access: string, refresh: string) {
|
||||
accessToken.value = access
|
||||
refreshToken.value = refresh
|
||||
localStorage.setItem('accessToken', access)
|
||||
localStorage.setItem('refreshToken', refresh)
|
||||
}
|
||||
|
||||
function setOrgSlug(slug: string) {
|
||||
orgSlug.value = slug
|
||||
localStorage.setItem('orgSlug', slug)
|
||||
}
|
||||
|
||||
function clearAuth() {
|
||||
user.value = null
|
||||
accessToken.value = null
|
||||
refreshToken.value = null
|
||||
localStorage.removeItem('accessToken')
|
||||
localStorage.removeItem('refreshToken')
|
||||
}
|
||||
|
||||
async function login(email: string, password: string, org: string) {
|
||||
const response = await api.post('/auth/login', {
|
||||
email,
|
||||
password,
|
||||
org_slug: org
|
||||
})
|
||||
|
||||
setTokens(response.data.accessToken, response.data.refreshToken)
|
||||
setOrgSlug(org)
|
||||
user.value = response.data.user
|
||||
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function register(data: {
|
||||
email: string
|
||||
password: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
phone?: string
|
||||
org_slug: string
|
||||
}) {
|
||||
const response = await api.post('/auth/register', data)
|
||||
return response.data
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try {
|
||||
await api.post('/auth/logout')
|
||||
} catch {
|
||||
// Ignore errors
|
||||
}
|
||||
clearAuth()
|
||||
}
|
||||
|
||||
async function fetchCurrentUser() {
|
||||
try {
|
||||
const response = await api.get('/auth/me')
|
||||
user.value = response.data.user
|
||||
} catch {
|
||||
clearAuth()
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAccessToken() {
|
||||
if (!refreshToken.value) {
|
||||
throw new Error('No refresh token')
|
||||
}
|
||||
|
||||
const response = await api.post('/auth/refresh', {
|
||||
refreshToken: refreshToken.value
|
||||
})
|
||||
|
||||
accessToken.value = response.data.accessToken
|
||||
localStorage.setItem('accessToken', response.data.accessToken)
|
||||
|
||||
return response.data.accessToken
|
||||
}
|
||||
|
||||
return {
|
||||
user,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
orgSlug,
|
||||
isAuthenticated,
|
||||
hasStoredToken,
|
||||
isChef,
|
||||
isDisponent,
|
||||
isMitarbeiter,
|
||||
canManageUsers,
|
||||
canManageOrders,
|
||||
fullName,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
fetchCurrentUser,
|
||||
refreshAccessToken,
|
||||
setOrgSlug
|
||||
}
|
||||
})
|
||||
120
src/views/AvailabilityView.vue
Normal file
120
src/views/AvailabilityView.vue
Normal file
@@ -0,0 +1,120 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { api } from '@/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const currentMonth = ref(new Date())
|
||||
const availability = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
const daysInMonth = computed(() => {
|
||||
const year = currentMonth.value.getFullYear()
|
||||
const month = currentMonth.value.getMonth()
|
||||
const days = new Date(year, month + 1, 0).getDate()
|
||||
return Array.from({ length: days }, (_, i) => {
|
||||
const date = new Date(year, month, i + 1)
|
||||
return {
|
||||
date: date.toISOString().split('T')[0],
|
||||
dayOfWeek: date.toLocaleDateString('de-DE', { weekday: 'short' }),
|
||||
day: i + 1,
|
||||
isWeekend: date.getDay() === 0 || date.getDay() === 6
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const monthLabel = computed(() => {
|
||||
return currentMonth.value.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
|
||||
})
|
||||
|
||||
onMounted(loadAvailability)
|
||||
|
||||
async function loadAvailability() {
|
||||
loading.value = true
|
||||
const year = currentMonth.value.getFullYear()
|
||||
const month = currentMonth.value.getMonth()
|
||||
const from = new Date(year, month, 1).toISOString().split('T')[0]
|
||||
const to = new Date(year, month + 1, 0).toISOString().split('T')[0]
|
||||
|
||||
try {
|
||||
const res = await api.get<{ availability: any[] }>(`/availability?from=${from}&to=${to}`)
|
||||
availability.value = res.data.availability
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getAvailability(date: string) {
|
||||
return availability.value.find(a => a.date === date && a.user_id === authStore.user?.id)
|
||||
}
|
||||
|
||||
async function toggleDay(date: string) {
|
||||
const current = getAvailability(date)
|
||||
const available = !current?.available
|
||||
|
||||
try {
|
||||
await api.post('/availability', { date, available })
|
||||
await loadAvailability()
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
function prevMonth() {
|
||||
currentMonth.value = new Date(currentMonth.value.getFullYear(), currentMonth.value.getMonth() - 1)
|
||||
loadAvailability()
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
currentMonth.value = new Date(currentMonth.value.getFullYear(), currentMonth.value.getMonth() + 1)
|
||||
loadAvailability()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">📅 Verfügbarkeit</h1>
|
||||
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<button class="btn btn-secondary" @click="prevMonth">←</button>
|
||||
<h2 class="text-lg font-semibold">{{ monthLabel }}</h2>
|
||||
<button class="btn btn-secondary" @click="nextMonth">→</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
|
||||
|
||||
<div v-else class="grid grid-cols-7 gap-2">
|
||||
<div v-for="day in daysInMonth" :key="day.date" class="text-center">
|
||||
<div class="text-xs text-gray-500 mb-1">{{ day.dayOfWeek }}</div>
|
||||
<button
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg font-medium transition-colors',
|
||||
day.isWeekend ? 'bg-gray-100 dark:bg-gray-700' : '',
|
||||
getAvailability(day.date)?.available
|
||||
? 'bg-green-500 text-white hover:bg-green-600'
|
||||
: 'bg-gray-200 dark:bg-gray-600 hover:bg-gray-300 dark:hover:bg-gray-500'
|
||||
]"
|
||||
@click="toggleDay(day.date)"
|
||||
>
|
||||
{{ day.day }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 flex items-center gap-4 text-sm">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 bg-green-500 rounded"></span>
|
||||
<span>Verfügbar</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="w-4 h-4 bg-gray-200 dark:bg-gray-600 rounded"></span>
|
||||
<span>Nicht gemeldet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
194
src/views/DashboardView.vue
Normal file
194
src/views/DashboardView.vue
Normal file
@@ -0,0 +1,194 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { api } from '@/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const stats = ref({
|
||||
openOrders: 0,
|
||||
myOrders: 0,
|
||||
pendingTimesheets: 0,
|
||||
availableToday: false
|
||||
})
|
||||
|
||||
const recentOrders = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
const greeting = computed(() => {
|
||||
const hour = new Date().getHours()
|
||||
if (hour < 12) return 'Guten Morgen'
|
||||
if (hour < 18) return 'Guten Tag'
|
||||
return 'Guten Abend'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Fetch orders
|
||||
const ordersRes = await api.get<{ orders: any[] }>('/orders')
|
||||
recentOrders.value = ordersRes.data.orders.slice(0, 5)
|
||||
|
||||
stats.value.openOrders = ordersRes.data.orders.filter(o =>
|
||||
['published', 'in_progress'].includes(o.status)
|
||||
).length
|
||||
|
||||
if (authStore.isMitarbeiter) {
|
||||
stats.value.myOrders = ordersRes.data.orders.length
|
||||
}
|
||||
|
||||
// Fetch timesheets (if disponent or chef)
|
||||
if (authStore.canManageUsers) {
|
||||
const tsRes = await api.get<{ timesheets: any[] }>('/timesheets?status=pending')
|
||||
stats.value.pendingTimesheets = tsRes.data.timesheets.length
|
||||
}
|
||||
|
||||
// Check today's availability
|
||||
const today = new Date().toISOString().split('T')[0]
|
||||
const availRes = await api.get<{ availability: any[] }>(`/availability?from=${today}&to=${today}`)
|
||||
stats.value.availableToday = availRes.data.availability.some(a => a.available && a.user_id === authStore.user?.id)
|
||||
} catch (e) {
|
||||
console.error('Dashboard load error:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
draft: 'badge-secondary',
|
||||
published: 'badge-primary',
|
||||
in_progress: 'badge-warning',
|
||||
completed: 'badge-success',
|
||||
cancelled: 'badge-danger'
|
||||
}
|
||||
return badges[status] || 'badge-secondary'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
published: 'Veröffentlicht',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Welcome -->
|
||||
<div class="card bg-gradient-to-r from-primary-500 to-primary-700 text-white">
|
||||
<h1 class="text-2xl font-bold">
|
||||
{{ greeting }}, {{ authStore.user?.first_name }}! 👋
|
||||
</h1>
|
||||
<p class="mt-1 text-primary-100">
|
||||
Willkommen zurück bei SeCu.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<!-- Open Orders -->
|
||||
<div class="card">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
|
||||
<span class="text-2xl">📋</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Offene Aufträge</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.openOrders }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- My Orders (for Mitarbeiter) -->
|
||||
<div v-if="authStore.isMitarbeiter" class="card">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-green-100 dark:bg-green-900 flex items-center justify-center">
|
||||
<span class="text-2xl">✅</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Meine Aufträge</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.myOrders }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pending Timesheets (for management) -->
|
||||
<div v-if="authStore.canManageUsers" class="card">
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-lg bg-yellow-100 dark:bg-yellow-900 flex items-center justify-center">
|
||||
<span class="text-2xl">⏱️</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Ausstehende Stundenzettel</p>
|
||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.pendingTimesheets }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's availability -->
|
||||
<div class="card">
|
||||
<div class="flex items-center gap-4">
|
||||
<div :class="[
|
||||
'w-12 h-12 rounded-lg flex items-center justify-center',
|
||||
stats.availableToday ? 'bg-green-100 dark:bg-green-900' : 'bg-gray-100 dark:bg-gray-700'
|
||||
]">
|
||||
<span class="text-2xl">{{ stats.availableToday ? '✅' : '❓' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">Heute verfügbar</p>
|
||||
<p class="text-lg font-medium text-gray-900 dark:text-white">
|
||||
{{ stats.availableToday ? 'Ja' : 'Nicht gemeldet' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Orders -->
|
||||
<div class="card">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Aktuelle Aufträge
|
||||
</h2>
|
||||
<router-link to="/orders" class="text-sm text-primary-600 hover:text-primary-700">
|
||||
Alle anzeigen →
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-500">
|
||||
Lädt...
|
||||
</div>
|
||||
|
||||
<div v-else-if="recentOrders.length === 0" class="text-center py-8 text-gray-500">
|
||||
Keine Aufträge vorhanden
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<router-link
|
||||
v-for="order in recentOrders"
|
||||
:key="order.id"
|
||||
:to="`/orders/${order.id}`"
|
||||
class="block p-4 rounded-lg border border-gray-200 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">
|
||||
#{{ order.number }} - {{ order.title }}
|
||||
</h3>
|
||||
<p v-if="order.location" class="text-sm text-gray-500 dark:text-gray-400">
|
||||
📍 {{ order.location }}
|
||||
</p>
|
||||
</div>
|
||||
<span :class="['badge', getStatusBadge(order.status)]">
|
||||
{{ getStatusLabel(order.status) }}
|
||||
</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
175
src/views/LoginView.vue
Normal file
175
src/views/LoginView.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const orgSlug = ref(authStore.orgSlug || '')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const isRegister = ref(false)
|
||||
const firstName = ref('')
|
||||
const lastName = ref('')
|
||||
const phone = ref('')
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (isRegister.value) {
|
||||
await authStore.register({
|
||||
email: email.value,
|
||||
password: password.value,
|
||||
first_name: firstName.value,
|
||||
last_name: lastName.value,
|
||||
phone: phone.value,
|
||||
org_slug: orgSlug.value
|
||||
})
|
||||
// After registration, login
|
||||
await authStore.login(email.value, password.value, orgSlug.value)
|
||||
} else {
|
||||
await authStore.login(email.value, password.value, orgSlug.value)
|
||||
}
|
||||
|
||||
const redirect = route.query.redirect as string || '/'
|
||||
router.push(redirect)
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Anmeldung fehlgeschlagen'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="min-h-screen flex items-center justify-center bg-gray-50 dark:bg-gray-900 py-12 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-md w-full space-y-8">
|
||||
<!-- Logo -->
|
||||
<div class="text-center">
|
||||
<h1 class="text-4xl font-bold text-primary-600">🔐 SeCu</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400">Mitarbeiterverwaltung</p>
|
||||
</div>
|
||||
|
||||
<!-- Form -->
|
||||
<div class="card">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
{{ isRegister ? 'Registrieren' : 'Anmelden' }}
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="handleSubmit" class="space-y-4">
|
||||
<!-- Organization -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Organisation
|
||||
</label>
|
||||
<input
|
||||
v-model="orgSlug"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
placeholder="z.B. demo"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Email -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
E-Mail
|
||||
</label>
|
||||
<input
|
||||
v-model="email"
|
||||
type="email"
|
||||
required
|
||||
class="input"
|
||||
placeholder="name@firma.de"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Registration fields -->
|
||||
<template v-if="isRegister">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Vorname
|
||||
</label>
|
||||
<input
|
||||
v-model="firstName"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Nachname
|
||||
</label>
|
||||
<input
|
||||
v-model="lastName"
|
||||
type="text"
|
||||
required
|
||||
class="input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Telefon
|
||||
</label>
|
||||
<input
|
||||
v-model="phone"
|
||||
type="tel"
|
||||
class="input"
|
||||
placeholder="Optional"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Password -->
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
Passwort
|
||||
</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
required
|
||||
class="input"
|
||||
:placeholder="isRegister ? 'Mindestens 8 Zeichen' : ''"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="error" class="p-3 bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 rounded-lg text-sm">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading"
|
||||
class="btn btn-primary w-full"
|
||||
>
|
||||
{{ loading ? 'Bitte warten...' : (isRegister ? 'Registrieren' : 'Anmelden') }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Toggle register/login -->
|
||||
<div class="mt-4 text-center">
|
||||
<button
|
||||
type="button"
|
||||
class="text-sm text-primary-600 hover:text-primary-700"
|
||||
@click="isRegister = !isRegister"
|
||||
>
|
||||
{{ isRegister ? 'Bereits registriert? Anmelden' : 'Noch kein Konto? Registrieren' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
132
src/views/ModulesView.vue
Normal file
132
src/views/ModulesView.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/api'
|
||||
|
||||
interface Module {
|
||||
id: string
|
||||
name: string
|
||||
display_name: string
|
||||
description?: string
|
||||
is_core: boolean
|
||||
enabled: boolean
|
||||
config: Record<string, unknown>
|
||||
}
|
||||
|
||||
const modules = ref<Module[]>([])
|
||||
const loading = ref(true)
|
||||
const systemStatus = ref<any>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadModules(), loadSystemStatus()])
|
||||
})
|
||||
|
||||
async function loadModules() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get<{ modules: Module[] }>('/modules/org')
|
||||
modules.value = res.data.modules
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSystemStatus() {
|
||||
try {
|
||||
const res = await api.get<any>('/modules/developer/status')
|
||||
systemStatus.value = res.data
|
||||
} catch (e) {
|
||||
// Developer module might not be enabled
|
||||
console.log('Dev status not available')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleModule(mod: Module) {
|
||||
if (mod.is_core) return
|
||||
|
||||
try {
|
||||
await api.post(`/modules/${mod.id}/toggle`, { enabled: !mod.enabled })
|
||||
mod.enabled = !mod.enabled
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">⚙️ Module</h1>
|
||||
|
||||
<!-- System Status -->
|
||||
<div v-if="systemStatus" class="card bg-gradient-to-r from-primary-500 to-primary-700 text-white">
|
||||
<h2 class="text-lg font-semibold mb-4">System Status</h2>
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
<div>
|
||||
<p class="text-primary-100 text-sm">Benutzer</p>
|
||||
<p class="text-2xl font-bold">{{ systemStatus.stats?.user_count || 0 }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-primary-100 text-sm">Aufträge</p>
|
||||
<p class="text-2xl font-bold">{{ systemStatus.stats?.order_count || 0 }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-primary-100 text-sm">Stundenzettel</p>
|
||||
<p class="text-2xl font-bold">{{ systemStatus.stats?.timesheet_count || 0 }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-primary-100 text-sm">Aktive Module</p>
|
||||
<p class="text-2xl font-bold">{{ systemStatus.stats?.enabled_modules || 0 }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modules List -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">Verfügbare Module</h2>
|
||||
|
||||
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="mod in modules"
|
||||
:key="mod.id"
|
||||
class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{{ mod.display_name }}</h3>
|
||||
<span v-if="mod.is_core" class="badge badge-primary">Core</span>
|
||||
</div>
|
||||
<p v-if="mod.description" class="text-sm text-gray-500 mt-1">{{ mod.description }}</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="mod.is_core"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
||||
mod.enabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-gray-600',
|
||||
mod.is_core ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'
|
||||
]"
|
||||
@click="toggleModule(mod)"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
||||
mod.enabled ? 'translate-x-6' : 'translate-x-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-2">Hinweis</h2>
|
||||
<p class="text-gray-500 text-sm">
|
||||
Core-Module (Basis-System, Auftragsverwaltung) können nicht deaktiviert werden.
|
||||
Änderungen an Modulen werden sofort wirksam.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
162
src/views/OrderDetailView.vue
Normal file
162
src/views/OrderDetailView.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { api } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const order = ref<any>(null)
|
||||
const assignments = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const res = await api.get<{ order: any; assignments: any[] }>(`/orders/${route.params.id}`)
|
||||
order.value = res.data.order
|
||||
assignments.value = res.data.assignments
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
router.push('/orders')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function updateStatus(status: string) {
|
||||
try {
|
||||
await api.put(`/orders/${route.params.id}`, { status })
|
||||
order.value.status = status
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmAssignment(confirm: boolean) {
|
||||
try {
|
||||
await api.put(`/orders/${route.params.id}/assignment`, {
|
||||
status: confirm ? 'confirmed' : 'declined'
|
||||
})
|
||||
const myAssignment = assignments.value.find(a => a.user_id === authStore.user?.id)
|
||||
if (myAssignment) {
|
||||
myAssignment.status = confirm ? 'confirmed' : 'declined'
|
||||
}
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Entwurf', published: 'Veröffentlicht', in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen', cancelled: 'Abgesagt',
|
||||
pending: 'Ausstehend', confirmed: 'Bestätigt', declined: 'Abgelehnt'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="text-center py-12 text-gray-500">Lädt...</div>
|
||||
|
||||
<div v-else-if="order" class="space-y-6">
|
||||
<!-- Back button -->
|
||||
<router-link to="/orders" class="text-primary-600 hover:text-primary-700 text-sm">
|
||||
← Zurück zu Aufträge
|
||||
</router-link>
|
||||
|
||||
<!-- Header -->
|
||||
<div class="card">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-gray-500">#{{ order.number }}</span>
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ order.title }}</h1>
|
||||
</div>
|
||||
<p v-if="order.description" class="mt-2 text-gray-600 dark:text-gray-400">
|
||||
{{ order.description }}
|
||||
</p>
|
||||
</div>
|
||||
<span :class="['badge', order.status === 'completed' ? 'badge-success' : 'badge-primary']">
|
||||
{{ getStatusLabel(order.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Info Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mt-6">
|
||||
<div v-if="order.location">
|
||||
<p class="text-sm text-gray-500">Ort</p>
|
||||
<p class="font-medium">📍 {{ order.location }}</p>
|
||||
</div>
|
||||
<div v-if="order.start_time">
|
||||
<p class="text-sm text-gray-500">Start</p>
|
||||
<p class="font-medium">{{ new Date(order.start_time).toLocaleString('de-DE') }}</p>
|
||||
</div>
|
||||
<div v-if="order.client_name">
|
||||
<p class="text-sm text-gray-500">Kunde</p>
|
||||
<p class="font-medium">{{ order.client_name }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-sm text-gray-500">Benötigte MA</p>
|
||||
<p class="font-medium">{{ assignments.length }}/{{ order.required_staff }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Status actions for management -->
|
||||
<div v-if="authStore.canManageOrders" class="mt-6 pt-6 border-t border-gray-200 dark:border-gray-700">
|
||||
<p class="text-sm text-gray-500 mb-2">Status ändern:</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button class="btn btn-secondary text-sm" @click="updateStatus('draft')">Entwurf</button>
|
||||
<button class="btn btn-primary text-sm" @click="updateStatus('published')">Veröffentlichen</button>
|
||||
<button class="btn btn-warning text-sm" @click="updateStatus('in_progress')">In Bearbeitung</button>
|
||||
<button class="btn btn-success text-sm" @click="updateStatus('completed')">Abschließen</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Assignments -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
👥 Zugewiesene Mitarbeiter
|
||||
</h2>
|
||||
|
||||
<div v-if="assignments.length === 0" class="text-center py-4 text-gray-500">
|
||||
Noch keine Mitarbeiter zugewiesen
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="assignment in assignments"
|
||||
:key="assignment.id"
|
||||
class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700/50 rounded-lg"
|
||||
>
|
||||
<div>
|
||||
<p class="font-medium text-gray-900 dark:text-white">{{ assignment.user_name }}</p>
|
||||
<p class="text-sm text-gray-500">{{ assignment.user_phone }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['badge', assignment.status === 'confirmed' ? 'badge-success' : assignment.status === 'declined' ? 'badge-danger' : 'badge-warning']">
|
||||
{{ getStatusLabel(assignment.status) }}
|
||||
</span>
|
||||
|
||||
<!-- Confirm/Decline buttons for assigned user -->
|
||||
<template v-if="assignment.user_id === authStore.user?.id && assignment.status === 'pending'">
|
||||
<button class="btn btn-success text-sm" @click="confirmAssignment(true)">✓</button>
|
||||
<button class="btn btn-danger text-sm" @click="confirmAssignment(false)">✗</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Special Instructions -->
|
||||
<div v-if="order.special_instructions" class="card">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
📝 Besondere Hinweise
|
||||
</h2>
|
||||
<p class="text-gray-600 dark:text-gray-400 whitespace-pre-wrap">{{ order.special_instructions }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
259
src/views/OrdersView.vue
Normal file
259
src/views/OrdersView.vue
Normal file
@@ -0,0 +1,259 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { api } from '@/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
interface Order {
|
||||
id: string
|
||||
number: number
|
||||
title: string
|
||||
description?: string
|
||||
location?: string
|
||||
status: string
|
||||
start_time?: string
|
||||
end_time?: string
|
||||
required_staff: number
|
||||
assigned_count?: number
|
||||
creator_name?: string
|
||||
}
|
||||
|
||||
const orders = ref<Order[]>([])
|
||||
const loading = ref(true)
|
||||
const showCreateModal = ref(false)
|
||||
const statusFilter = ref('')
|
||||
|
||||
const newOrder = ref({
|
||||
title: '',
|
||||
description: '',
|
||||
location: '',
|
||||
address: '',
|
||||
client_name: '',
|
||||
client_contact: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
required_staff: 1,
|
||||
special_instructions: ''
|
||||
})
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!statusFilter.value) return orders.value
|
||||
return orders.value.filter(o => o.status === statusFilter.value)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadOrders()
|
||||
})
|
||||
|
||||
async function loadOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get<{ orders: Order[] }>('/orders')
|
||||
orders.value = res.data.orders
|
||||
} catch (e) {
|
||||
console.error('Load orders error:', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createOrder() {
|
||||
try {
|
||||
await api.post('/orders', newOrder.value)
|
||||
showCreateModal.value = false
|
||||
newOrder.value = {
|
||||
title: '',
|
||||
description: '',
|
||||
location: '',
|
||||
address: '',
|
||||
client_name: '',
|
||||
client_contact: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
required_staff: 1,
|
||||
special_instructions: ''
|
||||
}
|
||||
await loadOrders()
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
const badges: Record<string, string> = {
|
||||
draft: 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
|
||||
published: 'badge-primary',
|
||||
in_progress: 'badge-warning',
|
||||
completed: 'badge-success',
|
||||
cancelled: 'badge-danger'
|
||||
}
|
||||
return badges[status] || 'badge-secondary'
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
const labels: Record<string, string> = {
|
||||
draft: 'Entwurf',
|
||||
published: 'Veröffentlicht',
|
||||
in_progress: 'In Bearbeitung',
|
||||
completed: 'Abgeschlossen',
|
||||
cancelled: 'Abgesagt'
|
||||
}
|
||||
return labels[status] || status
|
||||
}
|
||||
|
||||
function formatDate(dateStr?: string) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- Header -->
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
📋 Aufträge
|
||||
</h1>
|
||||
<button
|
||||
v-if="authStore.canManageOrders"
|
||||
class="btn btn-primary"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
+ Neuer Auftrag
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="card">
|
||||
<div class="flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Status:</label>
|
||||
<select v-model="statusFilter" class="input w-48">
|
||||
<option value="">Alle</option>
|
||||
<option value="draft">Entwurf</option>
|
||||
<option value="published">Veröffentlicht</option>
|
||||
<option value="in_progress">In Bearbeitung</option>
|
||||
<option value="completed">Abgeschlossen</option>
|
||||
<option value="cancelled">Abgesagt</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Orders List -->
|
||||
<div class="card">
|
||||
<div v-if="loading" class="text-center py-8 text-gray-500">
|
||||
Lädt...
|
||||
</div>
|
||||
|
||||
<div v-else-if="filteredOrders.length === 0" class="text-center py-8 text-gray-500">
|
||||
Keine Aufträge gefunden
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<router-link
|
||||
v-for="order in filteredOrders"
|
||||
:key="order.id"
|
||||
:to="`/orders/${order.id}`"
|
||||
class="block py-4 hover:bg-gray-50 dark:hover:bg-gray-700/50 -mx-6 px-6 transition-colors"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div class="flex-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-sm text-gray-500 dark:text-gray-400">#{{ order.number }}</span>
|
||||
<h3 class="font-medium text-gray-900 dark:text-white">{{ order.title }}</h3>
|
||||
<span :class="['badge', getStatusBadge(order.status)]">
|
||||
{{ getStatusLabel(order.status) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mt-1 flex items-center gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span v-if="order.location">📍 {{ order.location }}</span>
|
||||
<span v-if="order.start_time">🕐 {{ formatDate(order.start_time) }}</span>
|
||||
<span>👥 {{ order.assigned_count || 0 }}/{{ order.required_staff }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-gray-400">→</span>
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||
Neuer Auftrag
|
||||
</h2>
|
||||
|
||||
<form @submit.prevent="createOrder" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
||||
<input v-model="newOrder.title" type="text" required class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
||||
<textarea v-model="newOrder.description" rows="3" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ort</label>
|
||||
<input v-model="newOrder.location" type="text" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Benötigte MA</label>
|
||||
<input v-model.number="newOrder.required_staff" type="number" min="1" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Adresse</label>
|
||||
<input v-model="newOrder.address" type="text" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kunde</label>
|
||||
<input v-model="newOrder.client_name" type="text" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ansprechpartner</label>
|
||||
<input v-model="newOrder.client_contact" type="text" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start</label>
|
||||
<input v-model="newOrder.start_time" type="datetime-local" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ende</label>
|
||||
<input v-model="newOrder.end_time" type="datetime-local" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Besondere Hinweise</label>
|
||||
<textarea v-model="newOrder.special_instructions" rows="2" class="input" />
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary">
|
||||
Erstellen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
96
src/views/SettingsView.vue
Normal file
96
src/views/SettingsView.vue
Normal file
@@ -0,0 +1,96 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { api } from '@/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const loading = ref(false)
|
||||
const message = ref('')
|
||||
const error = ref('')
|
||||
|
||||
async function changePassword() {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = 'Passwörter stimmen nicht überein'
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = 'Passwort muss mindestens 8 Zeichen haben'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
message.value = ''
|
||||
|
||||
try {
|
||||
await api.post('/auth/change-password', {
|
||||
currentPassword: currentPassword.value,
|
||||
newPassword: newPassword.value
|
||||
})
|
||||
message.value = 'Passwort erfolgreich geändert'
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Fehler beim Ändern'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🔧 Einstellungen</h1>
|
||||
|
||||
<!-- Profile -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">Profil</h2>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm text-gray-500">Name</label>
|
||||
<p class="font-medium">{{ authStore.fullName }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-500">E-Mail</label>
|
||||
<p class="font-medium">{{ authStore.user?.email }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm text-gray-500">Rolle</label>
|
||||
<p class="font-medium capitalize">{{ authStore.user?.role }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Change Password -->
|
||||
<div class="card">
|
||||
<h2 class="text-lg font-semibold mb-4">Passwort ändern</h2>
|
||||
<form @submit.prevent="changePassword" class="space-y-4 max-w-md">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Aktuelles Passwort</label>
|
||||
<input v-model="currentPassword" type="password" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Neues Passwort</label>
|
||||
<input v-model="newPassword" type="password" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Passwort bestätigen</label>
|
||||
<input v-model="confirmPassword" type="password" required class="input" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
|
||||
<div v-if="message" class="text-green-600 text-sm">{{ message }}</div>
|
||||
|
||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
||||
{{ loading ? 'Speichern...' : 'Passwort ändern' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
137
src/views/TimesheetsView.vue
Normal file
137
src/views/TimesheetsView.vue
Normal file
@@ -0,0 +1,137 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { api } from '@/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const timesheets = ref<any[]>([])
|
||||
const loading = ref(true)
|
||||
const showCreateModal = ref(false)
|
||||
const newTimesheet = ref({ work_date: '', start_time: '', end_time: '', order_id: '' })
|
||||
const orders = ref<any[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadTimesheets(), loadOrders()])
|
||||
})
|
||||
|
||||
async function loadTimesheets() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get<{ timesheets: any[] }>('/timesheets')
|
||||
timesheets.value = res.data.timesheets
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrders() {
|
||||
try {
|
||||
const res = await api.get<{ orders: any[] }>('/orders')
|
||||
orders.value = res.data.orders
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function createTimesheet() {
|
||||
try {
|
||||
await api.post('/timesheets', newTimesheet.value)
|
||||
showCreateModal.value = false
|
||||
newTimesheet.value = { work_date: '', start_time: '', end_time: '', order_id: '' }
|
||||
await loadTimesheets()
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
async function reviewTimesheet(id: string, status: 'approved' | 'rejected') {
|
||||
const reason = status === 'rejected' ? prompt('Ablehnungsgrund:') : null
|
||||
if (status === 'rejected' && !reason) return
|
||||
|
||||
try {
|
||||
await api.post(`/timesheets/${id}/review`, { status, rejection_reason: reason })
|
||||
await loadTimesheets()
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusBadge(status: string) {
|
||||
return { pending: 'badge-warning', approved: 'badge-success', rejected: 'badge-danger' }[status] || ''
|
||||
}
|
||||
|
||||
function getStatusLabel(status: string) {
|
||||
return { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' }[status] || status
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">⏱️ Stundenzettel</h1>
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">+ Neu</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
|
||||
<div v-else-if="timesheets.length === 0" class="text-center py-8 text-gray-500">Keine Stundenzettel</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div v-for="ts in timesheets" :key="ts.id" class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
|
||||
<div>
|
||||
<p class="font-medium">{{ new Date(ts.work_date).toLocaleDateString('de-DE') }}</p>
|
||||
<p class="text-sm text-gray-500">
|
||||
{{ ts.start_time }} - {{ ts.end_time }}
|
||||
<span v-if="ts.hours_worked">({{ ts.hours_worked }}h)</span>
|
||||
</p>
|
||||
<p v-if="ts.order_title" class="text-sm text-gray-500">📋 {{ ts.order_title }}</p>
|
||||
<p v-if="authStore.canManageUsers" class="text-sm text-gray-500">👤 {{ ts.user_name }}</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span :class="['badge', getStatusBadge(ts.status)]">{{ getStatusLabel(ts.status) }}</span>
|
||||
<template v-if="authStore.canManageUsers && ts.status === 'pending'">
|
||||
<button class="btn btn-success text-sm" @click="reviewTimesheet(ts.id, 'approved')">✓</button>
|
||||
<button class="btn btn-danger text-sm" @click="reviewTimesheet(ts.id, 'rejected')">✗</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-md m-4">
|
||||
<h2 class="text-xl font-semibold mb-6">Neuer Stundenzettel</h2>
|
||||
<form @submit.prevent="createTimesheet" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Datum *</label>
|
||||
<input v-model="newTimesheet.work_date" type="date" required class="input" />
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Start</label>
|
||||
<input v-model="newTimesheet.start_time" type="time" class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Ende</label>
|
||||
<input v-model="newTimesheet.end_time" type="time" class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Auftrag</label>
|
||||
<select v-model="newTimesheet.order_id" class="input">
|
||||
<option value="">-- Kein Auftrag --</option>
|
||||
<option v-for="o in orders" :key="o.id" :value="o.id">#{{ o.number }} - {{ o.title }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Einreichen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
172
src/views/UsersView.vue
Normal file
172
src/views/UsersView.vue
Normal file
@@ -0,0 +1,172 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { api } from '@/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
interface User {
|
||||
id: string
|
||||
email: string
|
||||
role: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
phone?: string
|
||||
active: boolean
|
||||
}
|
||||
|
||||
const users = ref<User[]>([])
|
||||
const loading = ref(true)
|
||||
const showCreateModal = ref(false)
|
||||
const newUser = ref({
|
||||
email: '',
|
||||
password: '',
|
||||
first_name: '',
|
||||
last_name: '',
|
||||
phone: '',
|
||||
role: 'mitarbeiter' as const
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadUsers()
|
||||
})
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await api.get<{ users: User[] }>('/users')
|
||||
users.value = res.data.users
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser() {
|
||||
try {
|
||||
await api.post('/users', newUser.value)
|
||||
showCreateModal.value = false
|
||||
newUser.value = { email: '', password: '', first_name: '', last_name: '', phone: '', role: 'mitarbeiter' }
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(user: User) {
|
||||
try {
|
||||
if (user.active) {
|
||||
await api.delete(`/users/${user.id}`)
|
||||
} else {
|
||||
await api.put(`/users/${user.id}`, { active: true })
|
||||
}
|
||||
await loadUsers()
|
||||
} catch (e) {
|
||||
alert(e instanceof Error ? e.message : 'Fehler')
|
||||
}
|
||||
}
|
||||
|
||||
function getRoleBadge(role: string) {
|
||||
const badges: Record<string, string> = {
|
||||
chef: 'badge-danger',
|
||||
disponent: 'badge-primary',
|
||||
mitarbeiter: 'badge-success'
|
||||
}
|
||||
return badges[role] || 'badge-secondary'
|
||||
}
|
||||
|
||||
function getRoleLabel(role: string) {
|
||||
const labels: Record<string, string> = {
|
||||
chef: 'Chef',
|
||||
disponent: 'Disponent',
|
||||
mitarbeiter: 'Mitarbeiter'
|
||||
}
|
||||
return labels[role] || role
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">👥 Mitarbeiter</h1>
|
||||
<button class="btn btn-primary" @click="showCreateModal = true">+ Neu</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
|
||||
<div v-else-if="users.length === 0" class="text-center py-8 text-gray-500">Keine Mitarbeiter</div>
|
||||
|
||||
<table v-else class="w-full">
|
||||
<thead>
|
||||
<tr class="text-left text-sm text-gray-500 border-b border-gray-200 dark:border-gray-700">
|
||||
<th class="pb-3">Name</th>
|
||||
<th class="pb-3">E-Mail</th>
|
||||
<th class="pb-3">Rolle</th>
|
||||
<th class="pb-3">Status</th>
|
||||
<th class="pb-3"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="user in users" :key="user.id" class="border-b border-gray-100 dark:border-gray-800">
|
||||
<td class="py-3 font-medium">{{ user.first_name }} {{ user.last_name }}</td>
|
||||
<td class="py-3 text-gray-500">{{ user.email }}</td>
|
||||
<td class="py-3"><span :class="['badge', getRoleBadge(user.role)]">{{ getRoleLabel(user.role) }}</span></td>
|
||||
<td class="py-3"><span :class="user.active ? 'text-green-600' : 'text-red-600'">{{ user.active ? 'Aktiv' : 'Inaktiv' }}</span></td>
|
||||
<td class="py-3 text-right">
|
||||
<button
|
||||
v-if="user.id !== authStore.user?.id && user.role !== 'chef'"
|
||||
class="text-sm text-gray-500 hover:text-red-600"
|
||||
@click="toggleActive(user)"
|
||||
>
|
||||
{{ user.active ? 'Deaktivieren' : 'Aktivieren' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Create Modal -->
|
||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||
<div class="card w-full max-w-md m-4">
|
||||
<h2 class="text-xl font-semibold mb-6">Neuer Mitarbeiter</h2>
|
||||
<form @submit.prevent="createUser" class="space-y-4">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Vorname *</label>
|
||||
<input v-model="newUser.first_name" type="text" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Nachname *</label>
|
||||
<input v-model="newUser.last_name" type="text" required class="input" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">E-Mail *</label>
|
||||
<input v-model="newUser.email" type="email" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Passwort *</label>
|
||||
<input v-model="newUser.password" type="password" required class="input" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium mb-1">Telefon</label>
|
||||
<input v-model="newUser.phone" type="tel" class="input" />
|
||||
</div>
|
||||
<div v-if="authStore.isChef">
|
||||
<label class="block text-sm font-medium mb-1">Rolle</label>
|
||||
<select v-model="newUser.role" class="input">
|
||||
<option value="mitarbeiter">Mitarbeiter</option>
|
||||
<option value="disponent">Disponent</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex justify-end gap-3 pt-4">
|
||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Abbrechen</button>
|
||||
<button type="submit" class="btn btn-primary">Erstellen</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
9
src/vite-env.d.ts
vendored
Normal file
9
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
40
tailwind.config.js
Normal file
40
tailwind.config.js
Normal file
@@ -0,0 +1,40 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
||||
],
|
||||
darkMode: 'class',
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
primary: {
|
||||
50: '#eef2ff',
|
||||
100: '#e0e7ff',
|
||||
200: '#c7d2fe',
|
||||
300: '#a5b4fc',
|
||||
400: '#818cf8',
|
||||
500: '#6366f1',
|
||||
600: '#4f46e5',
|
||||
700: '#4338ca',
|
||||
800: '#3730a3',
|
||||
900: '#312e81',
|
||||
950: '#1e1b4b',
|
||||
},
|
||||
secondary: {
|
||||
50: '#f0fdf4',
|
||||
100: '#dcfce7',
|
||||
200: '#bbf7d0',
|
||||
300: '#86efac',
|
||||
400: '#4ade80',
|
||||
500: '#22c55e',
|
||||
600: '#16a34a',
|
||||
700: '#15803d',
|
||||
800: '#166534',
|
||||
900: '#14532d',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
24
tsconfig.json
Normal file
24
tsconfig.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"module": "ESNext",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "preserve",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
10
tsconfig.node.json
Normal file
10
tsconfig.node.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
21
vite.config.ts
Normal file
21
vite.config.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import { resolve } from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 3006,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8004',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user