🎨 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:
2026-02-20 15:18:06 +00:00
parent 82d388f555
commit 474c6d2470
27 changed files with 2328 additions and 2 deletions

16
src/App.vue Normal file
View 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
View 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
View 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;
}
}

View 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>

View 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>

View 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
View 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
View 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
View 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
}
})

View 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
View 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
View 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
View 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>

View 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
View 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>

View 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>

View 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
View 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
View File

@@ -0,0 +1,9 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_API_URL: string
}
interface ImportMeta {
readonly env: ImportMetaEnv
}