Add Qualifications view with full UI
This commit is contained in:
@@ -27,6 +27,7 @@ const navigation = computed(() => {
|
|||||||
items.push(
|
items.push(
|
||||||
{ name: 'Verfügbarkeit', href: '/availability', icon: '📅' },
|
{ name: 'Verfügbarkeit', href: '/availability', icon: '📅' },
|
||||||
{ name: 'Stundenzettel', href: '/timesheets', icon: '⏱️' },
|
{ name: 'Stundenzettel', href: '/timesheets', icon: '⏱️' },
|
||||||
|
{ name: 'Qualifikationen', href: '/qualifications', icon: '🎓' },
|
||||||
)
|
)
|
||||||
|
|
||||||
if (authStore.isChef) {
|
if (authStore.isChef) {
|
||||||
|
|||||||
@@ -78,6 +78,11 @@ const router = createRouter({
|
|||||||
path: 'help',
|
path: 'help',
|
||||||
name: 'help',
|
name: 'help',
|
||||||
component: () => import('@/views/HelpView.vue')
|
component: () => import('@/views/HelpView.vue')
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'qualifications',
|
||||||
|
name: 'qualifications',
|
||||||
|
component: () => import('@/views/QualificationsView.vue')
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
596
src/views/QualificationsView.vue
Normal file
596
src/views/QualificationsView.vue
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import { api } from '@/api'
|
||||||
|
import { useAuthStore } from '@/stores/auth'
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
interface QualificationType {
|
||||||
|
id: string
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
description?: string
|
||||||
|
category: string
|
||||||
|
has_expiry: boolean
|
||||||
|
validity_months?: number
|
||||||
|
icon: string
|
||||||
|
source: 'system' | 'custom'
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Qualification {
|
||||||
|
id: string
|
||||||
|
user_id: string
|
||||||
|
first_name: string
|
||||||
|
last_name: string
|
||||||
|
email: string
|
||||||
|
qualification_key: string
|
||||||
|
qualification_name: string
|
||||||
|
category: string
|
||||||
|
icon: string
|
||||||
|
issued_date?: string
|
||||||
|
expiry_date?: string
|
||||||
|
issuer?: string
|
||||||
|
certificate_number?: string
|
||||||
|
level?: string
|
||||||
|
document_url?: string
|
||||||
|
status: string
|
||||||
|
expiry_status: 'valid' | 'expiring_soon' | 'expired' | 'no_expiry'
|
||||||
|
days_until_expiry?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Category {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
icon: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const qualifications = ref<Qualification[]>([])
|
||||||
|
const qualificationTypes = ref<{ system: QualificationType[], custom: QualificationType[], categories: Category[] }>({
|
||||||
|
system: [],
|
||||||
|
custom: [],
|
||||||
|
categories: []
|
||||||
|
})
|
||||||
|
const expiringData = ref<{ expired: Qualification[], expiring_soon: Qualification[] }>({
|
||||||
|
expired: [],
|
||||||
|
expiring_soon: []
|
||||||
|
})
|
||||||
|
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({
|
||||||
|
user_id: '',
|
||||||
|
qualification_type_id: '',
|
||||||
|
org_qualification_type_id: '',
|
||||||
|
issued_date: '',
|
||||||
|
expiry_date: '',
|
||||||
|
issuer: '',
|
||||||
|
certificate_number: '',
|
||||||
|
level: '',
|
||||||
|
notes: ''
|
||||||
|
})
|
||||||
|
const saving = ref(false)
|
||||||
|
|
||||||
|
// Tab
|
||||||
|
const activeTab = ref<'list' | 'expiring' | 'matrix'>('list')
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await Promise.all([
|
||||||
|
loadQualificationTypes(),
|
||||||
|
loadQualifications(),
|
||||||
|
loadExpiringQualifications(),
|
||||||
|
loadUsers()
|
||||||
|
])
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadQualificationTypes() {
|
||||||
|
try {
|
||||||
|
const data = await api.get('/qualifications/types')
|
||||||
|
qualificationTypes.value = data
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load types:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadQualifications() {
|
||||||
|
try {
|
||||||
|
qualifications.value = await api.get('/qualifications')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load qualifications:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadExpiringQualifications() {
|
||||||
|
try {
|
||||||
|
expiringData.value = await api.get('/qualifications/expiring?days=30')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load expiring:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
users.value = await api.get('/users')
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load users:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(q =>
|
||||||
|
q.qualification_name.toLowerCase().includes(query) ||
|
||||||
|
q.first_name.toLowerCase().includes(query) ||
|
||||||
|
q.last_name.toLowerCase().includes(query)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
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: ''
|
||||||
|
}
|
||||||
|
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: ''
|
||||||
|
}
|
||||||
|
showModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveQualification() {
|
||||||
|
if (!formData.value.user_id || (!formData.value.qualification_type_id && !formData.value.org_qualification_type_id)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (editingQualification.value) {
|
||||||
|
await api.put(`/qualifications/${editingQualification.value.id}`, formData.value)
|
||||||
|
} else {
|
||||||
|
await api.post('/qualifications', formData.value)
|
||||||
|
}
|
||||||
|
showModal.value = false
|
||||||
|
await loadQualifications()
|
||||||
|
await loadExpiringQualifications()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to save:', e)
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteQualification(id: string) {
|
||||||
|
if (!confirm('Qualifikation wirklich löschen?')) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.delete(`/qualifications/${id}`)
|
||||||
|
await loadQualifications()
|
||||||
|
await loadExpiringQualifications()
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to delete:', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatusColor(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case 'valid': return 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
|
case 'expiring_soon': return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||||
|
case 'expired': return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
case 'no_expiry': return 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200'
|
||||||
|
default: return 'bg-gray-100 text-gray-800'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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'
|
||||||
|
default: return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(date: string | undefined): string {
|
||||||
|
if (!date) return '-'
|
||||||
|
return new Date(date).toLocaleDateString('de-DE')
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectQualificationType(type: QualificationType) {
|
||||||
|
if (type.source === 'system') {
|
||||||
|
formData.value.qualification_type_id = type.id
|
||||||
|
formData.value.org_qualification_type_id = ''
|
||||||
|
} else {
|
||||||
|
formData.value.org_qualification_type_id = type.id
|
||||||
|
formData.value.qualification_type_id = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedTypeName = computed(() => {
|
||||||
|
const typeId = formData.value.qualification_type_id || formData.value.org_qualification_type_id
|
||||||
|
if (!typeId) return ''
|
||||||
|
const type = allTypes.value.find(t => t.id === typeId)
|
||||||
|
return type ? `${type.icon} ${type.name}` : ''
|
||||||
|
})
|
||||||
|
</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">🎓 Qualifikationen</h1>
|
||||||
|
<button
|
||||||
|
v-if="authStore.canManageUsers"
|
||||||
|
@click="openAddModal"
|
||||||
|
class="btn btn-primary"
|
||||||
|
>
|
||||||
|
➕ Qualifikation hinzufügen
|
||||||
|
</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>
|
||||||
|
<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">
|
||||||
|
{{ 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']"
|
||||||
|
>
|
||||||
|
📊 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>
|
||||||
|
</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..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">👤 Mitarbeiter</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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">📂 Kategorie</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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">📊 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>
|
||||||
|
</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-else class="grid gap-4">
|
||||||
|
<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>
|
||||||
|
<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>
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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>
|
||||||
|
<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 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-sm font-medium text-red-600">Abgelaufen</p>
|
||||||
|
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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>
|
||||||
|
<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 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>
|
||||||
|
</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 text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
|
||||||
|
</div>
|
||||||
|
</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 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Matrix Tab (placeholder) -->
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Add/Edit 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>
|
||||||
|
|
||||||
|
<form @submit.prevent="saveQualification" class="space-y-4">
|
||||||
|
<!-- Mitarbeiter -->
|
||||||
|
<div v-if="!editingQualification">
|
||||||
|
<label class="block text-sm font-medium mb-1">Mitarbeiter *</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>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Qualifikationstyp -->
|
||||||
|
<div v-if="!editingQualification">
|
||||||
|
<label class="block text-sm font-medium mb-1">Qualifikation *</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'
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
{{ 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>
|
||||||
|
<input v-model="formData.issued_date" type="date" class="input" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Ablaufdatum -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Ablaufdatum</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" />
|
||||||
|
</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>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
Reference in New Issue
Block a user