Every detail page read "West Jefferson ShopDB - Machine". Two tabs on two different machines were indistinguishable, a bookmark said nothing about what it pointed at, and a link shared into Teams previewed as the section rather than the thing. The router cannot fix this alone: afterEach runs before the view has fetched anything, so it only ever knows the KIND of page. So a detail view names its record once the record arrives, and both writers go through composables/pageTitle.js. THE ORDERING IS THE WHOLE PROBLEM. The router awaits the site settings to build the "<facility> ShopDB" prefix, so on a cold load it finishes AFTER a view that fetched quickly and would overwrite the good title with the generic one. The route's fullPath is claimed before that await, and the generic write stands down when a record title already belongs to the route being landed on. The reverse case is handled too: a view that names its record BEFORE the facility name arrives composed against the placeholder prefix, so the late resolve rewrites it rather than leaving "ShopDB - CSF16" on a site called West Jefferson. Both are pinned by tests, because both fail only under timing. Covers machines, PCs, printers, network devices, subnets, measuring tools, USB devices, printed parts, applications, employees, knowledge base articles and guides. Printers reuse the page's own displayTitle, which already discards the literal name "NONE" a discovered queue can carry, so the tab and the heading agree. A record with no usable name, or one that failed to load, keeps the section name: "Machine" beats "Machine 4192" for somebody scanning a row of tabs. Long names are cut at a word boundary - knowledge base articles are named with a whole sentence of description, which a browser otherwise truncates mid-word. Reports gained meta.title, which the router already supported and nothing used. That also fixes "Pc Relationships", which the name-derived spelling produced.
365 lines
11 KiB
Vue
365 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'
|
|
import { setRecordTitle } from '@/composables/pageTitle'
|
|
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
|
|
setRecordTitle(employee.value?.name || employee.value?.sso)
|
|
} 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>
|