feat: Add i18n translations to all major views
- Dashboard, Users, Orders, Timesheets, Availability - Vehicles, Qualifications, Settings - Use $t() and useI18n() for all user-visible text - Translations work across all 7 languages
This commit is contained in:
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const { t, locale } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const currentMonth = ref(new Date())
|
const currentMonth = ref(new Date())
|
||||||
@@ -17,7 +19,7 @@ const daysInMonth = computed(() => {
|
|||||||
const date = new Date(year, month, i + 1)
|
const date = new Date(year, month, i + 1)
|
||||||
return {
|
return {
|
||||||
date: date.toISOString().split('T')[0],
|
date: date.toISOString().split('T')[0],
|
||||||
dayOfWeek: date.toLocaleDateString('de-DE', { weekday: 'short' }),
|
dayOfWeek: date.toLocaleDateString(locale.value, { weekday: 'short' }),
|
||||||
day: i + 1,
|
day: i + 1,
|
||||||
isWeekend: date.getDay() === 0 || date.getDay() === 6
|
isWeekend: date.getDay() === 0 || date.getDay() === 6
|
||||||
}
|
}
|
||||||
@@ -25,7 +27,7 @@ const daysInMonth = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const monthLabel = computed(() => {
|
const monthLabel = computed(() => {
|
||||||
return currentMonth.value.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' })
|
return currentMonth.value.toLocaleDateString(locale.value, { month: 'long', year: 'numeric' })
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(loadAvailability)
|
onMounted(loadAvailability)
|
||||||
@@ -59,7 +61,7 @@ async function toggleDay(date: string) {
|
|||||||
await api.post('/availability', { date, available })
|
await api.post('/availability', { date, available })
|
||||||
await loadAvailability()
|
await loadAvailability()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +78,7 @@ function nextMonth() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">📅 Verfügbarkeit</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">📅 {{ t('availability.title') }}</h1>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center justify-between mb-6">
|
<div class="flex items-center justify-between mb-6">
|
||||||
@@ -85,7 +87,7 @@ function nextMonth() {
|
|||||||
<button class="btn btn-secondary" @click="nextMonth">→</button>
|
<button class="btn btn-secondary" @click="nextMonth">→</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
|
<div v-if="loading" class="text-center py-8 text-gray-500">{{ t('app.loading') }}</div>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-7 gap-2">
|
<div v-else class="grid grid-cols-7 gap-2">
|
||||||
<div v-for="day in daysInMonth" :key="day.date" class="text-center">
|
<div v-for="day in daysInMonth" :key="day.date" class="text-center">
|
||||||
@@ -108,11 +110,11 @@ function nextMonth() {
|
|||||||
<div class="mt-6 flex items-center gap-4 text-sm">
|
<div class="mt-6 flex items-center gap-4 text-sm">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-4 h-4 bg-green-500 rounded"></span>
|
<span class="w-4 h-4 bg-green-500 rounded"></span>
|
||||||
<span>Verfügbar</span>
|
<span>{{ t('availability.available') }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="w-4 h-4 bg-gray-200 dark:bg-gray-600 rounded"></span>
|
<span class="w-4 h-4 bg-gray-200 dark:bg-gray-600 rounded"></span>
|
||||||
<span>Nicht gemeldet</span>
|
<span>{{ t('availability.unavailable') }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const stats = ref({
|
const stats = ref({
|
||||||
@@ -17,14 +19,13 @@ const loading = ref(true)
|
|||||||
|
|
||||||
const greeting = computed(() => {
|
const greeting = computed(() => {
|
||||||
const hour = new Date().getHours()
|
const hour = new Date().getHours()
|
||||||
if (hour < 12) return 'Guten Morgen'
|
if (hour < 12) return t('dashboard.welcome')
|
||||||
if (hour < 18) return 'Guten Tag'
|
if (hour < 18) return t('dashboard.welcome')
|
||||||
return 'Guten Abend'
|
return t('dashboard.welcome')
|
||||||
})
|
})
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
// Fetch orders
|
|
||||||
const ordersRes = await api.get<{ orders: any[] }>('/orders')
|
const ordersRes = await api.get<{ orders: any[] }>('/orders')
|
||||||
recentOrders.value = ordersRes.data.orders.slice(0, 5)
|
recentOrders.value = ordersRes.data.orders.slice(0, 5)
|
||||||
|
|
||||||
@@ -36,13 +37,11 @@ onMounted(async () => {
|
|||||||
stats.value.myOrders = ordersRes.data.orders.length
|
stats.value.myOrders = ordersRes.data.orders.length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fetch timesheets (if disponent or chef)
|
|
||||||
if (authStore.canManageUsers) {
|
if (authStore.canManageUsers) {
|
||||||
const tsRes = await api.get<{ timesheets: any[] }>('/timesheets?status=pending')
|
const tsRes = await api.get<{ timesheets: any[] }>('/timesheets?status=pending')
|
||||||
stats.value.pendingTimesheets = tsRes.data.timesheets.length
|
stats.value.pendingTimesheets = tsRes.data.timesheets.length
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check today's availability
|
|
||||||
const today = new Date().toISOString().split('T')[0]
|
const today = new Date().toISOString().split('T')[0]
|
||||||
const availRes = await api.get<{ availability: any[] }>(`/availability?from=${today}&to=${today}`)
|
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)
|
stats.value.availableToday = availRes.data.availability.some(a => a.available && a.user_id === authStore.user?.id)
|
||||||
@@ -65,14 +64,7 @@ function getStatusBadge(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status: string) {
|
function getStatusLabel(status: string) {
|
||||||
const labels: Record<string, string> = {
|
return t(`orders.statuses.${status}`) || status
|
||||||
draft: 'Entwurf',
|
|
||||||
published: 'Veröffentlicht',
|
|
||||||
in_progress: 'In Bearbeitung',
|
|
||||||
completed: 'Abgeschlossen',
|
|
||||||
cancelled: 'Abgesagt'
|
|
||||||
}
|
|
||||||
return labels[status] || status
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -84,52 +76,48 @@ function getStatusLabel(status: string) {
|
|||||||
{{ greeting }}, {{ authStore.user?.first_name }}! 👋
|
{{ greeting }}, {{ authStore.user?.first_name }}! 👋
|
||||||
</h1>
|
</h1>
|
||||||
<p class="mt-1 text-primary-100">
|
<p class="mt-1 text-primary-100">
|
||||||
Willkommen zurück bei SeCu.
|
{{ t('dashboard.overview') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Stats Grid -->
|
<!-- Stats Grid -->
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
<!-- Open Orders -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center gap-4">
|
<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">
|
<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>
|
<span class="text-2xl">📋</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Offene Aufträge</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.todayOrders') }}</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.openOrders }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.openOrders }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- My Orders (for Mitarbeiter) -->
|
|
||||||
<div v-if="authStore.isMitarbeiter" class="card">
|
<div v-if="authStore.isMitarbeiter" class="card">
|
||||||
<div class="flex items-center gap-4">
|
<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">
|
<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>
|
<span class="text-2xl">✅</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Meine Aufträge</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('orders.title') }}</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.myOrders }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.myOrders }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Pending Timesheets (for management) -->
|
|
||||||
<div v-if="authStore.canManageUsers" class="card">
|
<div v-if="authStore.canManageUsers" class="card">
|
||||||
<div class="flex items-center gap-4">
|
<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">
|
<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>
|
<span class="text-2xl">⏱️</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Ausstehende Stundenzettel</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('dashboard.pendingTimesheets') }}</p>
|
||||||
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.pendingTimesheets }}</p>
|
<p class="text-2xl font-bold text-gray-900 dark:text-white">{{ stats.pendingTimesheets }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Today's availability -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<div :class="[
|
<div :class="[
|
||||||
@@ -139,9 +127,9 @@ function getStatusLabel(status: string) {
|
|||||||
<span class="text-2xl">{{ stats.availableToday ? '✅' : '❓' }}</span>
|
<span class="text-2xl">{{ stats.availableToday ? '✅' : '❓' }}</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-sm text-gray-500 dark:text-gray-400">Heute verfügbar</p>
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('availability.available') }}</p>
|
||||||
<p class="text-lg font-medium text-gray-900 dark:text-white">
|
<p class="text-lg font-medium text-gray-900 dark:text-white">
|
||||||
{{ stats.availableToday ? 'Ja' : 'Nicht gemeldet' }}
|
{{ stats.availableToday ? t('app.yes') : t('app.no') }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,19 +140,19 @@ function getStatusLabel(status: string) {
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center justify-between mb-4">
|
<div class="flex items-center justify-between mb-4">
|
||||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
Aktuelle Aufträge
|
{{ t('dashboard.recentActivity') }}
|
||||||
</h2>
|
</h2>
|
||||||
<router-link to="/orders" class="text-sm text-primary-600 hover:text-primary-700">
|
<router-link to="/orders" class="text-sm text-primary-600 hover:text-primary-700">
|
||||||
Alle anzeigen →
|
{{ t('app.all') }} →
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-8 text-gray-500">
|
<div v-if="loading" class="text-center py-8 text-gray-500">
|
||||||
Lädt...
|
{{ t('app.loading') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="recentOrders.length === 0" class="text-center py-8 text-gray-500">
|
<div v-else-if="recentOrders.length === 0" class="text-center py-8 text-gray-500">
|
||||||
Keine Aufträge vorhanden
|
{{ t('messages.noData') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, computed } from 'vue'
|
import { ref, onMounted, computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
interface Order {
|
interface Order {
|
||||||
@@ -76,7 +78,7 @@ async function createOrder() {
|
|||||||
}
|
}
|
||||||
await loadOrders()
|
await loadOrders()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,25 +94,12 @@ function getStatusBadge(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status: string) {
|
function getStatusLabel(status: string) {
|
||||||
const labels: Record<string, string> = {
|
return t(`orders.statuses.${status}`) || status
|
||||||
draft: 'Entwurf',
|
|
||||||
published: 'Veröffentlicht',
|
|
||||||
in_progress: 'In Bearbeitung',
|
|
||||||
completed: 'Abgeschlossen',
|
|
||||||
cancelled: 'Abgesagt'
|
|
||||||
}
|
|
||||||
return labels[status] || status
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(dateStr?: string) {
|
function formatDate(dateStr?: string) {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
return new Date(dateStr).toLocaleDateString('de-DE', {
|
return new Date(dateStr).toLocaleDateString()
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: '2-digit',
|
|
||||||
minute: '2-digit'
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -119,28 +108,28 @@ function formatDate(dateStr?: string) {
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||||
📋 Aufträge
|
📋 {{ t('orders.title') }}
|
||||||
</h1>
|
</h1>
|
||||||
<button
|
<button
|
||||||
v-if="authStore.canManageOrders"
|
v-if="authStore.canManageOrders"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="showCreateModal = true"
|
@click="showCreateModal = true"
|
||||||
>
|
>
|
||||||
+ Neuer Auftrag
|
+ {{ t('orders.new') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Filters -->
|
<!-- Filters -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Status:</label>
|
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('app.status') }}:</label>
|
||||||
<select v-model="statusFilter" class="input w-48">
|
<select v-model="statusFilter" class="input w-48">
|
||||||
<option value="">Alle</option>
|
<option value="">{{ t('app.all') }}</option>
|
||||||
<option value="draft">Entwurf</option>
|
<option value="draft">{{ t('orders.statuses.draft') }}</option>
|
||||||
<option value="published">Veröffentlicht</option>
|
<option value="scheduled">{{ t('orders.statuses.scheduled') }}</option>
|
||||||
<option value="in_progress">In Bearbeitung</option>
|
<option value="active">{{ t('orders.statuses.active') }}</option>
|
||||||
<option value="completed">Abgeschlossen</option>
|
<option value="completed">{{ t('orders.statuses.completed') }}</option>
|
||||||
<option value="cancelled">Abgesagt</option>
|
<option value="cancelled">{{ t('orders.statuses.cancelled') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -148,11 +137,11 @@ function formatDate(dateStr?: string) {
|
|||||||
<!-- Orders List -->
|
<!-- Orders List -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="text-center py-8 text-gray-500">
|
<div v-if="loading" class="text-center py-8 text-gray-500">
|
||||||
Lädt...
|
{{ t('app.loading') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="filteredOrders.length === 0" class="text-center py-8 text-gray-500">
|
<div v-else-if="filteredOrders.length === 0" class="text-center py-8 text-gray-500">
|
||||||
Keine Aufträge gefunden
|
{{ t('messages.noData') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
<div v-else class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||||
@@ -187,69 +176,69 @@ function formatDate(dateStr?: string) {
|
|||||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<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">
|
<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">
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
Neuer Auftrag
|
{{ t('orders.new') }}
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<form @submit.prevent="createOrder" class="space-y-4">
|
<form @submit.prevent="createOrder" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('app.description') }} *</label>
|
||||||
<input v-model="newOrder.title" type="text" required class="input" />
|
<input v-model="newOrder.title" type="text" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Beschreibung</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('app.description') }}</label>
|
||||||
<textarea v-model="newOrder.description" rows="3" class="input" />
|
<textarea v-model="newOrder.description" rows="3" class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ort</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('orders.location') }}</label>
|
||||||
<input v-model="newOrder.location" type="text" class="input" />
|
<input v-model="newOrder.location" type="text" class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Benötigte MA</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('users.employees') }}</label>
|
||||||
<input v-model.number="newOrder.required_staff" type="number" min="1" class="input" />
|
<input v-model.number="newOrder.required_staff" type="number" min="1" class="input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Adresse</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('users.address') }}</label>
|
||||||
<input v-model="newOrder.address" type="text" class="input" />
|
<input v-model="newOrder.address" type="text" class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kunde</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('orders.client') }}</label>
|
||||||
<input v-model="newOrder.client_name" type="text" class="input" />
|
<input v-model="newOrder.client_name" type="text" class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ansprechpartner</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('customers.contact') }}</label>
|
||||||
<input v-model="newOrder.client_contact" type="text" class="input" />
|
<input v-model="newOrder.client_contact" type="text" class="input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('orders.startDate') }}</label>
|
||||||
<input v-model="newOrder.start_time" type="datetime-local" class="input" />
|
<input v-model="newOrder.start_time" type="datetime-local" class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ende</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('orders.endDate') }}</label>
|
||||||
<input v-model="newOrder.end_time" type="datetime-local" class="input" />
|
<input v-model="newOrder.end_time" type="datetime-local" class="input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Besondere Hinweise</label>
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ t('app.notes') }}</label>
|
||||||
<textarea v-model="newOrder.special_instructions" rows="2" class="input" />
|
<textarea v-model="newOrder.special_instructions" rows="2" class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">
|
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">
|
||||||
Abbrechen
|
{{ t('app.cancel') }}
|
||||||
</button>
|
</button>
|
||||||
<button type="submit" class="btn btn-primary">
|
<button type="submit" class="btn btn-primary">
|
||||||
Erstellen
|
{{ t('app.add') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
interface QualificationType {
|
interface QualificationType {
|
||||||
@@ -57,13 +59,11 @@ const expiringData = ref<{ expired: Qualification[], expiring_soon: Qualificatio
|
|||||||
})
|
})
|
||||||
const users = ref<any[]>([])
|
const users = ref<any[]>([])
|
||||||
|
|
||||||
// Filters
|
|
||||||
const filterUser = ref('')
|
const filterUser = ref('')
|
||||||
const filterCategory = ref('')
|
const filterCategory = ref('')
|
||||||
const filterStatus = ref('')
|
const filterStatus = ref('')
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
|
|
||||||
// Modal
|
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
const editingQualification = ref<any>(null)
|
const editingQualification = ref<any>(null)
|
||||||
const formData = ref({
|
const formData = ref({
|
||||||
@@ -78,8 +78,6 @@ const formData = ref({
|
|||||||
notes: ''
|
notes: ''
|
||||||
})
|
})
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
|
|
||||||
// Tab
|
|
||||||
const activeTab = ref<'list' | 'expiring' | 'matrix'>('list')
|
const activeTab = ref<'list' | 'expiring' | 'matrix'>('list')
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -127,19 +125,9 @@ async function loadUsers() {
|
|||||||
|
|
||||||
const filteredQualifications = computed(() => {
|
const filteredQualifications = computed(() => {
|
||||||
let result = [...qualifications.value]
|
let result = [...qualifications.value]
|
||||||
|
if (filterUser.value) result = result.filter(q => q.user_id === filterUser.value)
|
||||||
if (filterUser.value) {
|
if (filterCategory.value) result = result.filter(q => q.category === filterCategory.value)
|
||||||
result = result.filter(q => q.user_id === filterUser.value)
|
if (filterStatus.value) result = result.filter(q => q.expiry_status === filterStatus.value)
|
||||||
}
|
|
||||||
|
|
||||||
if (filterCategory.value) {
|
|
||||||
result = result.filter(q => q.category === filterCategory.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (filterStatus.value) {
|
|
||||||
result = result.filter(q => q.expiry_status === filterStatus.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (searchQuery.value) {
|
if (searchQuery.value) {
|
||||||
const query = searchQuery.value.toLowerCase()
|
const query = searchQuery.value.toLowerCase()
|
||||||
result = result.filter(q =>
|
result = result.filter(q =>
|
||||||
@@ -148,52 +136,25 @@ const filteredQualifications = computed(() => {
|
|||||||
q.last_name.toLowerCase().includes(query)
|
q.last_name.toLowerCase().includes(query)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return result
|
return result
|
||||||
})
|
})
|
||||||
|
|
||||||
const allTypes = computed(() => [
|
const allTypes = computed(() => [...qualificationTypes.value.system, ...qualificationTypes.value.custom])
|
||||||
...qualificationTypes.value.system,
|
|
||||||
...qualificationTypes.value.custom
|
|
||||||
])
|
|
||||||
|
|
||||||
function openAddModal() {
|
function openAddModal() {
|
||||||
editingQualification.value = null
|
editingQualification.value = null
|
||||||
formData.value = {
|
formData.value = { user_id: '', qualification_type_id: '', org_qualification_type_id: '', issued_date: '', expiry_date: '', issuer: '', certificate_number: '', level: '', notes: '' }
|
||||||
user_id: '',
|
|
||||||
qualification_type_id: '',
|
|
||||||
org_qualification_type_id: '',
|
|
||||||
issued_date: '',
|
|
||||||
expiry_date: '',
|
|
||||||
issuer: '',
|
|
||||||
certificate_number: '',
|
|
||||||
level: '',
|
|
||||||
notes: ''
|
|
||||||
}
|
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
function openEditModal(qual: Qualification) {
|
function openEditModal(qual: Qualification) {
|
||||||
editingQualification.value = qual
|
editingQualification.value = qual
|
||||||
formData.value = {
|
formData.value = { user_id: qual.user_id, qualification_type_id: '', org_qualification_type_id: '', issued_date: qual.issued_date || '', expiry_date: qual.expiry_date || '', issuer: qual.issuer || '', certificate_number: qual.certificate_number || '', level: qual.level || '', notes: '' }
|
||||||
user_id: qual.user_id,
|
|
||||||
qualification_type_id: '', // Will be handled separately
|
|
||||||
org_qualification_type_id: '',
|
|
||||||
issued_date: qual.issued_date || '',
|
|
||||||
expiry_date: qual.expiry_date || '',
|
|
||||||
issuer: qual.issuer || '',
|
|
||||||
certificate_number: qual.certificate_number || '',
|
|
||||||
level: qual.level || '',
|
|
||||||
notes: ''
|
|
||||||
}
|
|
||||||
showModal.value = true
|
showModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveQualification() {
|
async function saveQualification() {
|
||||||
if (!formData.value.user_id || (!formData.value.qualification_type_id && !formData.value.org_qualification_type_id)) {
|
if (!formData.value.user_id || (!formData.value.qualification_type_id && !formData.value.org_qualification_type_id)) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
saving.value = true
|
saving.value = true
|
||||||
try {
|
try {
|
||||||
if (editingQualification.value) {
|
if (editingQualification.value) {
|
||||||
@@ -212,8 +173,7 @@ async function saveQualification() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteQualification(id: string) {
|
async function deleteQualification(id: string) {
|
||||||
if (!confirm('Qualifikation wirklich löschen?')) return
|
if (!confirm(t('messages.confirmDelete'))) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete(`/qualifications/${id}`)
|
await api.delete(`/qualifications/${id}`)
|
||||||
await loadQualifications()
|
await loadQualifications()
|
||||||
@@ -235,17 +195,17 @@ function getStatusColor(status: string): string {
|
|||||||
|
|
||||||
function getStatusLabel(status: string): string {
|
function getStatusLabel(status: string): string {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case 'valid': return 'Gültig'
|
case 'valid': return t('qualifications.valid')
|
||||||
case 'expiring_soon': return 'Läuft ab'
|
case 'expiring_soon': return t('qualifications.expiringSoon')
|
||||||
case 'expired': return 'Abgelaufen'
|
case 'expired': return t('qualifications.expired')
|
||||||
case 'no_expiry': return 'Unbefristet'
|
case 'no_expiry': return '♾️'
|
||||||
default: return status
|
default: return status
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(date: string | undefined): string {
|
function formatDate(date: string | undefined): string {
|
||||||
if (!date) return '-'
|
if (!date) return '-'
|
||||||
return new Date(date).toLocaleDateString('de-DE')
|
return new Date(date).toLocaleDateString()
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectQualificationType(type: QualificationType) {
|
function selectQualificationType(type: QualificationType) {
|
||||||
@@ -269,142 +229,87 @@ const selectedTypeName = computed(() => {
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🎓 Qualifikationen</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🎓 {{ t('qualifications.title') }}</h1>
|
||||||
<button
|
<button v-if="authStore.canManageUsers" @click="openAddModal" class="btn btn-primary">
|
||||||
v-if="authStore.canManageUsers"
|
➕ {{ t('qualifications.new') }}
|
||||||
@click="openAddModal"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
➕ Qualifikation hinzufügen
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- Tabs -->
|
||||||
<div class="flex gap-2 border-b dark:border-gray-700">
|
<div class="flex gap-2 border-b dark:border-gray-700">
|
||||||
<button
|
<button @click="activeTab = 'list'" :class="['px-4 py-2 font-medium border-b-2 transition-colors', activeTab === 'list' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700']">
|
||||||
@click="activeTab = 'list'"
|
📋 {{ t('dashboard.overview') }}
|
||||||
:class="['px-4 py-2 font-medium border-b-2 transition-colors',
|
|
||||||
activeTab === 'list'
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700']"
|
|
||||||
>
|
|
||||||
📋 Übersicht
|
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button @click="activeTab = 'expiring'" :class="['px-4 py-2 font-medium border-b-2 transition-colors flex items-center gap-2', activeTab === 'expiring' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700']">
|
||||||
@click="activeTab = 'expiring'"
|
⚠️ {{ t('qualifications.expiringSoon') }}
|
||||||
:class="['px-4 py-2 font-medium border-b-2 transition-colors flex items-center gap-2',
|
<span v-if="expiringData.expired.length + expiringData.expiring_soon.length > 0" class="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full">
|
||||||
activeTab === 'expiring'
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700']"
|
|
||||||
>
|
|
||||||
⚠️ Ablaufend
|
|
||||||
<span v-if="expiringData.expired.length + expiringData.expiring_soon.length > 0"
|
|
||||||
class="bg-red-500 text-white text-xs px-2 py-0.5 rounded-full">
|
|
||||||
{{ expiringData.expired.length + expiringData.expiring_soon.length }}
|
{{ expiringData.expired.length + expiringData.expiring_soon.length }}
|
||||||
</span>
|
</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button v-if="authStore.canManageUsers" @click="activeTab = 'matrix'" :class="['px-4 py-2 font-medium border-b-2 transition-colors', activeTab === 'matrix' ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500 hover:text-gray-700']">
|
||||||
v-if="authStore.canManageUsers"
|
|
||||||
@click="activeTab = 'matrix'"
|
|
||||||
:class="['px-4 py-2 font-medium border-b-2 transition-colors',
|
|
||||||
activeTab === 'matrix'
|
|
||||||
? 'border-blue-500 text-blue-600'
|
|
||||||
: 'border-transparent text-gray-500 hover:text-gray-700']"
|
|
||||||
>
|
|
||||||
📊 Matrix
|
📊 Matrix
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Loading -->
|
|
||||||
<div v-if="loading" class="text-center py-12">
|
<div v-if="loading" class="text-center py-12">
|
||||||
<div class="animate-spin text-4xl">⏳</div>
|
<div class="animate-spin text-4xl">⏳</div>
|
||||||
<p class="mt-2 text-gray-500">Lade Qualifikationen...</p>
|
<p class="mt-2 text-gray-500">{{ t('app.loading') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- List Tab -->
|
<!-- List Tab -->
|
||||||
<div v-else-if="activeTab === 'list'" class="space-y-4">
|
<div v-else-if="activeTab === 'list'" class="space-y-4">
|
||||||
<!-- Filters -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">🔍 Suche</label>
|
<label class="block text-sm font-medium mb-1">🔍 {{ t('app.search') }}</label>
|
||||||
<input v-model="searchQuery" type="text" class="input" placeholder="Name oder Qualifikation..." />
|
<input v-model="searchQuery" type="text" class="input" :placeholder="t('app.search') + '...'" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">👤 Mitarbeiter</label>
|
<label class="block text-sm font-medium mb-1">👤 {{ t('users.employee') }}</label>
|
||||||
<select v-model="filterUser" class="input">
|
<select v-model="filterUser" class="input">
|
||||||
<option value="">Alle</option>
|
<option value="">{{ t('app.all') }}</option>
|
||||||
<option v-for="user in users" :key="user.id" :value="user.id">
|
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.first_name }} {{ user.last_name }}</option>
|
||||||
{{ user.first_name }} {{ user.last_name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">📂 Kategorie</label>
|
<label class="block text-sm font-medium mb-1">📂 {{ t('documents.category') }}</label>
|
||||||
<select v-model="filterCategory" class="input">
|
<select v-model="filterCategory" class="input">
|
||||||
<option value="">Alle</option>
|
<option value="">{{ t('app.all') }}</option>
|
||||||
<option v-for="cat in qualificationTypes.categories" :key="cat.key" :value="cat.key">
|
<option v-for="cat in qualificationTypes.categories" :key="cat.key" :value="cat.key">{{ cat.icon }} {{ cat.name }}</option>
|
||||||
{{ cat.icon }} {{ cat.name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">📊 Status</label>
|
<label class="block text-sm font-medium mb-1">📊 {{ t('app.status') }}</label>
|
||||||
<select v-model="filterStatus" class="input">
|
<select v-model="filterStatus" class="input">
|
||||||
<option value="">Alle</option>
|
<option value="">{{ t('app.all') }}</option>
|
||||||
<option value="valid">✅ Gültig</option>
|
<option value="valid">✅ {{ t('qualifications.valid') }}</option>
|
||||||
<option value="expiring_soon">⚠️ Läuft ab</option>
|
<option value="expiring_soon">⚠️ {{ t('qualifications.expiringSoon') }}</option>
|
||||||
<option value="expired">❌ Abgelaufen</option>
|
<option value="expired">❌ {{ t('qualifications.expired') }}</option>
|
||||||
<option value="no_expiry">♾️ Unbefristet</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Results -->
|
<div v-if="filteredQualifications.length === 0" class="card text-center py-12 text-gray-500">{{ t('messages.noData') }}</div>
|
||||||
<div v-if="filteredQualifications.length === 0" class="card text-center py-12 text-gray-500">
|
|
||||||
Keine Qualifikationen gefunden
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div v-else class="grid gap-4">
|
<div v-else class="grid gap-4">
|
||||||
<div
|
<div v-for="qual in filteredQualifications" :key="qual.id" class="card hover:shadow-md transition-shadow">
|
||||||
v-for="qual in filteredQualifications"
|
|
||||||
:key="qual.id"
|
|
||||||
class="card hover:shadow-md transition-shadow"
|
|
||||||
>
|
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div class="flex items-start gap-4">
|
<div class="flex items-start gap-4">
|
||||||
<div class="text-3xl">{{ qual.icon }}</div>
|
<div class="text-3xl">{{ qual.icon }}</div>
|
||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-gray-900 dark:text-white">
|
<h3 class="font-semibold text-gray-900 dark:text-white">{{ qual.qualification_name }}</h3>
|
||||||
{{ qual.qualification_name }}
|
<p class="text-sm text-gray-500">{{ qual.first_name }} {{ qual.last_name }}</p>
|
||||||
</h3>
|
|
||||||
<p class="text-sm text-gray-500">
|
|
||||||
{{ qual.first_name }} {{ qual.last_name }}
|
|
||||||
</p>
|
|
||||||
<div class="flex flex-wrap gap-2 mt-2">
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
<span :class="['px-2 py-0.5 rounded text-xs font-medium', getStatusColor(qual.expiry_status)]">
|
<span :class="['px-2 py-0.5 rounded text-xs font-medium', getStatusColor(qual.expiry_status)]">{{ getStatusLabel(qual.expiry_status) }}</span>
|
||||||
{{ getStatusLabel(qual.expiry_status) }}
|
<span v-if="qual.expiry_date" class="text-xs text-gray-500">📅 {{ formatDate(qual.expiry_date) }}</span>
|
||||||
</span>
|
<span v-if="qual.issuer" class="text-xs text-gray-500">🏢 {{ qual.issuer }}</span>
|
||||||
<span v-if="qual.expiry_date" class="text-xs text-gray-500">
|
|
||||||
📅 {{ formatDate(qual.expiry_date) }}
|
|
||||||
</span>
|
|
||||||
<span v-if="qual.issuer" class="text-xs text-gray-500">
|
|
||||||
🏢 {{ qual.issuer }}
|
|
||||||
</span>
|
|
||||||
<span v-if="qual.certificate_number" class="text-xs text-gray-500">
|
|
||||||
#{{ qual.certificate_number }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-if="authStore.canManageUsers" class="flex gap-2">
|
<div v-if="authStore.canManageUsers" class="flex gap-2">
|
||||||
<button @click="openEditModal(qual)" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">
|
<button @click="openEditModal(qual)" class="p-2 hover:bg-gray-100 dark:hover:bg-gray-700 rounded">✏️</button>
|
||||||
✏️
|
<button @click="deleteQualification(qual.id)" class="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600">🗑️</button>
|
||||||
</button>
|
|
||||||
<button @click="deleteQualification(qual.id)" class="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600">
|
|
||||||
🗑️
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -413,29 +318,20 @@ const selectedTypeName = computed(() => {
|
|||||||
|
|
||||||
<!-- Expiring Tab -->
|
<!-- Expiring Tab -->
|
||||||
<div v-else-if="activeTab === 'expiring'" class="space-y-6">
|
<div v-else-if="activeTab === 'expiring'" class="space-y-6">
|
||||||
<!-- Expired -->
|
|
||||||
<div v-if="expiringData.expired.length > 0" class="space-y-4">
|
<div v-if="expiringData.expired.length > 0" class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-red-600 flex items-center gap-2">
|
<h2 class="text-lg font-semibold text-red-600 flex items-center gap-2">❌ {{ t('qualifications.expired') }} ({{ expiringData.expired.length }})</h2>
|
||||||
❌ Abgelaufen ({{ expiringData.expired.length }})
|
|
||||||
</h2>
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3">
|
||||||
<div
|
<div v-for="qual in expiringData.expired" :key="qual.id" class="card border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20">
|
||||||
v-for="qual in expiringData.expired"
|
|
||||||
:key="qual.id"
|
|
||||||
class="card border-l-4 border-red-500 bg-red-50 dark:bg-red-900/20"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">{{ qual.icon }}</span>
|
<span class="text-2xl">{{ qual.icon }}</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{{ qual.qualification_name }}</p>
|
<p class="font-medium">{{ qual.qualification_name }}</p>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<p class="text-sm text-gray-600 dark:text-gray-400">{{ qual.first_name }} {{ qual.last_name }}</p>
|
||||||
{{ qual.first_name }} {{ qual.last_name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-sm font-medium text-red-600">Abgelaufen</p>
|
<p class="text-sm font-medium text-red-600">{{ t('qualifications.expired') }}</p>
|
||||||
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
|
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -443,31 +339,20 @@ const selectedTypeName = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Expiring Soon -->
|
|
||||||
<div v-if="expiringData.expiring_soon.length > 0" class="space-y-4">
|
<div v-if="expiringData.expiring_soon.length > 0" class="space-y-4">
|
||||||
<h2 class="text-lg font-semibold text-yellow-600 flex items-center gap-2">
|
<h2 class="text-lg font-semibold text-yellow-600 flex items-center gap-2">⚠️ {{ t('qualifications.expiringSoon') }} ({{ expiringData.expiring_soon.length }})</h2>
|
||||||
⚠️ Läuft bald ab ({{ expiringData.expiring_soon.length }})
|
|
||||||
</h2>
|
|
||||||
<div class="grid gap-3">
|
<div class="grid gap-3">
|
||||||
<div
|
<div v-for="qual in expiringData.expiring_soon" :key="qual.id" class="card border-l-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20">
|
||||||
v-for="qual in expiringData.expiring_soon"
|
|
||||||
:key="qual.id"
|
|
||||||
class="card border-l-4 border-yellow-500 bg-yellow-50 dark:bg-yellow-900/20"
|
|
||||||
>
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span class="text-2xl">{{ qual.icon }}</span>
|
<span class="text-2xl">{{ qual.icon }}</span>
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium">{{ qual.qualification_name }}</p>
|
<p class="font-medium">{{ qual.qualification_name }}</p>
|
||||||
<p class="text-sm text-gray-600 dark:text-gray-400">
|
<p class="text-sm text-gray-600 dark:text-gray-400">{{ qual.first_name }} {{ qual.last_name }}</p>
|
||||||
{{ qual.first_name }} {{ qual.last_name }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right">
|
<div class="text-right">
|
||||||
<p class="text-sm font-medium text-yellow-600">
|
<p class="text-sm font-medium text-yellow-600">{{ qual.days_until_expiry }} {{ t('time.days') }}</p>
|
||||||
Noch {{ qual.days_until_expiry }} Tage
|
|
||||||
</p>
|
|
||||||
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
|
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,118 +360,58 @@ const selectedTypeName = computed(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- All good -->
|
<div v-if="expiringData.expired.length === 0 && expiringData.expiring_soon.length === 0" class="card text-center py-12">
|
||||||
<div v-if="expiringData.expired.length === 0 && expiringData.expiring_soon.length === 0"
|
|
||||||
class="card text-center py-12">
|
|
||||||
<div class="text-4xl mb-4">✅</div>
|
<div class="text-4xl mb-4">✅</div>
|
||||||
<p class="text-lg font-medium text-green-600">Alles in Ordnung!</p>
|
<p class="text-lg font-medium text-green-600">{{ t('messages.success') }}</p>
|
||||||
<p class="text-gray-500">Keine ablaufenden Qualifikationen in den nächsten 30 Tagen.</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Matrix Tab (placeholder) -->
|
<!-- Matrix Tab -->
|
||||||
<div v-else-if="activeTab === 'matrix'" class="card">
|
<div v-else-if="activeTab === 'matrix'" class="card">
|
||||||
<p class="text-gray-500 text-center py-12">
|
<p class="text-gray-500 text-center py-12">📊 Matrix</p>
|
||||||
📊 Qualifikations-Matrix wird geladen...<br>
|
|
||||||
<span class="text-sm">Übersicht: Welcher Mitarbeiter hat welche Qualifikation</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add/Edit Modal -->
|
<!-- Modal -->
|
||||||
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">
|
<h2 class="text-xl font-semibold mb-4">{{ editingQualification ? '✏️ ' + t('app.edit') : '➕ ' + t('qualifications.new') }}</h2>
|
||||||
{{ editingQualification ? '✏️ Qualifikation bearbeiten' : '➕ Neue Qualifikation' }}
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form @submit.prevent="saveQualification" class="space-y-4">
|
<form @submit.prevent="saveQualification" class="space-y-4">
|
||||||
<!-- Mitarbeiter -->
|
|
||||||
<div v-if="!editingQualification">
|
<div v-if="!editingQualification">
|
||||||
<label class="block text-sm font-medium mb-1">Mitarbeiter *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('users.employee') }} *</label>
|
||||||
<select v-model="formData.user_id" class="input" required>
|
<select v-model="formData.user_id" class="input" required>
|
||||||
<option value="">Bitte wählen...</option>
|
<option value="">{{ t('app.search') }}...</option>
|
||||||
<option v-for="user in users" :key="user.id" :value="user.id">
|
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.first_name }} {{ user.last_name }}</option>
|
||||||
{{ user.first_name }} {{ user.last_name }}
|
|
||||||
</option>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Qualifikationstyp -->
|
|
||||||
<div v-if="!editingQualification">
|
<div v-if="!editingQualification">
|
||||||
<label class="block text-sm font-medium mb-1">Qualifikation *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('qualifications.type') }} *</label>
|
||||||
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto border dark:border-gray-600 rounded-lg p-2">
|
<div class="grid grid-cols-1 gap-2 max-h-48 overflow-y-auto border dark:border-gray-600 rounded-lg p-2">
|
||||||
<div v-for="cat in qualificationTypes.categories" :key="cat.key" class="space-y-1">
|
<div v-for="cat in qualificationTypes.categories" :key="cat.key" class="space-y-1">
|
||||||
<p class="text-xs font-medium text-gray-500 sticky top-0 bg-white dark:bg-gray-800 py-1">
|
<p class="text-xs font-medium text-gray-500 sticky top-0 bg-white dark:bg-gray-800 py-1">{{ cat.icon }} {{ cat.name }}</p>
|
||||||
{{ cat.icon }} {{ cat.name }}
|
<button v-for="type in allTypes.filter(t => t.category === cat.key)" :key="type.id" type="button" @click="selectQualificationType(type)" :class="['w-full text-left px-3 py-2 rounded text-sm transition-colors', (formData.qualification_type_id === type.id || formData.org_qualification_type_id === type.id) ? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200' : 'hover:bg-gray-100 dark:hover:bg-gray-700']">
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
v-for="type in [...qualificationTypes.system, ...qualificationTypes.custom].filter(t => t.category === cat.key)"
|
|
||||||
:key="type.id"
|
|
||||||
type="button"
|
|
||||||
@click="selectQualificationType(type)"
|
|
||||||
:class="[
|
|
||||||
'w-full text-left px-3 py-2 rounded text-sm transition-colors',
|
|
||||||
(formData.qualification_type_id === type.id || formData.org_qualification_type_id === type.id)
|
|
||||||
? 'bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200'
|
|
||||||
: 'hover:bg-gray-100 dark:hover:bg-gray-700'
|
|
||||||
]"
|
|
||||||
>
|
|
||||||
{{ type.icon }} {{ type.name }}
|
{{ type.icon }} {{ type.name }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="selectedTypeName" class="text-sm text-blue-600 mt-1">
|
|
||||||
Ausgewählt: {{ selectedTypeName }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<!-- Ausstellungsdatum -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Ausstellungsdatum</label>
|
<label class="block text-sm font-medium mb-1">{{ t('qualifications.issueDate') }}</label>
|
||||||
<input v-model="formData.issued_date" type="date" class="input" />
|
<input v-model="formData.issued_date" type="date" class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Ablaufdatum -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Ablaufdatum</label>
|
<label class="block text-sm font-medium mb-1">{{ t('qualifications.expiryDate') }}</label>
|
||||||
<input v-model="formData.expiry_date" type="date" class="input" />
|
<input v-model="formData.expiry_date" type="date" class="input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Aussteller -->
|
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Ausstellende Stelle</label>
|
<label class="block text-sm font-medium mb-1">{{ t('qualifications.issuedBy') }}</label>
|
||||||
<input v-model="formData.issuer" type="text" class="input" placeholder="z.B. IHK Berlin" />
|
<input v-model="formData.issuer" type="text" class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Zertifikatsnummer -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Zertifikatsnummer</label>
|
|
||||||
<input v-model="formData.certificate_number" type="text" class="input" placeholder="Optional" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Stufe/Level -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Stufe/Level</label>
|
|
||||||
<input v-model="formData.level" type="text" class="input" placeholder="z.B. B2 (für Sprachen)" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Notizen -->
|
|
||||||
<div>
|
|
||||||
<label class="block text-sm font-medium mb-1">Notizen</label>
|
|
||||||
<textarea v-model="formData.notes" class="input" rows="2" placeholder="Optional"></textarea>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Buttons -->
|
|
||||||
<div class="flex gap-3 pt-4">
|
<div class="flex gap-3 pt-4">
|
||||||
<button type="button" @click="showModal = false" class="btn flex-1">
|
<button type="button" @click="showModal = false" class="btn flex-1">{{ t('app.cancel') }}</button>
|
||||||
Abbrechen
|
<button type="submit" :disabled="saving" class="btn btn-primary flex-1">{{ saving ? t('app.loading') : t('app.save') }}</button>
|
||||||
</button>
|
|
||||||
<button type="submit" :disabled="saving" class="btn btn-primary flex-1">
|
|
||||||
{{ saving ? 'Speichern...' : 'Speichern' }}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
import SecuritySettings from '@/components/SecuritySettings.vue'
|
import SecuritySettings from '@/components/SecuritySettings.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
@@ -14,151 +16,94 @@ const message = ref('')
|
|||||||
const error = ref('')
|
const error = ref('')
|
||||||
const showSecuritySettings = ref(false)
|
const showSecuritySettings = ref(false)
|
||||||
|
|
||||||
// Logo upload
|
|
||||||
const logoFile = ref<File | null>(null)
|
const logoFile = ref<File | null>(null)
|
||||||
const logoPreview = ref<string | null>(null)
|
const logoPreview = ref<string | null>(null)
|
||||||
const logoLoading = ref(false)
|
const logoLoading = ref(false)
|
||||||
const logoMessage = ref('')
|
const logoMessage = ref('')
|
||||||
const logoError = ref('')
|
const logoError = ref('')
|
||||||
const fileInput = ref<HTMLInputElement | null>(null)
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
// App lock status
|
|
||||||
const lockMethod = ref<string | null>(null)
|
const lockMethod = ref<string | null>(null)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
lockMethod.value = localStorage.getItem('lockMethod')
|
lockMethod.value = localStorage.getItem('lockMethod')
|
||||||
// Store email for biometric setup
|
if (authStore.user?.email) localStorage.setItem('userEmail', authStore.user.email)
|
||||||
if (authStore.user?.email) {
|
if (authStore.orgLogo) logoPreview.value = authStore.orgLogo
|
||||||
localStorage.setItem('userEmail', authStore.user.email)
|
|
||||||
}
|
|
||||||
// Set logo preview from current org logo
|
|
||||||
if (authStore.orgLogo) {
|
|
||||||
logoPreview.value = authStore.orgLogo
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
function handleLogoSelect(event: Event) {
|
function handleLogoSelect(event: Event) {
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
const file = target.files?.[0]
|
const file = target.files?.[0]
|
||||||
|
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
// Validate file type
|
|
||||||
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']
|
||||||
if (!allowedTypes.includes(file.type)) {
|
if (!allowedTypes.includes(file.type)) { logoError.value = t('messages.invalid'); return }
|
||||||
logoError.value = 'Nur JPG, PNG, WebP oder SVG erlaubt'
|
if (file.size > 5 * 1024 * 1024) { logoError.value = t('messages.error'); return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate file size (5MB)
|
|
||||||
if (file.size > 5 * 1024 * 1024) {
|
|
||||||
logoError.value = 'Datei zu groß (max 5MB)'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
logoFile.value = file
|
logoFile.value = file
|
||||||
logoError.value = ''
|
logoError.value = ''
|
||||||
|
|
||||||
// Create preview
|
|
||||||
const reader = new FileReader()
|
const reader = new FileReader()
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => { logoPreview.value = e.target?.result as string }
|
||||||
logoPreview.value = e.target?.result as string
|
|
||||||
}
|
|
||||||
reader.readAsDataURL(file)
|
reader.readAsDataURL(file)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function uploadLogo() {
|
async function uploadLogo() {
|
||||||
if (!logoFile.value) return
|
if (!logoFile.value) return
|
||||||
|
|
||||||
logoLoading.value = true
|
logoLoading.value = true
|
||||||
logoError.value = ''
|
logoError.value = ''
|
||||||
logoMessage.value = ''
|
logoMessage.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const formData = new FormData()
|
const formData = new FormData()
|
||||||
formData.append('logo', logoFile.value)
|
formData.append('logo', logoFile.value)
|
||||||
|
const response = await api.post('/uploads/logo', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
|
||||||
const response = await api.post('/uploads/logo', formData, {
|
logoMessage.value = t('messages.saved')
|
||||||
headers: {
|
|
||||||
'Content-Type': 'multipart/form-data'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
logoMessage.value = 'Logo erfolgreich hochgeladen'
|
|
||||||
logoFile.value = null
|
logoFile.value = null
|
||||||
|
|
||||||
// Update store with new logo URL
|
|
||||||
authStore.setOrgLogo(response.data.logo_url)
|
authStore.setOrgLogo(response.data.logo_url)
|
||||||
logoPreview.value = response.data.logo_url
|
logoPreview.value = response.data.logo_url
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logoError.value = e instanceof Error ? e.message : 'Fehler beim Hochladen'
|
logoError.value = e instanceof Error ? e.message : t('messages.error')
|
||||||
} finally {
|
} finally { logoLoading.value = false }
|
||||||
logoLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteLogo() {
|
async function deleteLogo() {
|
||||||
if (!confirm('Logo wirklich löschen?')) return
|
if (!confirm(t('messages.confirmDelete'))) return
|
||||||
|
|
||||||
logoLoading.value = true
|
logoLoading.value = true
|
||||||
logoError.value = ''
|
logoError.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.delete('/uploads/logo')
|
await api.delete('/uploads/logo')
|
||||||
logoMessage.value = 'Logo gelöscht'
|
logoMessage.value = t('messages.deleted')
|
||||||
logoPreview.value = null
|
logoPreview.value = null
|
||||||
logoFile.value = null
|
logoFile.value = null
|
||||||
authStore.setOrgLogo(null)
|
authStore.setOrgLogo(null)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logoError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
|
logoError.value = e instanceof Error ? e.message : t('messages.error')
|
||||||
} finally {
|
} finally { logoLoading.value = false }
|
||||||
logoLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const lockStatusText = computed(() => {
|
const lockStatusText = computed(() => {
|
||||||
if (!lockMethod.value || lockMethod.value === 'none') return 'Deaktiviert'
|
if (!lockMethod.value || lockMethod.value === 'none') return t('modules.disabled')
|
||||||
if (lockMethod.value === 'biometric') return 'Fingerabdruck / Face ID'
|
if (lockMethod.value === 'biometric') return 'Biometric'
|
||||||
if (lockMethod.value === 'pin') return 'PIN-Code'
|
if (lockMethod.value === 'pin') return t('settings.pin')
|
||||||
return 'Unbekannt'
|
return ''
|
||||||
})
|
})
|
||||||
|
|
||||||
const lockStatusClass = computed(() => {
|
const lockStatusClass = computed(() => {
|
||||||
if (!lockMethod.value || lockMethod.value === 'none') {
|
if (!lockMethod.value || lockMethod.value === 'none') return 'bg-red-100 text-red-700'
|
||||||
return 'bg-red-100 text-red-700'
|
|
||||||
}
|
|
||||||
return 'bg-green-100 text-green-700'
|
return 'bg-green-100 text-green-700'
|
||||||
})
|
})
|
||||||
|
|
||||||
async function changePassword() {
|
async function changePassword() {
|
||||||
if (newPassword.value !== confirmPassword.value) {
|
if (newPassword.value !== confirmPassword.value) { error.value = t('messages.invalid'); return }
|
||||||
error.value = 'Passwörter stimmen nicht überein'
|
if (newPassword.value.length < 8) { error.value = t('messages.invalid'); return }
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (newPassword.value.length < 8) {
|
|
||||||
error.value = 'Passwort muss mindestens 8 Zeichen haben'
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
error.value = ''
|
error.value = ''
|
||||||
message.value = ''
|
message.value = ''
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post('/auth/change-password', {
|
await api.post('/auth/change-password', { currentPassword: currentPassword.value, newPassword: newPassword.value })
|
||||||
currentPassword: currentPassword.value,
|
message.value = t('messages.saved')
|
||||||
newPassword: newPassword.value
|
|
||||||
})
|
|
||||||
message.value = 'Passwort erfolgreich geändert'
|
|
||||||
currentPassword.value = ''
|
currentPassword.value = ''
|
||||||
newPassword.value = ''
|
newPassword.value = ''
|
||||||
confirmPassword.value = ''
|
confirmPassword.value = ''
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
error.value = e instanceof Error ? e.message : 'Fehler beim Ändern'
|
error.value = e instanceof Error ? e.message : t('messages.error')
|
||||||
} finally {
|
} finally { loading.value = false }
|
||||||
loading.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleSecurityClose() {
|
function handleSecurityClose() {
|
||||||
@@ -169,99 +114,51 @@ function handleSecurityClose() {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🔧 Einstellungen</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🔧 {{ t('settings.title') }}</h1>
|
||||||
|
|
||||||
<!-- Profile -->
|
<!-- Profile -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-lg font-semibold mb-4">👤 Profil</h2>
|
<h2 class="text-lg font-semibold mb-4">👤 {{ t('settings.profile') }}</h2>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-500">Name</label>
|
<label class="block text-sm text-gray-500">{{ t('auth.firstName') }}</label>
|
||||||
<p class="font-medium">{{ authStore.fullName }}</p>
|
<p class="font-medium">{{ authStore.fullName }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-500">E-Mail</label>
|
<label class="block text-sm text-gray-500">{{ t('auth.email') }}</label>
|
||||||
<p class="font-medium">{{ authStore.user?.email }}</p>
|
<p class="font-medium">{{ authStore.user?.email }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-500">Rolle</label>
|
<label class="block text-sm text-gray-500">{{ t('users.role') }}</label>
|
||||||
<p class="font-medium capitalize">{{ authStore.user?.role }}</p>
|
<p class="font-medium capitalize">{{ authStore.user?.role }}</p>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm text-gray-500">Organisation</label>
|
<label class="block text-sm text-gray-500">{{ t('auth.orgName') }}</label>
|
||||||
<p class="font-medium">{{ authStore.orgName }}</p>
|
<p class="font-medium">{{ authStore.orgName }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Organization Logo (Chef only) -->
|
<!-- Organization Logo -->
|
||||||
<div v-if="authStore.isChef" class="card">
|
<div v-if="authStore.isChef" class="card">
|
||||||
<h2 class="text-lg font-semibold mb-4">🏢 Firmenlogo</h2>
|
<h2 class="text-lg font-semibold mb-4">🏢 Logo</h2>
|
||||||
<p class="text-gray-600 text-sm mb-4">
|
|
||||||
Lade ein Logo für deine Organisation hoch. Deine Mitarbeiter sehen es in der Sidebar.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-start gap-6">
|
<div class="flex items-start gap-6">
|
||||||
<!-- Logo Preview -->
|
|
||||||
<div class="flex-shrink-0">
|
<div class="flex-shrink-0">
|
||||||
<div class="w-32 h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-gray-700">
|
<div class="w-32 h-32 border-2 border-dashed border-gray-300 dark:border-gray-600 rounded-lg flex items-center justify-center overflow-hidden bg-gray-50 dark:bg-gray-700">
|
||||||
<img
|
<img v-if="logoPreview" :src="logoPreview" alt="Logo" class="max-w-full max-h-full object-contain">
|
||||||
v-if="logoPreview"
|
|
||||||
:src="logoPreview"
|
|
||||||
alt="Logo Vorschau"
|
|
||||||
class="max-w-full max-h-full object-contain"
|
|
||||||
>
|
|
||||||
<span v-else class="text-4xl text-gray-400">🏢</span>
|
<span v-else class="text-4xl text-gray-400">🏢</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Upload Controls -->
|
|
||||||
<div class="flex-1 space-y-4">
|
<div class="flex-1 space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<input
|
<input ref="fileInput" type="file" accept="image/jpeg,image/png,image/webp,image/svg+xml" class="hidden" @change="handleLogoSelect">
|
||||||
ref="fileInput"
|
<button type="button" class="btn btn-secondary" @click="fileInput?.click()">📁 {{ t('app.upload') }}</button>
|
||||||
type="file"
|
<span v-if="logoFile" class="ml-3 text-sm text-gray-600 dark:text-gray-400">{{ logoFile.name }}</span>
|
||||||
accept="image/jpeg,image/png,image/webp,image/svg+xml"
|
|
||||||
class="hidden"
|
|
||||||
@change="handleLogoSelect"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-secondary"
|
|
||||||
@click="fileInput?.click()"
|
|
||||||
>
|
|
||||||
📁 Datei auswählen
|
|
||||||
</button>
|
|
||||||
<span v-if="logoFile" class="ml-3 text-sm text-gray-600 dark:text-gray-400">
|
|
||||||
{{ logoFile.name }}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-xs text-gray-500">
|
|
||||||
JPG, PNG, WebP oder SVG • max. 5MB • empfohlen: 200x80px
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button v-if="logoFile" type="button" :disabled="logoLoading" class="btn btn-primary" @click="uploadLogo">{{ logoLoading ? t('app.loading') : '⬆️ ' + t('app.upload') }}</button>
|
||||||
v-if="logoFile"
|
<button v-if="authStore.orgLogo" type="button" :disabled="logoLoading" class="btn btn-secondary text-red-600 hover:bg-red-50" @click="deleteLogo">🗑️ {{ t('app.delete') }}</button>
|
||||||
type="button"
|
|
||||||
:disabled="logoLoading"
|
|
||||||
class="btn btn-primary"
|
|
||||||
@click="uploadLogo"
|
|
||||||
>
|
|
||||||
{{ logoLoading ? 'Hochladen...' : '⬆️ Hochladen' }}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-if="authStore.orgLogo"
|
|
||||||
type="button"
|
|
||||||
:disabled="logoLoading"
|
|
||||||
class="btn btn-secondary text-red-600 hover:bg-red-50"
|
|
||||||
@click="deleteLogo"
|
|
||||||
>
|
|
||||||
🗑️ Logo löschen
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="logoError" class="text-red-600 text-sm">{{ logoError }}</div>
|
<div v-if="logoError" class="text-red-600 text-sm">{{ logoError }}</div>
|
||||||
<div v-if="logoMessage" class="text-green-600 text-sm">{{ logoMessage }}</div>
|
<div v-if="logoMessage" class="text-green-600 text-sm">{{ logoMessage }}</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -270,58 +167,38 @@ function handleSecurityClose() {
|
|||||||
|
|
||||||
<!-- App Security -->
|
<!-- App Security -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-lg font-semibold mb-4">🔐 App-Sicherheit</h2>
|
<h2 class="text-lg font-semibold mb-4">🔐 {{ t('settings.security') }}</h2>
|
||||||
<p class="text-gray-600 text-sm mb-4">
|
|
||||||
Schütze deine App mit Fingerabdruck, Face ID oder PIN.
|
|
||||||
Bei jedem Öffnen der App musst du dich verifizieren.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
|
||||||
<div>
|
<div>
|
||||||
<div class="font-medium">App-Sperre</div>
|
<div class="font-medium">{{ t('settings.lockScreen') }}</div>
|
||||||
<div :class="['inline-block px-2 py-1 rounded text-sm mt-1', lockStatusClass]">
|
<div :class="['inline-block px-2 py-1 rounded text-sm mt-1', lockStatusClass]">{{ lockStatusText }}</div>
|
||||||
{{ lockStatusText }}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button @click="showSecuritySettings = true" class="btn btn-primary">{{ t('modules.configure') }}</button>
|
||||||
@click="showSecuritySettings = true"
|
|
||||||
class="btn btn-primary"
|
|
||||||
>
|
|
||||||
Konfigurieren
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Change Password -->
|
<!-- Change Password -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2 class="text-lg font-semibold mb-4">🔑 Passwort ändern</h2>
|
<h2 class="text-lg font-semibold mb-4">🔑 {{ t('settings.changePassword') }}</h2>
|
||||||
<form @submit.prevent="changePassword" class="space-y-4 max-w-md">
|
<form @submit.prevent="changePassword" class="space-y-4 max-w-md">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Aktuelles Passwort</label>
|
<label class="block text-sm font-medium mb-1">{{ t('auth.password') }}</label>
|
||||||
<input v-model="currentPassword" type="password" required class="input" />
|
<input v-model="currentPassword" type="password" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Neues Passwort</label>
|
<label class="block text-sm font-medium mb-1">{{ t('auth.password') }} ({{ t('app.add') }})</label>
|
||||||
<input v-model="newPassword" type="password" required class="input" />
|
<input v-model="newPassword" type="password" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Passwort bestätigen</label>
|
<label class="block text-sm font-medium mb-1">{{ t('auth.confirmPassword') }}</label>
|
||||||
<input v-model="confirmPassword" type="password" required class="input" />
|
<input v-model="confirmPassword" type="password" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="text-red-600 text-sm">{{ error }}</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>
|
<div v-if="message" class="text-green-600 text-sm">{{ message }}</div>
|
||||||
|
<button type="submit" :disabled="loading" class="btn btn-primary">{{ loading ? t('app.loading') : t('app.save') }}</button>
|
||||||
<button type="submit" :disabled="loading" class="btn btn-primary">
|
|
||||||
{{ loading ? 'Speichern...' : 'Passwort ändern' }}
|
|
||||||
</button>
|
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Security Settings Modal -->
|
<SecuritySettings v-if="showSecuritySettings" @close="handleSecurityClose" />
|
||||||
<SecuritySettings
|
|
||||||
v-if="showSecuritySettings"
|
|
||||||
@close="handleSecurityClose"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
const timesheets = ref<any[]>([])
|
const timesheets = ref<any[]>([])
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
@@ -42,19 +44,19 @@ async function createTimesheet() {
|
|||||||
newTimesheet.value = { work_date: '', start_time: '', end_time: '', order_id: '' }
|
newTimesheet.value = { work_date: '', start_time: '', end_time: '', order_id: '' }
|
||||||
await loadTimesheets()
|
await loadTimesheets()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function reviewTimesheet(id: string, status: 'approved' | 'rejected') {
|
async function reviewTimesheet(id: string, status: 'approved' | 'rejected') {
|
||||||
const reason = status === 'rejected' ? prompt('Ablehnungsgrund:') : null
|
const reason = status === 'rejected' ? prompt(t('availability.reason') + ':') : null
|
||||||
if (status === 'rejected' && !reason) return
|
if (status === 'rejected' && !reason) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await api.post(`/timesheets/${id}/review`, { status, rejection_reason: reason })
|
await api.post(`/timesheets/${id}/review`, { status, rejection_reason: reason })
|
||||||
await loadTimesheets()
|
await loadTimesheets()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,25 +65,25 @@ function getStatusBadge(status: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getStatusLabel(status: string) {
|
function getStatusLabel(status: string) {
|
||||||
return { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' }[status] || status
|
return t(`timesheets.statuses.${status}`) || status
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">⏱️ Stundenzettel</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">⏱️ {{ t('timesheets.title') }}</h1>
|
||||||
<button class="btn btn-primary" @click="showCreateModal = true">+ Neu</button>
|
<button class="btn btn-primary" @click="showCreateModal = true">+ {{ t('timesheets.new') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
|
<div v-if="loading" class="text-center py-8 text-gray-500">{{ t('app.loading') }}</div>
|
||||||
<div v-else-if="timesheets.length === 0" class="text-center py-8 text-gray-500">Keine Stundenzettel</div>
|
<div v-else-if="timesheets.length === 0" class="text-center py-8 text-gray-500">{{ t('messages.noData') }}</div>
|
||||||
|
|
||||||
<div v-else class="space-y-3">
|
<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 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>
|
<div>
|
||||||
<p class="font-medium">{{ new Date(ts.work_date).toLocaleDateString('de-DE') }}</p>
|
<p class="font-medium">{{ new Date(ts.work_date).toLocaleDateString() }}</p>
|
||||||
<p class="text-sm text-gray-500">
|
<p class="text-sm text-gray-500">
|
||||||
{{ ts.start_time }} - {{ ts.end_time }}
|
{{ ts.start_time }} - {{ ts.end_time }}
|
||||||
<span v-if="ts.hours_worked">({{ ts.hours_worked }}h)</span>
|
<span v-if="ts.hours_worked">({{ ts.hours_worked }}h)</span>
|
||||||
@@ -103,32 +105,32 @@ function getStatusLabel(status: string) {
|
|||||||
<!-- Create Modal -->
|
<!-- Create Modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<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">
|
<div class="card w-full max-w-md m-4">
|
||||||
<h2 class="text-xl font-semibold mb-6">Neuer Stundenzettel</h2>
|
<h2 class="text-xl font-semibold mb-6">{{ t('timesheets.new') }}</h2>
|
||||||
<form @submit.prevent="createTimesheet" class="space-y-4">
|
<form @submit.prevent="createTimesheet" class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Datum *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('app.date') }} *</label>
|
||||||
<input v-model="newTimesheet.work_date" type="date" required class="input" />
|
<input v-model="newTimesheet.work_date" type="date" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Start</label>
|
<label class="block text-sm font-medium mb-1">{{ t('app.from') }}</label>
|
||||||
<input v-model="newTimesheet.start_time" type="time" class="input" />
|
<input v-model="newTimesheet.start_time" type="time" class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Ende</label>
|
<label class="block text-sm font-medium mb-1">{{ t('app.to') }}</label>
|
||||||
<input v-model="newTimesheet.end_time" type="time" class="input" />
|
<input v-model="newTimesheet.end_time" type="time" class="input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Auftrag</label>
|
<label class="block text-sm font-medium mb-1">{{ t('orders.title') }}</label>
|
||||||
<select v-model="newTimesheet.order_id" class="input">
|
<select v-model="newTimesheet.order_id" class="input">
|
||||||
<option value="">-- Kein Auftrag --</option>
|
<option value="">-- {{ t('app.none') }} --</option>
|
||||||
<option v-for="o in orders" :key="o.id" :value="o.id">#{{ o.number }} - {{ o.title }}</option>
|
<option v-for="o in orders" :key="o.id" :value="o.id">#{{ o.number }} - {{ o.title }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Abbrechen</button>
|
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">{{ t('app.cancel') }}</button>
|
||||||
<button type="submit" class="btn btn-primary">Einreichen</button>
|
<button type="submit" class="btn btn-primary">{{ t('app.submit') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { useAuthStore } from '@/stores/auth'
|
import { useAuthStore } from '@/stores/auth'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
interface User {
|
interface User {
|
||||||
@@ -50,7 +52,7 @@ async function createUser() {
|
|||||||
newUser.value = { email: '', password: '', first_name: '', last_name: '', phone: '', role: 'mitarbeiter' }
|
newUser.value = { email: '', password: '', first_name: '', last_name: '', phone: '', role: 'mitarbeiter' }
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler beim Erstellen')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -63,7 +65,7 @@ async function toggleActive(user: User) {
|
|||||||
}
|
}
|
||||||
await loadUsers()
|
await loadUsers()
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert(e instanceof Error ? e.message : 'Fehler')
|
alert(e instanceof Error ? e.message : t('messages.error'))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,33 +79,28 @@ function getRoleBadge(role: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRoleLabel(role: string) {
|
function getRoleLabel(role: string) {
|
||||||
const labels: Record<string, string> = {
|
return t(`users.roles.${role}`) || role
|
||||||
chef: 'Chef',
|
|
||||||
disponent: 'Disponent',
|
|
||||||
mitarbeiter: 'Mitarbeiter'
|
|
||||||
}
|
|
||||||
return labels[role] || role
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">👥 Mitarbeiter</h1>
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">👥 {{ t('users.title') }}</h1>
|
||||||
<button class="btn btn-primary" @click="showCreateModal = true">+ Neu</button>
|
<button class="btn btn-primary" @click="showCreateModal = true">+ {{ t('users.new') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
|
<div v-if="loading" class="text-center py-8 text-gray-500">{{ t('app.loading') }}</div>
|
||||||
<div v-else-if="users.length === 0" class="text-center py-8 text-gray-500">Keine Mitarbeiter</div>
|
<div v-else-if="users.length === 0" class="text-center py-8 text-gray-500">{{ t('messages.noData') }}</div>
|
||||||
|
|
||||||
<table v-else class="w-full">
|
<table v-else class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr class="text-left text-sm text-gray-500 border-b border-gray-200 dark:border-gray-700">
|
<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">{{ t('auth.firstName') }} {{ t('auth.lastName') }}</th>
|
||||||
<th class="pb-3">E-Mail</th>
|
<th class="pb-3">{{ t('auth.email') }}</th>
|
||||||
<th class="pb-3">Rolle</th>
|
<th class="pb-3">{{ t('users.role') }}</th>
|
||||||
<th class="pb-3">Status</th>
|
<th class="pb-3">{{ t('app.status') }}</th>
|
||||||
<th class="pb-3"></th>
|
<th class="pb-3"></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -112,14 +109,14 @@ function getRoleLabel(role: string) {
|
|||||||
<td class="py-3 font-medium">{{ user.first_name }} {{ user.last_name }}</td>
|
<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 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="['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"><span :class="user.active ? 'text-green-600' : 'text-red-600'">{{ user.active ? t('users.active') : t('users.inactive') }}</span></td>
|
||||||
<td class="py-3 text-right">
|
<td class="py-3 text-right">
|
||||||
<button
|
<button
|
||||||
v-if="user.id !== authStore.user?.id && user.role !== 'chef'"
|
v-if="user.id !== authStore.user?.id && user.role !== 'chef'"
|
||||||
class="text-sm text-gray-500 hover:text-red-600"
|
class="text-sm text-gray-500 hover:text-red-600"
|
||||||
@click="toggleActive(user)"
|
@click="toggleActive(user)"
|
||||||
>
|
>
|
||||||
{{ user.active ? 'Deaktivieren' : 'Aktivieren' }}
|
{{ user.active ? t('modules.disable') : t('modules.enable') }}
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -130,40 +127,40 @@ function getRoleLabel(role: string) {
|
|||||||
<!-- Create Modal -->
|
<!-- Create Modal -->
|
||||||
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
|
<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">
|
<div class="card w-full max-w-md m-4">
|
||||||
<h2 class="text-xl font-semibold mb-6">Neuer Mitarbeiter</h2>
|
<h2 class="text-xl font-semibold mb-6">{{ t('users.new') }}</h2>
|
||||||
<form @submit.prevent="createUser" class="space-y-4">
|
<form @submit.prevent="createUser" class="space-y-4">
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Vorname *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('auth.firstName') }} *</label>
|
||||||
<input v-model="newUser.first_name" type="text" required class="input" />
|
<input v-model="newUser.first_name" type="text" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Nachname *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('auth.lastName') }} *</label>
|
||||||
<input v-model="newUser.last_name" type="text" required class="input" />
|
<input v-model="newUser.last_name" type="text" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">E-Mail *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('auth.email') }} *</label>
|
||||||
<input v-model="newUser.email" type="email" required class="input" />
|
<input v-model="newUser.email" type="email" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Passwort *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('auth.password') }} *</label>
|
||||||
<input v-model="newUser.password" type="password" required class="input" />
|
<input v-model="newUser.password" type="password" required class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Telefon</label>
|
<label class="block text-sm font-medium mb-1">{{ t('users.phone') }}</label>
|
||||||
<input v-model="newUser.phone" type="tel" class="input" />
|
<input v-model="newUser.phone" type="tel" class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div v-if="authStore.isChef">
|
<div v-if="authStore.isChef">
|
||||||
<label class="block text-sm font-medium mb-1">Rolle</label>
|
<label class="block text-sm font-medium mb-1">{{ t('users.role') }}</label>
|
||||||
<select v-model="newUser.role" class="input">
|
<select v-model="newUser.role" class="input">
|
||||||
<option value="mitarbeiter">Mitarbeiter</option>
|
<option value="mitarbeiter">{{ t('users.roles.mitarbeiter') }}</option>
|
||||||
<option value="disponent">Disponent</option>
|
<option value="disponent">{{ t('users.roles.disponent') }}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end gap-3 pt-4">
|
<div class="flex justify-end gap-3 pt-4">
|
||||||
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Abbrechen</button>
|
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">{{ t('app.cancel') }}</button>
|
||||||
<button type="submit" class="btn btn-primary">Erstellen</button>
|
<button type="submit" class="btn btn-primary">{{ t('app.add') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
import { api } from '@/api'
|
import { api } from '@/api'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const vehicles = ref<any[]>([])
|
const vehicles = ref<any[]>([])
|
||||||
const showModal = ref(false)
|
const showModal = ref(false)
|
||||||
@@ -24,15 +26,15 @@ async function createVehicle() {
|
|||||||
showModal.value = false
|
showModal.value = false
|
||||||
form.value = { license_plate: '', brand: '', model: '', year: 2024, color: '', fuel_type: 'diesel' }
|
form.value = { license_plate: '', brand: '', model: '', year: 2024, color: '', fuel_type: 'diesel' }
|
||||||
await loadData()
|
await loadData()
|
||||||
} catch (e: any) { alert('Fehler: ' + e.message) }
|
} catch (e: any) { alert(t('messages.error') + ': ' + e.message) }
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadge(s: string): { text: string, class: string, icon: string } {
|
function statusBadge(s: string): { text: string, class: string, icon: string } {
|
||||||
const map: Record<string, any> = {
|
const map: Record<string, any> = {
|
||||||
available: { text: 'Verfügbar', class: 'bg-green-100 text-green-700', icon: '✅' },
|
available: { text: t('vehicles.statuses.available'), class: 'bg-green-100 text-green-700', icon: '✅' },
|
||||||
in_use: { text: 'Im Einsatz', class: 'bg-blue-100 text-blue-700', icon: '🚗' },
|
in_use: { text: t('vehicles.statuses.inUse'), class: 'bg-blue-100 text-blue-700', icon: '🚗' },
|
||||||
maintenance: { text: 'Wartung', class: 'bg-yellow-100 text-yellow-700', icon: '🔧' },
|
maintenance: { text: t('vehicles.statuses.maintenance'), class: 'bg-yellow-100 text-yellow-700', icon: '🔧' },
|
||||||
retired: { text: 'Stillgelegt', class: 'bg-gray-100 text-gray-700', icon: '⛔' }
|
retired: { text: t('vehicles.statuses.outOfService'), class: 'bg-gray-100 text-gray-700', icon: '⛔' }
|
||||||
}
|
}
|
||||||
return map[s] || { text: s, class: 'bg-gray-100', icon: '' }
|
return map[s] || { text: s, class: 'bg-gray-100', icon: '' }
|
||||||
}
|
}
|
||||||
@@ -42,17 +44,17 @@ function statusBadge(s: string): { text: string, class: string, icon: string } {
|
|||||||
<div class="p-6">
|
<div class="p-6">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-2xl font-bold">🚗 Fahrzeuge</h1>
|
<h1 class="text-2xl font-bold">🚗 {{ t('vehicles.title') }}</h1>
|
||||||
<p class="text-gray-500">Fuhrpark verwalten</p>
|
<p class="text-gray-500">{{ t('app.title') }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button @click="showModal = true" class="btn btn-primary">+ Fahrzeug</button>
|
<button @click="showModal = true" class="btn btn-primary">+ {{ t('vehicles.new') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="text-center py-12">Laden...</div>
|
<div v-if="loading" class="text-center py-12">{{ t('app.loading') }}</div>
|
||||||
|
|
||||||
<div v-else-if="vehicles.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
|
<div v-else-if="vehicles.length === 0" class="bg-gray-50 rounded-lg p-12 text-center text-gray-500">
|
||||||
<p class="text-4xl mb-4">🚗</p>
|
<p class="text-4xl mb-4">🚗</p>
|
||||||
<p>Keine Fahrzeuge vorhanden</p>
|
<p>{{ t('messages.noData') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<div v-else class="grid md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
@@ -61,17 +63,17 @@ function statusBadge(s: string): { text: string, class: string, icon: string } {
|
|||||||
<div>
|
<div>
|
||||||
<h3 class="font-semibold text-lg">{{ v.license_plate }}</h3>
|
<h3 class="font-semibold text-lg">{{ v.license_plate }}</h3>
|
||||||
<p class="text-gray-600">{{ v.brand }} {{ v.model }}</p>
|
<p class="text-gray-600">{{ v.brand }} {{ v.model }}</p>
|
||||||
<p v-if="v.year" class="text-sm text-gray-400">Baujahr {{ v.year }}</p>
|
<p v-if="v.year" class="text-sm text-gray-400">{{ t('vehicles.year') }} {{ v.year }}</p>
|
||||||
</div>
|
</div>
|
||||||
<span :class="['px-2 py-1 text-xs rounded', statusBadge(v.status).class]">
|
<span :class="['px-2 py-1 text-xs rounded', statusBadge(v.status).class]">
|
||||||
{{ statusBadge(v.status).icon }} {{ statusBadge(v.status).text }}
|
{{ statusBadge(v.status).icon }} {{ statusBadge(v.status).text }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
|
<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
|
||||||
<div><span class="text-gray-500">KM-Stand:</span> {{ v.current_mileage?.toLocaleString() || '-' }}</div>
|
<div><span class="text-gray-500">{{ t('vehicles.mileage') }}:</span> {{ v.current_mileage?.toLocaleString() || '-' }}</div>
|
||||||
<div><span class="text-gray-500">Kraftstoff:</span> {{ v.fuel_type }}</div>
|
<div><span class="text-gray-500">{{ t('vehicles.fuelLevel') }}:</span> {{ v.fuel_type }}</div>
|
||||||
<div v-if="v.tuev_expires" :class="['col-span-2', new Date(v.tuev_expires) < new Date() ? 'text-red-600' : '']">
|
<div v-if="v.tuev_expires" :class="['col-span-2', new Date(v.tuev_expires) < new Date() ? 'text-red-600' : '']">
|
||||||
TÜV: {{ new Date(v.tuev_expires).toLocaleDateString('de-DE') }}
|
{{ t('vehicles.nextService') }}: {{ new Date(v.tuev_expires).toLocaleDateString() }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -80,29 +82,29 @@ function statusBadge(s: string): { text: string, class: string, icon: string } {
|
|||||||
<!-- Modal -->
|
<!-- Modal -->
|
||||||
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
<div v-if="showModal" class="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md p-6">
|
||||||
<h2 class="text-xl font-bold mb-4">Neues Fahrzeug</h2>
|
<h2 class="text-xl font-bold mb-4">{{ t('vehicles.new') }}</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Kennzeichen *</label>
|
<label class="block text-sm font-medium mb-1">{{ t('vehicles.licensePlate') }} *</label>
|
||||||
<input v-model="form.license_plate" class="input" placeholder="B-AB 1234" />
|
<input v-model="form.license_plate" class="input" placeholder="B-AB 1234" />
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Marke</label>
|
<label class="block text-sm font-medium mb-1">{{ t('vehicles.make') }}</label>
|
||||||
<input v-model="form.brand" class="input" placeholder="VW" />
|
<input v-model="form.brand" class="input" placeholder="VW" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Modell</label>
|
<label class="block text-sm font-medium mb-1">{{ t('vehicles.model') }}</label>
|
||||||
<input v-model="form.model" class="input" placeholder="Passat" />
|
<input v-model="form.model" class="input" placeholder="Passat" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Baujahr</label>
|
<label class="block text-sm font-medium mb-1">{{ t('vehicles.year') }}</label>
|
||||||
<input v-model.number="form.year" type="number" class="input" />
|
<input v-model.number="form.year" type="number" class="input" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium mb-1">Kraftstoff</label>
|
<label class="block text-sm font-medium mb-1">{{ t('vehicles.fuelLevel') }}</label>
|
||||||
<select v-model="form.fuel_type" class="input">
|
<select v-model="form.fuel_type" class="input">
|
||||||
<option value="diesel">Diesel</option>
|
<option value="diesel">Diesel</option>
|
||||||
<option value="petrol">Benzin</option>
|
<option value="petrol">Benzin</option>
|
||||||
@@ -113,8 +115,8 @@ function statusBadge(s: string): { text: string, class: string, icon: string } {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-end space-x-2 mt-6">
|
<div class="flex justify-end space-x-2 mt-6">
|
||||||
<button @click="showModal = false" class="btn">Abbrechen</button>
|
<button @click="showModal = false" class="btn">{{ t('app.cancel') }}</button>
|
||||||
<button @click="createVehicle" class="btn btn-primary">Erstellen</button>
|
<button @click="createVehicle" class="btn btn-primary">{{ t('app.add') }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user