core.js still routed plugin-owned pages directly. Extracted all 11 into the owning plugin's route file + moved their views into plugins/<name>/frontend/: - computers: reports/pc-relationships, settings/pctypemapping - printers: reports/toner, settings/printertypes, settings/zabbix (toner/supply monitoring) - machines: settings/machinetypes - network: settings/networktypes - warranty: settings/dellwarranty - slides: settings/slides (its route file gains a default export; it was toplevel-only) - employees: NEW plugin frontend (employees/:sso + settings/employeedirectory) - employees had no route file before; its pages lived only in core.js. core.js now holds only core routes; all 14 bundled plugins are self-contained under plugins/<name>/frontend/. Verified live: the extracted Machine Types settings page renders in the settings rail from the machines plugin frontend. Build + 58 vitest + naming green.
363 lines
11 KiB
Vue
363 lines
11 KiB
Vue
<template>
|
|
<div class="detail-page">
|
|
<div v-if="loading" class="loading">Loading...</div>
|
|
<div v-else-if="error" class="error-message">{{ error }}</div>
|
|
|
|
<template v-else-if="employee">
|
|
<div class="hero-card">
|
|
<div class="hero-image" v-if="employee.photourl">
|
|
<img :src="employee.photourl" :alt="fullName" @error="onPhotoError" />
|
|
</div>
|
|
<div class="hero-image placeholder" v-else>
|
|
<img :src="fallbackAvatar" alt="GE Aerospace" class="ge-avatar-fallback-lg" />
|
|
</div>
|
|
<div class="hero-content">
|
|
<h1 class="hero-title">{{ fullName }}</h1>
|
|
<div class="hero-meta">
|
|
<span class="badge">SSO: {{ employee.SSO }}</span>
|
|
<span v-if="employee.Team" class="badge badge-primary">{{ employee.Team }}</span>
|
|
</div>
|
|
<div class="hero-details">
|
|
<p v-if="employee.Role"><strong>Role:</strong> {{ employee.Role }}</p>
|
|
</div>
|
|
<div class="hero-actions">
|
|
<router-link to="/" class="btn">Back to Dashboard</router-link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Recognitions -->
|
|
<div class="section-card">
|
|
<h2 class="section-title">
|
|
Recognitions
|
|
<span v-if="recognitions.length > 0" class="count-badge">{{ recognitions.length }}</span>
|
|
</h2>
|
|
<div v-if="recognitionsLoading" class="loading">Loading...</div>
|
|
<div v-else-if="recognitions.length === 0" class="empty">
|
|
No recognitions yet.
|
|
</div>
|
|
<div v-else class="recognitions-list">
|
|
<div v-for="rec in displayedRecognitions" :key="rec.notificationid" class="recognition-card">
|
|
<div class="recognition-header">
|
|
<span class="badge" :style="{ backgroundColor: rec.typecolor || '#14abef' }">
|
|
{{ rec.typename || 'Recognition' }}
|
|
</span>
|
|
<span class="recognition-date">{{ formatDate(rec.startdate) }}</span>
|
|
</div>
|
|
<div class="recognition-message">{{ rec.notification }}</div>
|
|
</div>
|
|
<button
|
|
v-if="recognitions.length > recognitionsLimit && !showAllRecognitions"
|
|
class="btn btn-secondary show-more-btn"
|
|
@click="showAllRecognitions = true"
|
|
>
|
|
Show {{ recognitions.length - recognitionsLimit }} more
|
|
</button>
|
|
<button
|
|
v-if="showAllRecognitions && recognitions.length > recognitionsLimit"
|
|
class="btn btn-secondary show-more-btn"
|
|
@click="showAllRecognitions = false"
|
|
>
|
|
Show less
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Currently Checked Out USB Devices -->
|
|
<div v-if="usbEnabled" class="section-card">
|
|
<h2 class="section-title">Checked Out USB Devices</h2>
|
|
<div v-if="usbLoading" class="loading">Loading...</div>
|
|
<div v-else-if="usbDevices.length === 0" class="empty">
|
|
No USB devices currently checked out.
|
|
</div>
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Device</th>
|
|
<th>Checked Out</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="checkout in usbDevices" :key="checkout.log_id">
|
|
<td>
|
|
<router-link :to="`/usb/${checkout.device_id}`">
|
|
{{ checkout.device_id }}
|
|
</router-link>
|
|
</td>
|
|
<td>{{ formatDate(checkout.timestamp) }}</td>
|
|
<td class="actions">
|
|
<button class="btn btn-small btn-success" @click="checkinDevice(checkout)">
|
|
Check In
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- USB Checkout History -->
|
|
<div v-if="usbEnabled" class="section-card">
|
|
<h2 class="section-title">USB Checkout History</h2>
|
|
<div v-if="historyLoading" class="loading">Loading...</div>
|
|
<div v-else-if="checkoutHistory.length === 0" class="empty">
|
|
No checkout history.
|
|
</div>
|
|
<div v-else class="table-container">
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Device</th>
|
|
<th>Checked Out</th>
|
|
<th>Device Status</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr v-for="record in displayedHistory" :key="record.log_id">
|
|
<td>
|
|
<router-link :to="`/usb/${record.device_id}`">
|
|
{{ record.device_id }}
|
|
</router-link>
|
|
</td>
|
|
<td>{{ formatDate(record.timestamp) }}</td>
|
|
<td>{{ record.device_status || '-' }}</td>
|
|
</tr>
|
|
</tbody>
|
|
</table>
|
|
<button
|
|
v-if="checkoutHistory.length > historyLimit && !showAllHistory"
|
|
class="btn btn-secondary show-more-btn"
|
|
@click="showAllHistory = true"
|
|
>
|
|
Show {{ checkoutHistory.length - historyLimit }} more
|
|
</button>
|
|
<button
|
|
v-if="showAllHistory && checkoutHistory.length > historyLimit"
|
|
class="btn btn-secondary show-more-btn"
|
|
@click="showAllHistory = false"
|
|
>
|
|
Show less
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { withBase } from '@/utils/basePath'
|
|
import { employeesApi, usbApi, notificationsApi } from '@/api'
|
|
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
|
import { useToast } from '@/composables/toast'
|
|
const toast = useToast()
|
|
|
|
const route = useRoute()
|
|
|
|
const employee = ref(null)
|
|
const recognitions = ref([])
|
|
const usbDevices = ref([])
|
|
const checkoutHistory = ref([])
|
|
const usbEnabled = ref(true)
|
|
const loading = ref(true)
|
|
const recognitionsLoading = ref(true)
|
|
const usbLoading = ref(true)
|
|
const historyLoading = ref(true)
|
|
const error = ref('')
|
|
|
|
const recognitionsLimit = 5
|
|
const showAllRecognitions = ref(false)
|
|
|
|
const displayedRecognitions = computed(() => {
|
|
if (showAllRecognitions.value) {
|
|
return recognitions.value
|
|
}
|
|
return recognitions.value.slice(0, recognitionsLimit)
|
|
})
|
|
|
|
const historyLimit = 10
|
|
const showAllHistory = ref(false)
|
|
|
|
const displayedHistory = computed(() => {
|
|
if (showAllHistory.value) {
|
|
return checkoutHistory.value
|
|
}
|
|
return checkoutHistory.value.slice(0, historyLimit)
|
|
})
|
|
|
|
const fullName = computed(() => {
|
|
if (!employee.value) return ''
|
|
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
|
|
})
|
|
|
|
const fallbackAvatar = withBase('/ge-monogram.svg')
|
|
|
|
function onPhotoError(event) {
|
|
event.target.src = fallbackAvatar
|
|
event.target.classList.add('ge-avatar-fallback-lg')
|
|
}
|
|
|
|
const initials = computed(() => {
|
|
if (!employee.value) return '?'
|
|
const first = employee.value.First_Name?.trim()?.[0] || ''
|
|
const last = employee.value.Last_Name?.trim()?.[0] || ''
|
|
return (first + last).toUpperCase() || '?'
|
|
})
|
|
|
|
onMounted(async () => {
|
|
await loadEmployee()
|
|
// Skip the USB panels entirely when the usb plugin is disabled - its
|
|
// /api/usb routes 404 otherwise and spam the console.
|
|
await loadEnabledPlugins()
|
|
usbEnabled.value = isPluginEnabled('usb')
|
|
const tasks = [loadRecognitions()]
|
|
if (usbEnabled.value) tasks.push(loadUSBDevices(), loadCheckoutHistory())
|
|
await Promise.all(tasks)
|
|
})
|
|
|
|
async function loadEmployee() {
|
|
loading.value = true
|
|
try {
|
|
const response = await employeesApi.lookup(route.params.sso)
|
|
employee.value = response.data.data
|
|
} catch (err) {
|
|
console.error('Error loading employee:', err)
|
|
error.value = 'Employee not found'
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadRecognitions() {
|
|
recognitionsLoading.value = true
|
|
try {
|
|
const response = await notificationsApi.getEmployeeRecognitions(route.params.sso)
|
|
recognitions.value = response.data.data?.recognitions || []
|
|
} catch (err) {
|
|
console.error('Error loading recognitions:', err)
|
|
recognitions.value = []
|
|
} finally {
|
|
recognitionsLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadUSBDevices() {
|
|
usbLoading.value = true
|
|
try {
|
|
// active check-out log rows for this badge
|
|
const response = await usbApi.getUserCheckouts(route.params.sso)
|
|
usbDevices.value = response.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading USB devices:', err)
|
|
} finally {
|
|
usbLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function loadCheckoutHistory() {
|
|
historyLoading.value = true
|
|
try {
|
|
// all check-out log rows for this badge, newest first
|
|
const response = await usbApi.getUserCheckouts(route.params.sso, false)
|
|
checkoutHistory.value = response.data.data || []
|
|
} catch (err) {
|
|
console.error('Error loading checkout history:', err)
|
|
checkoutHistory.value = []
|
|
} finally {
|
|
historyLoading.value = false
|
|
}
|
|
}
|
|
|
|
async function checkinDevice(checkout) {
|
|
if (!confirm(`Check in ${checkout.device_id}?`)) return
|
|
|
|
try {
|
|
await usbApi.checkin(checkout.device_id, { badge: checkout.badge_number || route.params.sso })
|
|
await loadUSBDevices()
|
|
await loadCheckoutHistory()
|
|
} catch (err) {
|
|
console.error('Error checking in device:', err)
|
|
toast.error('Failed to check in device')
|
|
}
|
|
}
|
|
|
|
function formatDate(dateStr) {
|
|
if (!dateStr) return ''
|
|
return new Date(dateStr).toLocaleString()
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.hero-image.placeholder {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
background: var(--primary);
|
|
color: white;
|
|
}
|
|
|
|
.ge-avatar-fallback-lg {
|
|
width: 60%;
|
|
height: 60%;
|
|
object-fit: contain;
|
|
opacity: 0.85;
|
|
}
|
|
.initials {
|
|
font-size: 3rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.recognitions-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 1rem;
|
|
}
|
|
|
|
.recognition-card {
|
|
padding: 1rem;
|
|
background: var(--bg);
|
|
border-radius: 8px;
|
|
border-left: 4px solid var(--primary);
|
|
}
|
|
|
|
.recognition-header {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
margin-bottom: 0.5rem;
|
|
}
|
|
|
|
.recognition-date {
|
|
color: var(--text-light);
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.recognition-message {
|
|
white-space: pre-wrap;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.count-badge {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-width: 1.5rem;
|
|
height: 1.5rem;
|
|
padding: 0 0.5rem;
|
|
margin-left: 0.5rem;
|
|
background: var(--primary);
|
|
color: white;
|
|
border-radius: 999px;
|
|
font-size: 0.8rem;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.show-more-btn {
|
|
width: 100%;
|
|
margin-top: 0.5rem;
|
|
}
|
|
</style>
|