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.
This commit is contained in:
cproudlock
2026-08-26 13:13:30 -04:00
parent e2a226e8b0
commit ed5d64fa3e
19 changed files with 518 additions and 226 deletions

View File

@@ -0,0 +1,91 @@
// The browser tab title, including WHICH record is open.
//
// The router can only name the KIND of page, because it runs before the view
// has fetched anything: every application read "West Jefferson ShopDB -
// Application", so two tabs on two different machines were indistinguishable,
// and a shared link previewed as the section rather than the thing.
//
// So a detail view names itself once its record arrives:
//
// setRecordTitle(machine.assetnumber)
//
// THE ORDERING IS THE WHOLE PROBLEM. The router's afterEach awaits the site
// settings before it can build "<facility> ShopDB - <page>", so on a cold load
// it finishes AFTER a view that fetched quickly has already set a good title,
// and would overwrite it with the generic one. Both writers therefore go
// through here, and the generic write is dropped when a record title already
// belongs to the route being landed on. The key is the route's fullPath, so
// navigating away invalidates the previous record's claim.
import { getFacilityName } from '@/utils/siteSettings'
let activeKey = null // fullPath of the route the tab is currently showing
let recordTitle = null // record name set by a view for activeKey, if any
let sitePrefix = 'ShopDB' // '<facility> ShopDB', resolved once and cached
function compose(page) {
return page && page !== 'Home' ? `${sitePrefix} - ${page}` : sitePrefix
}
async function resolveSitePrefix() {
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') sitePrefix = `${facility} ShopDB`
} catch (settingsError) {
// settings unavailable: plain ShopDB is a fine tab title
}
return sitePrefix
}
// Called by the router on every navigation, BEFORE it awaits anything, so a
// view that resolves first is not treated as belonging to the previous route.
export function beginNavigation(key) {
activeKey = key
recordTitle = null
}
// Called by the router once it knows the site prefix. Skipped when a view has
// already named the record for this route.
export async function applyGenericTitle(key, page) {
await resolveSitePrefix()
if (key !== activeKey) return // navigated on while we awaited
// A view that named the record before the facility name arrived composed its
// title against the placeholder prefix, so rewrite it rather than leaving
// 'ShopDB - CSF16' on a site called West Jefferson.
document.title = compose(recordTitle !== null ? recordTitle : page)
}
// A tab shows very little, and the browser truncates mid-word wherever it runs
// out. Cut at a word boundary instead, so what survives is readable. Knowledge
// base articles are the case that forces this: their name is a whole sentence
// of description, not a short label.
const MAXLENGTH = 60
function shorten(text) {
if (text.length <= MAXLENGTH) return text
const cut = text.slice(0, MAXLENGTH)
const space = cut.lastIndexOf(' ')
return `${(space > MAXLENGTH / 2 ? cut.slice(0, space) : cut).trimEnd()}...`
}
// Called by a detail view once its record has loaded. An empty or missing name
// leaves the generic title in place rather than showing a bare id: "Machine"
// beats "Machine 4192" for somebody scanning a row of tabs, and a record that
// failed to load has no name to show anyway.
export function setRecordTitle(name) {
const text = (name === null || name === undefined) ? '' : String(name).trim()
if (!text) return
recordTitle = shorten(text)
document.title = compose(recordTitle)
}
// Exposed for tests.
export function _state() {
return { activeKey, recordTitle, sitePrefix }
}
export function _reset() {
activeKey = null
recordTitle = null
sitePrefix = 'ShopDB'
}

View File

