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:
2026-03-13 10:32:03 +00:00
parent eccfdc1e97
commit a4d759e6fd
8 changed files with 246 additions and 564 deletions

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
const { t, locale } = useI18n()
const authStore = useAuthStore()
const currentMonth = ref(new Date())
@@ -17,7 +19,7 @@ const daysInMonth = computed(() => {
const date = new Date(year, month, i + 1)
return {
date: date.toISOString().split('T')[0],
dayOfWeek: date.toLocaleDateString('de-DE', { weekday: 'short' }),
dayOfWeek: date.toLocaleDateString(locale.value, { weekday: 'short' }),
day: i + 1,
isWeekend: date.getDay() === 0 || date.getDay() === 6
}
@@ -25,7 +27,7 @@ const daysInMonth = 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)
@@ -59,7 +61,7 @@ async function toggleDay(date: string) {
await api.post('/availability', { date, available })
await loadAvailability()
} 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>
<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="flex items-center justify-between mb-6">
@@ -85,7 +87,7 @@ function nextMonth() {
<button class="btn btn-secondary" @click="nextMonth"></button>
</div>
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
<div v-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-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="flex items-center gap-2">
<span class="w-4 h-4 bg-green-500 rounded"></span>
<span>Verfügbar</span>
<span>{{ t('availability.available') }}</span>
</div>
<div class="flex items-center gap-2">
<span class="w-4 h-4 bg-gray-200 dark:bg-gray-600 rounded"></span>
<span>Nicht gemeldet</span>
<span>{{ t('availability.unavailable') }}</span>
</div>
</div>
</div>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
const { t } = useI18n()
const authStore = useAuthStore()
const stats = ref({
@@ -17,14 +19,13 @@ const loading = ref(true)
const greeting = computed(() => {
const hour = new Date().getHours()
if (hour < 12) return 'Guten Morgen'
if (hour < 18) return 'Guten Tag'
return 'Guten Abend'
if (hour < 12) return t('dashboard.welcome')
if (hour < 18) return t('dashboard.welcome')
return t('dashboard.welcome')
})
onMounted(async () => {
try {
// Fetch orders
const ordersRes = await api.get<{ orders: any[] }>('/orders')
recentOrders.value = ordersRes.data.orders.slice(0, 5)
@@ -36,13 +37,11 @@ onMounted(async () => {
stats.value.myOrders = ordersRes.data.orders.length
}
// Fetch timesheets (if disponent or chef)
if (authStore.canManageUsers) {
const tsRes = await api.get<{ timesheets: any[] }>('/timesheets?status=pending')
stats.value.pendingTimesheets = tsRes.data.timesheets.length
}
// Check today's availability
const today = new Date().toISOString().split('T')[0]
const availRes = await api.get<{ availability: any[] }>(`/availability?from=${today}&to=${today}`)
stats.value.availableToday = availRes.data.availability.some(a => a.available && a.user_id === authStore.user?.id)
@@ -65,14 +64,7 @@ function getStatusBadge(status: string) {
}
function getStatusLabel(status: string) {
const labels: Record<string, string> = {
draft: 'Entwurf',
published: 'Veröffentlicht',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt'
}
return labels[status] || status
return t(`orders.statuses.${status}`) || status
}
</script>
@@ -84,52 +76,48 @@ function getStatusLabel(status: string) {
{{ greeting }}, {{ authStore.user?.first_name }}! 👋
</h1>
<p class="mt-1 text-primary-100">
Willkommen zurück bei SeCu.
{{ t('dashboard.overview') }}
</p>
</div>
<!-- Stats Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<!-- Open Orders -->
<div class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-blue-100 dark:bg-blue-900 flex items-center justify-center">
<span class="text-2xl">📋</span>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Offene Aufträge</p>
<p class="text-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>
</div>
</div>
</div>
<!-- My Orders (for Mitarbeiter) -->
<div v-if="authStore.isMitarbeiter" class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-green-100 dark:bg-green-900 flex items-center justify-center">
<span class="text-2xl"></span>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Meine Aufträge</p>
<p class="text-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>
</div>
</div>
</div>
<!-- Pending Timesheets (for management) -->
<div v-if="authStore.canManageUsers" class="card">
<div class="flex items-center gap-4">
<div class="w-12 h-12 rounded-lg bg-yellow-100 dark:bg-yellow-900 flex items-center justify-center">
<span class="text-2xl"></span>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Ausstehende Stundenzettel</p>
<p class="text-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>
</div>
</div>
</div>
<!-- Today's availability -->
<div class="card">
<div class="flex items-center gap-4">
<div :class="[
@@ -139,9 +127,9 @@ function getStatusLabel(status: string) {
<span class="text-2xl">{{ stats.availableToday ? '✅' : '❓' }}</span>
</div>
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">Heute verfügbar</p>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t('availability.available') }}</p>
<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>
</div>
</div>
@@ -152,19 +140,19 @@ function getStatusLabel(status: string) {
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">
Aktuelle Aufträge
{{ t('dashboard.recentActivity') }}
</h2>
<router-link to="/orders" class="text-sm text-primary-600 hover:text-primary-700">
Alle anzeigen →
{{ t('app.all') }}
</router-link>
</div>
<div v-if="loading" class="text-center py-8 text-gray-500">
Lädt...
{{ t('app.loading') }}
</div>
<div v-else-if="recentOrders.length === 0" class="text-center py-8 text-gray-500">
Keine Aufträge vorhanden
{{ t('messages.noData') }}
</div>
<div v-else class="space-y-3">

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
const { t } = useI18n()
const authStore = useAuthStore()
interface Order {
@@ -76,7 +78,7 @@ async function createOrder() {
}
await loadOrders()
} 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) {
const labels: Record<string, string> = {
draft: 'Entwurf',
published: 'Veröffentlicht',
in_progress: 'In Bearbeitung',
completed: 'Abgeschlossen',
cancelled: 'Abgesagt'
}
return labels[status] || status
return t(`orders.statuses.${status}`) || status
}
function formatDate(dateStr?: string) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
})
return new Date(dateStr).toLocaleDateString()
}
</script>
@@ -119,28 +108,28 @@ function formatDate(dateStr?: string) {
<!-- Header -->
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
📋 Aufträge
📋 {{ t('orders.title') }}
</h1>
<button
v-if="authStore.canManageOrders"
class="btn btn-primary"
@click="showCreateModal = true"
>
+ Neuer Auftrag
+ {{ t('orders.new') }}
</button>
</div>
<!-- Filters -->
<div class="card">
<div class="flex items-center gap-4">
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Status:</label>
<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">
<option value="">Alle</option>
<option value="draft">Entwurf</option>
<option value="published">Veröffentlicht</option>
<option value="in_progress">In Bearbeitung</option>
<option value="completed">Abgeschlossen</option>
<option value="cancelled">Abgesagt</option>
<option value="">{{ t('app.all') }}</option>
<option value="draft">{{ t('orders.statuses.draft') }}</option>
<option value="scheduled">{{ t('orders.statuses.scheduled') }}</option>
<option value="active">{{ t('orders.statuses.active') }}</option>
<option value="completed">{{ t('orders.statuses.completed') }}</option>
<option value="cancelled">{{ t('orders.statuses.cancelled') }}</option>
</select>
</div>
</div>
@@ -148,11 +137,11 @@ function formatDate(dateStr?: string) {
<!-- Orders List -->
<div class="card">
<div v-if="loading" class="text-center py-8 text-gray-500">
Lädt...
{{ t('app.loading') }}
</div>
<div v-else-if="filteredOrders.length === 0" class="text-center py-8 text-gray-500">
Keine Aufträge gefunden
{{ t('messages.noData') }}
</div>
<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 class="card w-full max-w-2xl max-h-[90vh] overflow-y-auto m-4">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
Neuer Auftrag
{{ t('orders.new') }}
</h2>
<form @submit.prevent="createOrder" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Titel *</label>
<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" />
</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" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Ort</label>
<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" />
</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" />
</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" />
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Kunde</label>
<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" />
</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" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start</label>
<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" />
</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" />
</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" />
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">
Abbrechen
{{ t('app.cancel') }}
</button>
<button type="submit" class="btn btn-primary">
Erstellen
{{ t('app.add') }}
</button>
</div>
</form>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api'
import { useAuthStore } from '@/stores/auth'
const { t } = useI18n()
const authStore = useAuthStore()
interface QualificationType {
@@ -57,13 +59,11 @@ const expiringData = ref<{ expired: Qualification[], expiring_soon: Qualificatio
})
const users = ref<any[]>([])
// Filters
const filterUser = ref('')
const filterCategory = ref('')
const filterStatus = ref('')
const searchQuery = ref('')
// Modal
const showModal = ref(false)
const editingQualification = ref<any>(null)
const formData = ref({
@@ -78,8 +78,6 @@ const formData = ref({
notes: ''
})
const saving = ref(false)
// Tab
const activeTab = ref<'list' | 'expiring' | 'matrix'>('list')
onMounted(async () => {
@@ -127,19 +125,9 @@ async function loadUsers() {
const filteredQualifications = computed(() => {
let result = [...qualifications.value]
if (filterUser.value) {
result = result.filter(q => q.user_id === filterUser.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 (filterUser.value) result = result.filter(q => q.user_id === filterUser.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) {
const query = searchQuery.value.toLowerCase()
result = result.filter(q =>
@@ -148,52 +136,25 @@ const filteredQualifications = computed(() => {
q.last_name.toLowerCase().includes(query)
)
}
return result
})
const allTypes = computed(() => [
...qualificationTypes.value.system,
...qualificationTypes.value.custom
])
const allTypes = computed(() => [...qualificationTypes.value.system, ...qualificationTypes.value.custom])
function openAddModal() {
editingQualification.value = null
formData.value = {
user_id: '',
qualification_type_id: '',
org_qualification_type_id: '',
issued_date: '',
expiry_date: '',
issuer: '',
certificate_number: '',
level: '',
notes: ''
}
formData.value = { user_id: '', qualification_type_id: '', org_qualification_type_id: '', issued_date: '', expiry_date: '', issuer: '', certificate_number: '', level: '', notes: '' }
showModal.value = true
}
function openEditModal(qual: Qualification) {
editingQualification.value = qual
formData.value = {
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: ''
}
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: '' }
showModal.value = true
}
async function saveQualification() {
if (!formData.value.user_id || (!formData.value.qualification_type_id && !formData.value.org_qualification_type_id)) {
return
}
if (!formData.value.user_id || (!formData.value.qualification_type_id && !formData.value.org_qualification_type_id)) return
saving.value = true
try {
if (editingQualification.value) {
@@ -212,8 +173,7 @@ async function saveQualification() {
}
async function deleteQualification(id: string) {
if (!confirm('Qualifikation wirklich löschen?')) return
if (!confirm(t('messages.confirmDelete'))) return
try {
await api.delete(`/qualifications/${id}`)
await loadQualifications()
@@ -235,17 +195,17 @@ function getStatusColor(status: string): string {
function getStatusLabel(status: string): string {
switch (status) {
case 'valid': return 'Gültig'
case 'expiring_soon': return 'Läuft ab'
case 'expired': return 'Abgelaufen'
case 'no_expiry': return 'Unbefristet'
case 'valid': return t('qualifications.valid')
case 'expiring_soon': return t('qualifications.expiringSoon')
case 'expired': return t('qualifications.expired')
case 'no_expiry': return '♾️'
default: return status
}
}
function formatDate(date: string | undefined): string {
if (!date) return '-'
return new Date(date).toLocaleDateString('de-DE')
return new Date(date).toLocaleDateString()
}
function selectQualificationType(type: QualificationType) {
@@ -269,142 +229,87 @@ const selectedTypeName = computed(() => {
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🎓 Qualifikationen</h1>
<button
v-if="authStore.canManageUsers"
@click="openAddModal"
class="btn btn-primary"
>
Qualifikation hinzufügen
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">🎓 {{ t('qualifications.title') }}</h1>
<button v-if="authStore.canManageUsers" @click="openAddModal" class="btn btn-primary">
{{ t('qualifications.new') }}
</button>
</div>
<!-- Tabs -->
<div class="flex gap-2 border-b dark:border-gray-700">
<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']"
>
📋 Übersicht
<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']">
📋 {{ t('dashboard.overview') }}
</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']"
>
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">
<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']">
{{ t('qualifications.expiringSoon') }}
<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 }}
</span>
</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']"
>
<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']">
📊 Matrix
</button>
</div>
<!-- Loading -->
<div v-if="loading" class="text-center py-12">
<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>
<!-- List Tab -->
<div v-else-if="activeTab === 'list'" class="space-y-4">
<!-- Filters -->
<div class="card">
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm font-medium mb-1">🔍 Suche</label>
<input v-model="searchQuery" type="text" class="input" placeholder="Name oder Qualifikation..." />
<label class="block text-sm font-medium mb-1">🔍 {{ t('app.search') }}</label>
<input v-model="searchQuery" type="text" class="input" :placeholder="t('app.search') + '...'" />
</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">
<option value="">Alle</option>
<option v-for="user in users" :key="user.id" :value="user.id">
{{ user.first_name }} {{ user.last_name }}
</option>
<option value="">{{ t('app.all') }}</option>
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.first_name }} {{ user.last_name }}</option>
</select>
</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">
<option value="">Alle</option>
<option v-for="cat in qualificationTypes.categories" :key="cat.key" :value="cat.key">
{{ cat.icon }} {{ cat.name }}
</option>
<option value="">{{ t('app.all') }}</option>
<option v-for="cat in qualificationTypes.categories" :key="cat.key" :value="cat.key">{{ cat.icon }} {{ cat.name }}</option>
</select>
</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">
<option value="">Alle</option>
<option value="valid"> Gültig</option>
<option value="expiring_soon"> Läuft ab</option>
<option value="expired"> Abgelaufen</option>
<option value="no_expiry"> Unbefristet</option>
<option value="">{{ t('app.all') }}</option>
<option value="valid"> {{ t('qualifications.valid') }}</option>
<option value="expiring_soon"> {{ t('qualifications.expiringSoon') }}</option>
<option value="expired"> {{ t('qualifications.expired') }}</option>
</select>
</div>
</div>
</div>
<!-- Results -->
<div v-if="filteredQualifications.length === 0" class="card text-center py-12 text-gray-500">
Keine Qualifikationen gefunden
</div>
<div v-if="filteredQualifications.length === 0" class="card text-center py-12 text-gray-500">{{ t('messages.noData') }}</div>
<div v-else class="grid gap-4">
<div
v-for="qual in filteredQualifications"
:key="qual.id"
class="card hover:shadow-md transition-shadow"
>
<div 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 gap-4">
<div class="text-3xl">{{ qual.icon }}</div>
<div>
<h3 class="font-semibold text-gray-900 dark:text-white">
{{ qual.qualification_name }}
</h3>
<p class="text-sm text-gray-500">
{{ qual.first_name }} {{ qual.last_name }}
</p>
<h3 class="font-semibold text-gray-900 dark:text-white">{{ qual.qualification_name }}</h3>
<p class="text-sm text-gray-500">{{ qual.first_name }} {{ qual.last_name }}</p>
<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)]">
{{ getStatusLabel(qual.expiry_status) }}
</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>
<span :class="['px-2 py-0.5 rounded text-xs font-medium', getStatusColor(qual.expiry_status)]">{{ getStatusLabel(qual.expiry_status) }}</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>
</div>
</div>
</div>
<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>
<button @click="deleteQualification(qual.id)" class="p-2 hover:bg-red-100 dark:hover:bg-red-900 rounded text-red-600">
🗑
</button>
<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>
</div>
</div>
</div>
@@ -413,29 +318,20 @@ const selectedTypeName = computed(() => {
<!-- Expiring Tab -->
<div v-else-if="activeTab === 'expiring'" class="space-y-6">
<!-- Expired -->
<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">
Abgelaufen ({{ expiringData.expired.length }})
</h2>
<h2 class="text-lg font-semibold text-red-600 flex items-center gap-2"> {{ t('qualifications.expired') }} ({{ expiringData.expired.length }})</h2>
<div class="grid gap-3">
<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"
>
<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">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">{{ qual.icon }}</span>
<div>
<p class="font-medium">{{ qual.qualification_name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ qual.first_name }} {{ qual.last_name }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ qual.first_name }} {{ qual.last_name }}</p>
</div>
</div>
<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>
</div>
</div>
@@ -443,31 +339,20 @@ const selectedTypeName = computed(() => {
</div>
</div>
<!-- Expiring Soon -->
<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">
Läuft bald ab ({{ expiringData.expiring_soon.length }})
</h2>
<h2 class="text-lg font-semibold text-yellow-600 flex items-center gap-2"> {{ t('qualifications.expiringSoon') }} ({{ expiringData.expiring_soon.length }})</h2>
<div class="grid gap-3">
<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"
>
<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">
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="text-2xl">{{ qual.icon }}</span>
<div>
<p class="font-medium">{{ qual.qualification_name }}</p>
<p class="text-sm text-gray-600 dark:text-gray-400">
{{ qual.first_name }} {{ qual.last_name }}
</p>
<p class="text-sm text-gray-600 dark:text-gray-400">{{ qual.first_name }} {{ qual.last_name }}</p>
</div>
</div>
<div class="text-right">
<p class="text-sm font-medium text-yellow-600">
Noch {{ qual.days_until_expiry }} Tage
</p>
<p class="text-sm font-medium text-yellow-600">{{ qual.days_until_expiry }} {{ t('time.days') }}</p>
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
</div>
</div>
@@ -475,118 +360,58 @@ const selectedTypeName = computed(() => {
</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>
<p class="text-lg font-medium text-green-600">Alles in Ordnung!</p>
<p class="text-gray-500">Keine ablaufenden Qualifikationen in den nächsten 30 Tagen.</p>
<p class="text-lg font-medium text-green-600">{{ t('messages.success') }}</p>
</div>
</div>
<!-- Matrix Tab (placeholder) -->
<!-- Matrix Tab -->
<div v-else-if="activeTab === 'matrix'" class="card">
<p class="text-gray-500 text-center py-12">
📊 Qualifikations-Matrix wird geladen...<br>
<span class="text-sm">Übersicht: Welcher Mitarbeiter hat welche Qualifikation</span>
</p>
<p class="text-gray-500 text-center py-12">📊 Matrix</p>
</div>
<!-- Add/Edit Modal -->
<!-- Modal -->
<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="p-6">
<h2 class="text-xl font-semibold mb-4">
{{ editingQualification ? '✏️ Qualifikation bearbeiten' : ' Neue Qualifikation' }}
</h2>
<h2 class="text-xl font-semibold mb-4">{{ editingQualification ? '✏️ ' + t('app.edit') : ' ' + t('qualifications.new') }}</h2>
<form @submit.prevent="saveQualification" class="space-y-4">
<!-- Mitarbeiter -->
<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>
<option value="">Bitte wählen...</option>
<option v-for="user in users" :key="user.id" :value="user.id">
{{ user.first_name }} {{ user.last_name }}
</option>
<option value="">{{ t('app.search') }}...</option>
<option v-for="user in users" :key="user.id" :value="user.id">{{ user.first_name }} {{ user.last_name }}</option>
</select>
</div>
<!-- Qualifikationstyp -->
<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 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">
{{ cat.icon }} {{ cat.name }}
</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'
]"
>
<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>
<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']">
{{ type.icon }} {{ type.name }}
</button>
</div>
</div>
<p v-if="selectedTypeName" class="text-sm text-blue-600 mt-1">
Ausgewählt: {{ selectedTypeName }}
</p>
</div>
<div class="grid grid-cols-2 gap-4">
<!-- Ausstellungsdatum -->
<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" />
</div>
<!-- Ablaufdatum -->
<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" />
</div>
</div>
<!-- Aussteller -->
<div>
<label class="block text-sm font-medium mb-1">Ausstellende Stelle</label>
<input v-model="formData.issuer" type="text" class="input" placeholder="z.B. IHK Berlin" />
<label class="block text-sm font-medium mb-1">{{ t('qualifications.issuedBy') }}</label>
<input v-model="formData.issuer" type="text" class="input" />
</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">
<button type="button" @click="showModal = false" class="btn flex-1">
Abbrechen
</button>
<button type="submit" :disabled="saving" class="btn btn-primary flex-1">
{{ saving ? 'Speichern...' : 'Speichern' }}
</button>
<button type="button" @click="showModal = false" class="btn flex-1">{{ t('app.cancel') }}</button>
<button type="submit" :disabled="saving" class="btn btn-primary flex-1">{{ saving ? t('app.loading') : t('app.save') }}</button>
</div>
</form>
</div>

View File

@@ -1,9 +1,11 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
import SecuritySettings from '@/components/SecuritySettings.vue'
const { t } = useI18n()
const authStore = useAuthStore()
const currentPassword = ref('')
@@ -14,151 +16,94 @@ const message = ref('')
const error = ref('')
const showSecuritySettings = ref(false)
// Logo upload
const logoFile = ref<File | null>(null)
const logoPreview = ref<string | null>(null)
const logoLoading = ref(false)
const logoMessage = ref('')
const logoError = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
// App lock status
const lockMethod = ref<string | null>(null)
onMounted(() => {
lockMethod.value = localStorage.getItem('lockMethod')
// Store email for biometric setup
if (authStore.user?.email) {
localStorage.setItem('userEmail', authStore.user.email)
}
// Set logo preview from current org logo
if (authStore.orgLogo) {
logoPreview.value = authStore.orgLogo
}
if (authStore.user?.email) localStorage.setItem('userEmail', authStore.user.email)
if (authStore.orgLogo) logoPreview.value = authStore.orgLogo
})
function handleLogoSelect(event: Event) {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
// Validate file type
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/svg+xml']
if (!allowedTypes.includes(file.type)) {
logoError.value = 'Nur JPG, PNG, WebP oder SVG erlaubt'
return
}
// Validate file size (5MB)
if (file.size > 5 * 1024 * 1024) {
logoError.value = 'Datei zu groß (max 5MB)'
return
}
if (!allowedTypes.includes(file.type)) { logoError.value = t('messages.invalid'); return }
if (file.size > 5 * 1024 * 1024) { logoError.value = t('messages.error'); return }
logoFile.value = file
logoError.value = ''
// Create preview
const reader = new FileReader()
reader.onload = (e) => {
logoPreview.value = e.target?.result as string
}
reader.onload = (e) => { logoPreview.value = e.target?.result as string }
reader.readAsDataURL(file)
}
async function uploadLogo() {
if (!logoFile.value) return
logoLoading.value = true
logoError.value = ''
logoMessage.value = ''
try {
const formData = new FormData()
formData.append('logo', logoFile.value)
const response = await api.post('/uploads/logo', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
})
logoMessage.value = 'Logo erfolgreich hochgeladen'
const response = await api.post('/uploads/logo', formData, { headers: { 'Content-Type': 'multipart/form-data' } })
logoMessage.value = t('messages.saved')
logoFile.value = null
// Update store with new logo URL
authStore.setOrgLogo(response.data.logo_url)
logoPreview.value = response.data.logo_url
} catch (e) {
logoError.value = e instanceof Error ? e.message : 'Fehler beim Hochladen'
} finally {
logoLoading.value = false
}
logoError.value = e instanceof Error ? e.message : t('messages.error')
} finally { logoLoading.value = false }
}
async function deleteLogo() {
if (!confirm('Logo wirklich löschen?')) return
if (!confirm(t('messages.confirmDelete'))) return
logoLoading.value = true
logoError.value = ''
try {
await api.delete('/uploads/logo')
logoMessage.value = 'Logo gelöscht'
logoMessage.value = t('messages.deleted')
logoPreview.value = null
logoFile.value = null
authStore.setOrgLogo(null)
} catch (e) {
logoError.value = e instanceof Error ? e.message : 'Fehler beim Löschen'
} finally {
logoLoading.value = false
}
logoError.value = e instanceof Error ? e.message : t('messages.error')
} finally { logoLoading.value = false }
}
const lockStatusText = computed(() => {
if (!lockMethod.value || lockMethod.value === 'none') return 'Deaktiviert'
if (lockMethod.value === 'biometric') return 'Fingerabdruck / Face ID'
if (lockMethod.value === 'pin') return 'PIN-Code'
return 'Unbekannt'
if (!lockMethod.value || lockMethod.value === 'none') return t('modules.disabled')
if (lockMethod.value === 'biometric') return 'Biometric'
if (lockMethod.value === 'pin') return t('settings.pin')
return ''
})
const lockStatusClass = computed(() => {
if (!lockMethod.value || lockMethod.value === 'none') {
return 'bg-red-100 text-red-700'
}
if (!lockMethod.value || lockMethod.value === 'none') return 'bg-red-100 text-red-700'
return 'bg-green-100 text-green-700'
})
async function changePassword() {
if (newPassword.value !== confirmPassword.value) {
error.value = 'Passwörter stimmen nicht überein'
return
}
if (newPassword.value.length < 8) {
error.value = 'Passwort muss mindestens 8 Zeichen haben'
return
}
if (newPassword.value !== confirmPassword.value) { error.value = t('messages.invalid'); return }
if (newPassword.value.length < 8) { error.value = t('messages.invalid'); return }
loading.value = true
error.value = ''
message.value = ''
try {
await api.post('/auth/change-password', {
currentPassword: currentPassword.value,
newPassword: newPassword.value
})
message.value = 'Passwort erfolgreich geändert'
await api.post('/auth/change-password', { currentPassword: currentPassword.value, newPassword: newPassword.value })
message.value = t('messages.saved')
currentPassword.value = ''
newPassword.value = ''
confirmPassword.value = ''
} catch (e) {
error.value = e instanceof Error ? e.message : 'Fehler beim Ändern'
} finally {
loading.value = false
}
error.value = e instanceof Error ? e.message : t('messages.error')
} finally { loading.value = false }
}
function handleSecurityClose() {
@@ -169,99 +114,51 @@ function handleSecurityClose() {
<template>
<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 -->
<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>
<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>
</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>
</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>
</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>
</div>
</div>
</div>
<!-- Organization Logo (Chef only) -->
<!-- Organization Logo -->
<div v-if="authStore.isChef" class="card">
<h2 class="text-lg font-semibold mb-4">🏢 Firmenlogo</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>
<h2 class="text-lg font-semibold mb-4">🏢 Logo</h2>
<div class="flex items-start gap-6">
<!-- Logo Preview -->
<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">
<img
v-if="logoPreview"
:src="logoPreview"
alt="Logo Vorschau"
class="max-w-full max-h-full object-contain"
>
<img v-if="logoPreview" :src="logoPreview" alt="Logo" class="max-w-full max-h-full object-contain">
<span v-else class="text-4xl text-gray-400">🏢</span>
</div>
</div>
<!-- Upload Controls -->
<div class="flex-1 space-y-4">
<div>
<input
ref="fileInput"
type="file"
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>
<input ref="fileInput" type="file" accept="image/jpeg,image/png,image/webp,image/svg+xml" class="hidden" @change="handleLogoSelect">
<button type="button" class="btn btn-secondary" @click="fileInput?.click()">📁 {{ t('app.upload') }}</button>
<span v-if="logoFile" class="ml-3 text-sm text-gray-600 dark:text-gray-400">{{ logoFile.name }}</span>
</div>
<p class="text-xs text-gray-500">
JPG, PNG, WebP oder SVG max. 5MB empfohlen: 200x80px
</p>
<div class="flex gap-2">
<button
v-if="logoFile"
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>
<button v-if="logoFile" type="button" :disabled="logoLoading" class="btn btn-primary" @click="uploadLogo">{{ logoLoading ? t('app.loading') : ' ' + t('app.upload') }}</button>
<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>
</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>
@@ -270,58 +167,38 @@ function handleSecurityClose() {
<!-- App Security -->
<div class="card">
<h2 class="text-lg font-semibold mb-4">🔐 App-Sicherheit</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>
<h2 class="text-lg font-semibold mb-4">🔐 {{ t('settings.security') }}</h2>
<div class="flex items-center justify-between p-4 bg-gray-50 rounded-lg">
<div>
<div class="font-medium">App-Sperre</div>
<div :class="['inline-block px-2 py-1 rounded text-sm mt-1', lockStatusClass]">
{{ lockStatusText }}
</div>
<div class="font-medium">{{ t('settings.lockScreen') }}</div>
<div :class="['inline-block px-2 py-1 rounded text-sm mt-1', lockStatusClass]">{{ lockStatusText }}</div>
</div>
<button
@click="showSecuritySettings = true"
class="btn btn-primary"
>
Konfigurieren
</button>
<button @click="showSecuritySettings = true" class="btn btn-primary">{{ t('modules.configure') }}</button>
</div>
</div>
<!-- Change Password -->
<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">
<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" />
</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" />
</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" />
</div>
<div v-if="error" class="text-red-600 text-sm">{{ error }}</div>
<div v-if="message" class="text-green-600 text-sm">{{ message }}</div>
<button type="submit" :disabled="loading" class="btn btn-primary">
{{ loading ? 'Speichern...' : 'Passwort ändern' }}
</button>
<button type="submit" :disabled="loading" class="btn btn-primary">{{ loading ? t('app.loading') : t('app.save') }}</button>
</form>
</div>
<!-- Security Settings Modal -->
<SecuritySettings
v-if="showSecuritySettings"
@close="handleSecurityClose"
/>
<SecuritySettings v-if="showSecuritySettings" @close="handleSecurityClose" />
</div>
</template>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
const { t } = useI18n()
const authStore = useAuthStore()
const timesheets = ref<any[]>([])
const loading = ref(true)
@@ -42,19 +44,19 @@ async function createTimesheet() {
newTimesheet.value = { work_date: '', start_time: '', end_time: '', order_id: '' }
await loadTimesheets()
} 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') {
const reason = status === 'rejected' ? prompt('Ablehnungsgrund:') : null
const reason = status === 'rejected' ? prompt(t('availability.reason') + ':') : null
if (status === 'rejected' && !reason) return
try {
await api.post(`/timesheets/${id}/review`, { status, rejection_reason: reason })
await loadTimesheets()
} catch (e) {
alert(e instanceof Error ? e.message : 'Fehler')
alert(e instanceof Error ? e.message : t('messages.error'))
}
}
@@ -63,25 +65,25 @@ function getStatusBadge(status: string) {
}
function getStatusLabel(status: string) {
return { pending: 'Ausstehend', approved: 'Genehmigt', rejected: 'Abgelehnt' }[status] || status
return t(`timesheets.statuses.${status}`) || status
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> Stundenzettel</h1>
<button class="btn btn-primary" @click="showCreateModal = true">+ Neu</button>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white"> {{ t('timesheets.title') }}</h1>
<button class="btn btn-primary" @click="showCreateModal = true">+ {{ t('timesheets.new') }}</button>
</div>
<div class="card">
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
<div v-else-if="timesheets.length === 0" class="text-center py-8 text-gray-500">Keine Stundenzettel</div>
<div v-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">{{ t('messages.noData') }}</div>
<div v-else class="space-y-3">
<div v-for="ts in timesheets" :key="ts.id" class="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700/50 rounded-lg">
<div>
<p class="font-medium">{{ new Date(ts.work_date).toLocaleDateString('de-DE') }}</p>
<p class="font-medium">{{ new Date(ts.work_date).toLocaleDateString() }}</p>
<p class="text-sm text-gray-500">
{{ ts.start_time }} - {{ ts.end_time }}
<span v-if="ts.hours_worked">({{ ts.hours_worked }}h)</span>
@@ -103,32 +105,32 @@ function getStatusLabel(status: string) {
<!-- Create Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-md m-4">
<h2 class="text-xl font-semibold mb-6">Neuer Stundenzettel</h2>
<h2 class="text-xl font-semibold mb-6">{{ t('timesheets.new') }}</h2>
<form @submit.prevent="createTimesheet" class="space-y-4">
<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" />
</div>
<div class="grid grid-cols-2 gap-4">
<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" />
</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" />
</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">
<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>
</select>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Abbrechen</button>
<button type="submit" class="btn btn-primary">Einreichen</button>
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">{{ t('app.cancel') }}</button>
<button type="submit" class="btn btn-primary">{{ t('app.submit') }}</button>
</div>
</form>
</div>

View File

@@ -1,8 +1,10 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { useAuthStore } from '@/stores/auth'
import { api } from '@/api'
const { t } = useI18n()
const authStore = useAuthStore()
interface User {
@@ -50,7 +52,7 @@ async function createUser() {
newUser.value = { email: '', password: '', first_name: '', last_name: '', phone: '', role: 'mitarbeiter' }
await loadUsers()
} 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()
} 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) {
const labels: Record<string, string> = {
chef: 'Chef',
disponent: 'Disponent',
mitarbeiter: 'Mitarbeiter'
}
return labels[role] || role
return t(`users.roles.${role}`) || role
}
</script>
<template>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">👥 Mitarbeiter</h1>
<button class="btn btn-primary" @click="showCreateModal = true">+ Neu</button>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">👥 {{ t('users.title') }}</h1>
<button class="btn btn-primary" @click="showCreateModal = true">+ {{ t('users.new') }}</button>
</div>
<div class="card">
<div v-if="loading" class="text-center py-8 text-gray-500">Lädt...</div>
<div v-else-if="users.length === 0" class="text-center py-8 text-gray-500">Keine Mitarbeiter</div>
<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">{{ t('messages.noData') }}</div>
<table v-else class="w-full">
<thead>
<tr class="text-left text-sm text-gray-500 border-b border-gray-200 dark:border-gray-700">
<th class="pb-3">Name</th>
<th class="pb-3">E-Mail</th>
<th class="pb-3">Rolle</th>
<th class="pb-3">Status</th>
<th class="pb-3">{{ t('auth.firstName') }} {{ t('auth.lastName') }}</th>
<th class="pb-3">{{ t('auth.email') }}</th>
<th class="pb-3">{{ t('users.role') }}</th>
<th class="pb-3">{{ t('app.status') }}</th>
<th class="pb-3"></th>
</tr>
</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 text-gray-500">{{ user.email }}</td>
<td class="py-3"><span :class="['badge', getRoleBadge(user.role)]">{{ getRoleLabel(user.role) }}</span></td>
<td class="py-3"><span :class="user.active ? 'text-green-600' : 'text-red-600'">{{ user.active ? 'Aktiv' : 'Inaktiv' }}</span></td>
<td class="py-3"><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">
<button
v-if="user.id !== authStore.user?.id && user.role !== 'chef'"
class="text-sm text-gray-500 hover:text-red-600"
@click="toggleActive(user)"
>
{{ user.active ? 'Deaktivieren' : 'Aktivieren' }}
{{ user.active ? t('modules.disable') : t('modules.enable') }}
</button>
</td>
</tr>
@@ -130,40 +127,40 @@ function getRoleLabel(role: string) {
<!-- Create Modal -->
<div v-if="showCreateModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="card w-full max-w-md m-4">
<h2 class="text-xl font-semibold mb-6">Neuer Mitarbeiter</h2>
<h2 class="text-xl font-semibold mb-6">{{ t('users.new') }}</h2>
<form @submit.prevent="createUser" class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">Vorname *</label>
<label class="block text-sm font-medium mb-1">{{ t('auth.firstName') }} *</label>
<input v-model="newUser.first_name" type="text" required class="input" />
</div>
<div>
<label class="block text-sm font-medium mb-1">Nachname *</label>
<label class="block text-sm font-medium mb-1">{{ t('auth.lastName') }} *</label>
<input v-model="newUser.last_name" type="text" required class="input" />
</div>
</div>
<div>
<label class="block text-sm font-medium mb-1">E-Mail *</label>
<label class="block text-sm font-medium mb-1">{{ t('auth.email') }} *</label>
<input v-model="newUser.email" type="email" required class="input" />
</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" />
</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" />
</div>
<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">
<option value="mitarbeiter">Mitarbeiter</option>
<option value="disponent">Disponent</option>
<option value="mitarbeiter">{{ t('users.roles.mitarbeiter') }}</option>
<option value="disponent">{{ t('users.roles.disponent') }}</option>
</select>
</div>
<div class="flex justify-end gap-3 pt-4">
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">Abbrechen</button>
<button type="submit" class="btn btn-primary">Erstellen</button>
<button type="button" class="btn btn-secondary" @click="showCreateModal = false">{{ t('app.cancel') }}</button>
<button type="submit" class="btn btn-primary">{{ t('app.add') }}</button>
</div>
</form>
</div>

View File

@@ -1,7 +1,9 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { useI18n } from 'vue-i18n'
import { api } from '@/api'
const { t } = useI18n()
const loading = ref(true)
const vehicles = ref<any[]>([])
const showModal = ref(false)
@@ -24,15 +26,15 @@ async function createVehicle() {
showModal.value = false
form.value = { license_plate: '', brand: '', model: '', year: 2024, color: '', fuel_type: 'diesel' }
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 } {
const map: Record<string, any> = {
available: { text: 'Verfügbar', class: 'bg-green-100 text-green-700', icon: '✅' },
in_use: { text: 'Im Einsatz', class: 'bg-blue-100 text-blue-700', icon: '🚗' },
maintenance: { text: 'Wartung', class: 'bg-yellow-100 text-yellow-700', icon: '🔧' },
retired: { text: 'Stillgelegt', class: 'bg-gray-100 text-gray-700', icon: '⛔' }
available: { text: t('vehicles.statuses.available'), class: 'bg-green-100 text-green-700', icon: '✅' },
in_use: { text: t('vehicles.statuses.inUse'), class: 'bg-blue-100 text-blue-700', icon: '🚗' },
maintenance: { text: t('vehicles.statuses.maintenance'), class: 'bg-yellow-100 text-yellow-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: '' }
}
@@ -42,17 +44,17 @@ function statusBadge(s: string): { text: string, class: string, icon: string } {
<div class="p-6">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-2xl font-bold">🚗 Fahrzeuge</h1>
<p class="text-gray-500">Fuhrpark verwalten</p>
<h1 class="text-2xl font-bold">🚗 {{ t('vehicles.title') }}</h1>
<p class="text-gray-500">{{ t('app.title') }}</p>
</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 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">
<p class="text-4xl mb-4">🚗</p>
<p>Keine Fahrzeuge vorhanden</p>
<p>{{ t('messages.noData') }}</p>
</div>
<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>
<h3 class="font-semibold text-lg">{{ v.license_plate }}</h3>
<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>
<span :class="['px-2 py-1 text-xs rounded', statusBadge(v.status).class]">
{{ statusBadge(v.status).icon }} {{ statusBadge(v.status).text }}
</span>
</div>
<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">Kraftstoff:</span> {{ v.fuel_type }}</div>
<div><span class="text-gray-500">{{ t('vehicles.mileage') }}:</span> {{ v.current_mileage?.toLocaleString() || '-' }}</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' : '']">
TÜV: {{ new Date(v.tuev_expires).toLocaleDateString('de-DE') }}
{{ t('vehicles.nextService') }}: {{ new Date(v.tuev_expires).toLocaleDateString() }}
</div>
</div>
</div>
@@ -80,29 +82,29 @@ function statusBadge(s: string): { text: string, class: string, icon: string } {
<!-- Modal -->
<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">
<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>
<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" />
</div>
<div class="grid grid-cols-2 gap-4">
<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" />
</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" />
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<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" />
</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">
<option value="diesel">Diesel</option>
<option value="petrol">Benzin</option>
@@ -113,8 +115,8 @@ function statusBadge(s: string): { text: string, class: string, icon: string } {
</div>
</div>
<div class="flex justify-end space-x-2 mt-6">
<button @click="showModal = false" class="btn">Abbrechen</button>
<button @click="createVehicle" class="btn btn-primary">Erstellen</button>
<button @click="showModal = false" class="btn">{{ t('app.cancel') }}</button>
<button @click="createVehicle" class="btn btn-primary">{{ t('app.add') }}</button>
</div>
</div>
</div>