🎨 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