🎨 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:
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>
|
||||
Reference in New Issue
Block a user