@@ -0,0 +1,177 @@
// The tab title names the record, and the two writers do not fight.
//
// The router can only name the KIND of page - it runs before the view has
// fetched anything - so a detail view names the record itself once it loads.
// That leaves two writers for one string, and the router's is asynchronous
// (it awaits the site settings), so on a cold load it can finish LAST and
// overwrite a good title with the generic one. These pin that ordering.
import { describe, it, expect, beforeEach, vi } from 'vitest'
// The real getFacilityName caches, so only the first call is slow and every
// later one is instant. Mirror that: pending calls are held until the test
// releases them, and the value is served synchronously from then on. A mock
// that hands out a fresh unresolved promise per call hangs the moment a test
// navigates twice.
let pending = []
let settled = null
function resolveFacility(value) {
settled = value
const waiting = pending
pending = []
waiting.forEach((resolve) => resolve(value))
}
vi.mock('@/utils/siteSettings', () => ({
getFacilityName: () => (settled !== null
? Promise.resolve(settled)
: new Promise((resolve) => { pending.push(resolve) })),
}))
import {
beginNavigation, applyGenericTitle, setRecordTitle, _reset,
} from './pageTitle'
beforeEach(() => {
_reset()
document.title = ''
pending = []
settled = null
})
describe('the generic title', () => {
it('names the section when no record has been set', async () => {
beginNavigation('/machines')
const done = applyGenericTitle('/machines', 'Machines')
resolveFacility('West Jefferson')
await done
expect(document.title).toBe('West Jefferson ShopDB - Machines')
})
it('falls back to plain ShopDB when the facility is unset', async () => {
beginNavigation('/machines')
const done = applyGenericTitle('/machines', 'Machines')
resolveFacility('ShopDB')
await done
expect(document.title).toBe('ShopDB - Machines')
})
it('drops the site name entirely on the dashboard', async () => {
beginNavigation('/')
const done = applyGenericTitle('/', 'Home')
resolveFacility('West Jefferson')
await done
expect(document.title).toBe('West Jefferson ShopDB')
})
})
describe('a record title', () => {
it('names the record once the view has loaded it', async () => {
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('CSF16')
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
})
it('SURVIVES a slow router that resolves afterwards', async () => {
// The regression this file exists for. The view wins the race, the
// settings fetch lands second, and the generic write must stand down.
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
setRecordTitle('CSF16') // view got there first
resolveFacility('West Jefferson')
await done // router finishes last
expect(document.title).toBe('West Jefferson ShopDB - CSF16')
})
it('is ignored when the record has no usable name', async () => {
beginNavigation('/machines/12')
const done = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('')
setRecordTitle(null)
setRecordTitle(undefined)
setRecordTitle(' ')
// the section name is a better tab than a blank or a bare id
expect(document.title).toBe('West Jefferson ShopDB - Machines')
})
it('shortens a name too long for a tab, at a word boundary', async () => {
// Knowledge base articles are named with a sentence of description, and a
// browser truncates those mid-word wherever it happens to run out.
beginNavigation('/knowledgebase/2')
const done = applyGenericTitle('/knowledgebase/2', 'Knowledge Base')
resolveFacility('West Jefferson')
await done
const full = 'Documentation on how to image a Standard or Business PC '
+ 'in GCC High using MediaCreator Lite'
setRecordTitle(full)
expect(document.title.length).toBeLessThan(90)
expect(document.title.startsWith('West Jefferson ShopDB - ')).toBe(true)
expect(document.title.endsWith('...')).toBe(true)
// what survives is a whole-word prefix of the original: the character
// following the cut in the source is a space, so no word was split
const kept = document.title
.replace('West Jefferson ShopDB - ', '')
.replace(/\.\.\.$/, '')
expect(full.startsWith(kept)).toBe(true)
expect(full[kept.length]).toBe(' ')
})
it('leaves a name that already fits alone', async () => {
beginNavigation('/machines/14')
const done = applyGenericTitle('/machines/14', 'Machines')
resolveFacility('West Jefferson')
await done
setRecordTitle('0600')
expect(document.title).toBe('West Jefferson ShopDB - 0600')
})
it('does not leak onto the next page', async () => {
beginNavigation('/machines/12')
const first = applyGenericTitle('/machines/12', 'Machines')
resolveFacility('West Jefferson')
await first
setRecordTitle('CSF16')
beginNavigation('/printers')
const second = applyGenericTitle('/printers', 'Printers')
await second
expect(document.title).toBe('West Jefferson ShopDB - Printers')
})
it('is discarded when the user navigates away mid-flight', async () => {
// Two navigations overlap; the first one's late generic write must not
// land on the second page.
beginNavigation('/machines/12')
const stale = applyGenericTitle('/machines/12', 'Machines')
beginNavigation('/printers/3')
const fresh = applyGenericTitle('/printers/3', 'Printers')
setRecordTitle('WJPRT04')
resolveFacility('West Jefferson')
await Promise.all([stale, fresh])
expect(document.title).toBe('West Jefferson ShopDB - WJPRT04')
})
})

