Files
shopdb-flask/plugins/usb/frontend/views/USBDetail.vue
cproudlock ed5d64fa3e Name the record in the browser tab, not just the section
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.
2026-08-26 13:13:30 -04:00

289 lines
8.1 KiB
Vue

<template>
<div class="detail-page">
<div class="page-header">
<h2>USB Device Details</h2>
<div class="header-actions">
<router-link to="/usb" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="device">
<!-- Hero Section -->
<div class="hero-card">
<div class="hero-content">
<div class="hero-title">
<h1>{{ device.device_desc || device.device_id }}</h1>
</div>
<div class="hero-meta">
<span class="badge badge-lg" :class="device.status === 'checked-out' ? 'badge-warning' : 'badge-success'">
{{ device.status === 'checked-out' ? 'Checked Out' : 'Available' }}
</span>
</div>
<div class="hero-details">
<div class="hero-detail" v-if="device.device_id">
<span class="hero-detail-label">Serial Number</span>
<span class="hero-detail-value mono">{{ device.device_id }}</span>
</div>
<div class="hero-detail" v-if="device.locker_location">
<span class="hero-detail-label">Locker Location</span>
<span class="hero-detail-value">{{ device.locker_location }}</span>
</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="action-buttons">
<button
v-if="device.status !== 'checked-out'"
class="btn btn-primary btn-lg"
@click="openCheckoutModal"
>
Checkout Device
</button>
<button
v-else
class="btn btn-warning btn-lg"
@click="openCheckinModal"
>
Check In Device
</button>
</div>
<!-- Current Checkout Info -->
<div class="section-card" v-if="device.status === 'checked-out'">
<h3 class="section-title">Current Checkout</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Checked Out By</span>
<span class="info-value">{{ device.current_holder_name || device.current_holder }}</span>
</div>
<div class="info-row">
<span class="info-label">Checkout Time</span>
<span class="info-value">{{ formatDate(device.checkout_time) }}</span>
</div>
</div>
</div>
<!-- Check-in/out Log -->
<div class="card">
<div class="card-header">
<h3>Checkout History</h3>
</div>
<div v-if="!device.checkinoutlog?.length" class="empty-state">
No checkout history
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Action</th>
<th>User</th>
<th>Time</th>
<th>Sanitized</th>
</tr>
</thead>
<tbody>
<tr v-for="entry in device.checkinoutlog" :key="entry.log_id">
<td>{{ entry.action === 'check-in' ? 'Check In' : 'Check Out' }}</td>
<td>{{ entry.badge_name || entry.badge_number }}</td>
<td>{{ formatDate(entry.timestamp) }}</td>
<td>
<span v-if="entry.action === 'check-in'">{{ entry.sanitized ? 'Yes' : 'No' }}</span>
<span v-else>-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">USB device not found</p>
</div>
<!-- Checkout Modal -->
<Modal v-if="showCheckoutModal" @close="closeModals">
<template #header>
<h3>Checkout USB Device</h3>
</template>
<template #body>
<div class="form-group">
<label>Your SSO *</label>
<input v-model="checkoutForm.sso" type="text" class="form-control" placeholder="Enter your SSO" />
</div>
<div class="form-group">
<label>Reason</label>
<textarea v-model="checkoutForm.reason" class="form-control" rows="3" placeholder="Why do you need this device?"></textarea>
</div>
</template>
<template #footer>
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
<button class="btn btn-primary" @click="doCheckout" :disabled="!checkoutForm.sso">Checkout</button>
</template>
</Modal>
<!-- Checkin Modal -->
<Modal v-if="showCheckinModal" @close="closeModals">
<template #header>
<h3>Check In USB Device</h3>
</template>
<template #body>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" v-model="checkinForm.waswiped" />
Device was wiped
</label>
</div>
<div class="form-group">
<label>Notes</label>
<textarea v-model="checkinForm.notes" class="form-control" rows="3" placeholder="Any notes about this return?"></textarea>
</div>
</template>
<template #footer>
<button class="btn btn-secondary" @click="closeModals">Cancel</button>
<button class="btn btn-primary" @click="doCheckin">Check In</button>
</template>
</Modal>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { usbApi } from '@/api'
import Modal from '@/components/Modal.vue'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const route = useRoute()
const loading = ref(true)
const device = ref(null)
const showCheckoutModal = ref(false)
const showCheckinModal = ref(false)
const checkoutForm = ref({ sso: '', reason: '' })
const checkinForm = ref({ waswiped: false, notes: '' })
onMounted(async () => {
await loadDevice()
})
async function loadDevice() {
loading.value = true
try {
const response = await usbApi.get(route.params.id)
device.value = response.data.data
setRecordTitle(device.value?.label || device.value?.serialnumber)
} catch (error) {
console.error('Error loading USB device:', error)
} finally {
loading.value = false
}
}
function openCheckoutModal() {
checkoutForm.value = { sso: '', reason: '' }
showCheckoutModal.value = true
}
function openCheckinModal() {
checkinForm.value = { waswiped: false, notes: '' }
showCheckinModal.value = true
}
function closeModals() {
showCheckoutModal.value = false
showCheckinModal.value = false
}
async function doCheckout() {
try {
// The SSO the operator enters is the badge; API resolves the name.
await usbApi.checkout(device.value.device_id, {
badge: checkoutForm.value.sso,
reason: checkoutForm.value.reason
})
closeModals()
await loadDevice()
} catch (error) {
console.error('Checkout error:', error)
toast.error(apiError(error, 'Checkout failed'))
}
}
async function doCheckin() {
try {
// Check the current holder back in; sanitized mirrors the wiped checkbox.
await usbApi.checkin(device.value.device_id, {
badge: device.value.current_holder,
sanitized: checkinForm.value.waswiped,
notes: checkinForm.value.notes
})
closeModals()
await loadDevice()
} catch (error) {
console.error('Checkin error:', error)
toast.error(apiError(error, 'Check in failed'))
}
}
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
</script>
<style scoped>
.action-buttons {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1.125rem;
}
.empty-state {
text-align: center;
color: var(--text-light);
padding: 2rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-label input[type="checkbox"] {
width: 18px;
height: 18px;
}
.form-group {
margin-bottom: 1rem;
}
.form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
</style>