Files
secu-frontend/src/views/QualificationsView.vue
OpenClaw a4d759e6fd 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
2026-03-13 10:32:03 +00:00

422 lines
17 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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 {
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[]>([])
const filterUser = ref('')
const filterCategory = ref('')
const filterStatus = ref('')
const searchQuery = ref('')
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)
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: '', 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(t('messages.confirmDelete'))) 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 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()
}
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">🎓 {{ 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']">
📋 {{ 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']">
{{ 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']">
📊 Matrix
</button>
</div>
<div v-if="loading" class="text-center py-12">
<div class="animate-spin text-4xl"></div>
<p class="mt-2 text-gray-500">{{ t('app.loading') }}</p>
</div>
<!-- List Tab -->
<div v-else-if="activeTab === 'list'" class="space-y-4">
<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">🔍 {{ 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">👤 {{ t('users.employee') }}</label>
<select v-model="filterUser" class="input">
<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">📂 {{ t('documents.category') }}</label>
<select v-model="filterCategory" class="input">
<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">📊 {{ t('app.status') }}</label>
<select v-model="filterStatus" class="input">
<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>
<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 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>
</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">
<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"> {{ 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 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">{{ t('qualifications.expired') }}</p>
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
</div>
</div>
</div>
</div>
</div>
<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"> {{ 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 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">{{ qual.days_until_expiry }} {{ t('time.days') }}</p>
<p class="text-sm text-gray-500">{{ formatDate(qual.expiry_date) }}</p>
</div>
</div>
</div>
</div>
</div>
<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">{{ t('messages.success') }}</p>
</div>
</div>
<!-- Matrix Tab -->
<div v-else-if="activeTab === 'matrix'" class="card">
<p class="text-gray-500 text-center py-12">📊 Matrix</p>
</div>
<!-- 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 ? '✏️ ' + t('app.edit') : ' ' + t('qualifications.new') }}</h2>
<form @submit.prevent="saveQualification" class="space-y-4">
<div v-if="!editingQualification">
<label class="block text-sm font-medium mb-1">{{ t('users.employee') }} *</label>
<select v-model="formData.user_id" class="input" required>
<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>
<div v-if="!editingQualification">
<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 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>
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium mb-1">{{ t('qualifications.issueDate') }}</label>
<input v-model="formData.issued_date" type="date" class="input" />
</div>
<div>
<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>
<div>
<label class="block text-sm font-medium mb-1">{{ t('qualifications.issuedBy') }}</label>
<input v-model="formData.issuer" type="text" class="input" />
</div>
<div class="flex gap-3 pt-4">
<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>
</div>
</div>
</div>
</template>