View File

@@ -6,7 +6,7 @@ import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState,
needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState' needsAdmin, isNeedsAdminLoaded, refreshNeedsAdmin } from '../composables/setupState'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins' import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast' import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings' import { beginNavigation, applyGenericTitle } from '../composables/pageTitle'
import { stagedChildren, stagedToplevel } from './routes.gen' import { stagedChildren, stagedToplevel } from './routes.gen'
// Auto-discover all route modules from routes/ directory. A module's default // Auto-discover all route modules from routes/ directory. A module's default
@@ -147,14 +147,12 @@ function pageTitleFor(route) {
word.charAt(0).toUpperCase() + word.slice(1)).join(' ') word.charAt(0).toUpperCase() + word.slice(1)).join(' ')
} }
router.afterEach(async (to) => { router.afterEach((to) => {
let siteTitle = 'ShopDB' // Claim the route BEFORE awaiting anything: a detail view that resolves
try { // quickly sets the record title, and the generic write below must not
const facility = await getFacilityName() // overwrite it. See composables/pageTitle.js.
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB` beginNavigation(to.fullPath)
} catch (titleError) { /* settings unavailable - plain ShopDB */ } applyGenericTitle(to.fullPath, pageTitleFor(to))
const page = pageTitleFor(to)
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
}) })
// A deploy replaces the hashed chunk files, so a tab that was open across it // A deploy replaces the hashed chunk files, so a tab that was open across it

View File

@@ -168,6 +168,7 @@ import { applicationsApi } from '@/api'
import { getContactEmailDomain } from '@/utils/siteSettings' import { getContactEmailDomain } from '@/utils/siteSettings'
import { sanitizeNotesHtml } from '@/utils/sanitizeHtml' import { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
import { fileHref } from '@/utils/basePath' import { fileHref } from '@/utils/basePath'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
@@ -202,6 +203,7 @@ onMounted(async () => {
// Load application details // Load application details
const response = await applicationsApi.get(route.params.id) const response = await applicationsApi.get(route.params.id)
app.value = response.data.data app.value = response.data.data
setRecordTitle(app.value?.appname)
// Load versions // Load versions
try { try {

View File

@@ -6,7 +6,7 @@ export default [
path: 'reports/pc-relationships', path: 'reports/pc-relationships',
name: 'report-pc-relationships', name: 'report-pc-relationships',
component: () => import('./views/PCRelationshipsReport.vue'), component: () => import('./views/PCRelationshipsReport.vue'),
meta: { plugin: 'computers' } meta: { plugin: 'computers', title: 'PC Relationships Report' }
}, },
{ {
path: 'settings/pctypemapping', path: 'settings/pctypemapping',

View File

@@ -263,6 +263,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -280,6 +281,7 @@ onMounted(async () => {
try { try {
const response = await computersApi.get(route.params.id) const response = await computersApi.get(route.params.id)
computer.value = response.data.data computer.value = response.data.data
setRecordTitle(computer.value?.assetnumber || computer.value?.name)
// Load installed applications // Load installed applications
try { try {

View File

@@ -153,6 +153,7 @@ import { withBase } from '@/utils/basePath'
import { employeesApi, usbApi, notificationsApi } from '@/api' import { employeesApi, usbApi, notificationsApi } from '@/api'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins' import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast() const toast = useToast()
const route = useRoute() const route = useRoute()
@@ -223,6 +224,7 @@ async function loadEmployee() {
try { try {
const response = await employeesApi.lookup(route.params.sso) const response = await employeesApi.lookup(route.params.sso)
employee.value = response.data.data employee.value = response.data.data
setRecordTitle(employee.value?.name || employee.value?.sso)
} catch (err) { } catch (err) {
console.error('Error loading employee:', err) console.error('Error loading employee:', err)
error.value = 'Employee not found' error.value = 'Employee not found'

View File

@@ -59,6 +59,7 @@ import { useRoute } from 'vue-router'
import api from '@/api' import api from '@/api'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
import GuideBlocks from '../components/GuideBlocks.vue' import GuideBlocks from '../components/GuideBlocks.vue'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const guide = ref(null) const guide = ref(null)
@@ -146,6 +147,7 @@ async function load () {
try { try {
const response = await api.get('/guides/' + route.params.slug) const response = await api.get('/guides/' + route.params.slug)
guide.value = response.data?.data || response.data guide.value = response.data?.data || response.data
setRecordTitle(guide.value?.title)
loadProgress() loadProgress()
} catch (err) { } catch (err) {
error.value = apiError(err, 'Could not load this guide') error.value = apiError(err, 'Could not load this guide')

View File

@@ -80,6 +80,7 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api' import { knowledgebaseApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
@@ -90,6 +91,7 @@ onMounted(async () => {
try { try {
const response = await knowledgebaseApi.get(route.params.id) const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data article.value = response.data.data
setRecordTitle(article.value?.shortdescription)
} catch (error) { } catch (error) {
console.error('Error loading article:', error) console.error('Error loading article:', error)
} finally { } finally {

View File

@@ -257,6 +257,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -271,6 +272,7 @@ onMounted(async () => {
try { try {
const response = await machinesApi.get(route.params.id) const response = await machinesApi.get(route.params.id)
machine.value = response.data.data machine.value = response.data.data
setRecordTitle(machine.value?.assetnumber || machine.value?.name)
} catch (error) { } catch (error) {
console.error('Error loading machine:', error) console.error('Error loading machine:', error)
} finally { } finally {

View File

@@ -43,7 +43,7 @@ export default [
path: 'reports/calibration', path: 'reports/calibration',
name: 'calibration-report', name: 'calibration-report',
component: () => import('./views/CalibrationReport.vue'), component: () => import('./views/CalibrationReport.vue'),
meta: { plugin: 'measuringtools' } meta: { plugin: 'measuringtools', title: 'Calibration Report' }
}, },
{ {
path: 'settings/measuringtooltypes', path: 'settings/measuringtooltypes',

View File

@@ -172,6 +172,7 @@ import CustomFieldsSection from '@/components/CustomFieldsSection.vue'
import PluginAssetPanels from '@/components/PluginAssetPanels.vue' import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
@@ -193,6 +194,7 @@ onMounted(async () => {
? await measuringtoolsApi.getByAsset(route.params.assetid) ? await measuringtoolsApi.getByAsset(route.params.assetid)
: await measuringtoolsApi.get(route.params.id) : await measuringtoolsApi.get(route.params.id)
tool.value = response.data.data tool.value = response.data.data
setRecordTitle(tool.value?.assetnumber || tool.value?.name)
} catch (err) { } catch (err) {
console.error('Error loading measuring tool:', err) console.error('Error loading measuring tool:', err)
} finally { } finally {

View File

@@ -228,6 +228,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast() const toast = useToast()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -250,6 +251,7 @@ async function loadDevice() {
try { try {
const response = await networkApi.get(deviceId) const response = await networkApi.get(deviceId)
device.value = response.data.data device.value = response.data.data
setRecordTitle(device.value?.assetnumber || device.value?.name)
} catch (error) { } catch (error) {
console.error('Error loading device:', error) console.error('Error loading device:', error)
device.value = null device.value = null

View File

@@ -59,6 +59,7 @@
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { networkApi } from '@/api' import { networkApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const subnet = ref(null) const subnet = ref(null)
@@ -79,6 +80,7 @@ async function load() {
try { try {
const response = await networkApi.subnets.get(route.params.id) const response = await networkApi.subnets.get(route.params.id)
subnet.value = response.data?.data || response.data subnet.value = response.data?.data || response.data
setRecordTitle(subnet.value?.name || subnet.value?.cidr)
} finally { } finally {
loading.value = false loading.value = false
} }

View File

@@ -187,6 +187,7 @@ import { useRoute } from 'vue-router'
import { printedpartsApi } from '@/api' import { printedpartsApi } from '@/api'
import { withBase } from '@/utils/basePath' import { withBase } from '@/utils/basePath'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const item = ref(null) const item = ref(null)
@@ -196,6 +197,7 @@ onMounted(async () => {
try { try {
const response = await printedpartsApi.get(route.params.id) const response = await printedpartsApi.get(route.params.id)
item.value = response.data.data item.value = response.data.data
setRecordTitle(item.value?.itemname || item.value?.itemcode)
await loadFiles() await loadFiles()
} catch (loadError) { } catch (loadError) {
console.error('Error loading printed item:', loadError) console.error('Error loading printed item:', loadError)

View File

@@ -6,13 +6,13 @@ export default [
path: 'reports/toner', path: 'reports/toner',
name: 'toner-report', name: 'toner-report',
component: () => import('./views/TonerReport.vue'), component: () => import('./views/TonerReport.vue'),
meta: { plugin: 'printers' } meta: { plugin: 'printers', title: 'Toner Report' }
}, },
{ {
path: 'reports/toner-forecast', path: 'reports/toner-forecast',
name: 'toner-forecast', name: 'toner-forecast',
component: () => import('./views/TonerForecast.vue'), component: () => import('./views/TonerForecast.vue'),
meta: { plugin: 'printers' } meta: { plugin: 'printers', title: 'Toner Forecast' }
}, },
{ {
path: 'settings/printertypes', path: 'settings/printertypes',

View File

@@ -305,6 +305,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue' import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge' import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings' import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute() const route = useRoute()
const { isEnabled } = useIdentifierFlags() const { isEnabled } = useIdentifierFlags()
@@ -344,6 +345,9 @@ onMounted(async () => {
]) ])
printer.value = printerRes.data.data printer.value = printerRes.data.data
// the page's own heading logic, which already discards the literal
// name 'NONE' that a discovered queue can carry
setRecordTitle(displayTitle.value)
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]} // supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || [] supplies.value = suppliesRes.data.data?.supplies || []
// drivers are attached to the printer detail, matched by model // drivers are attached to the printer detail, matched by model

View File

@@ -160,6 +160,7 @@ import { usbApi } from '@/api'
import Modal from '@/components/Modal.vue' import Modal from '@/components/Modal.vue'
import { useToast } from '@/composables/toast' import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError' import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast() const toast = useToast()
const route = useRoute() const route = useRoute()
@@ -181,6 +182,7 @@ async function loadDevice() {
try { try {
const response = await usbApi.get(route.params.id) const response = await usbApi.get(route.params.id)
device.value = response.data.data device.value = response.data.data
setRecordTitle(device.value?.label || device.value?.serialnumber)
} catch (error) { } catch (error) {
console.error('Error loading USB device:', error) console.error('Error loading USB device:', error)
} finally { } finally {

View File

@@ -18,7 +18,7 @@ export default [
path: 'reports/warranty', path: 'reports/warranty',
name: 'warranty-report', name: 'warranty-report',
component: () => import('./views/WarrantyReport.vue'), component: () => import('./views/WarrantyReport.vue'),
meta: { plugin: 'warranty' } meta: { plugin: 'warranty', title: 'Warranty Report' }
} }
] ]