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