Tab titles name the record; Guides reach a site and the contract surface

This commit is contained in:
2026-08-26 13:31:13 -04:00
parent f221fba540
commit 4e38303326
22 changed files with 538 additions and 228 deletions

View File

@@ -90,6 +90,24 @@ ADR-007 and ADR-002.
### Fixed
- **Every detail page had the same browser tab title.** A machine, a PC and a
printer all read "West Jefferson ShopDB - Machine" or the equivalent, so two
tabs on two different records 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. Detail pages now name the record: machines,
PCs, printers, network devices, subnets, measuring tools, USB devices,
printed parts, applications, employees, knowledge base articles and guides.
A record with no usable name, or one that failed to load, keeps the section
name, and a long name is cut at a word boundary. Report pages gained proper
titles too, which also fixes "Pc Relationships".
- **Guides would have been dropped at provisioning.** The plugin was missing
from `deploy/site-profile-universal.json`, the profile the released Windows
installer is built from, so the wizard would not have offered it and
`flask plugin prune-schema` drops the tables of any plugin absent from the
profile (ADR-014). Its model also imported core directly rather than through
`shopdb.api`, which the contract test catches.
- **The shipped catalog offered two ways to spell the same vendor, and one row
that should never have left a test.** `HP Inc.` and `Zebra Technologies` were
empty rows duplicating `HP` and `Zebra`, which carry every model between

View File

@@ -5,6 +5,7 @@
"computers",
"employees",
"geenforce",
"guides",
"knowledgebase",
"machines",
"measuringtools",

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'
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
import { useToast } from '../composables/toast'
import { getFacilityName } from '../utils/siteSettings'
import { beginNavigation, applyGenericTitle } from '../composables/pageTitle'
import { stagedChildren, stagedToplevel } from './routes.gen'
// 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(' ')
}
router.afterEach(async (to) => {
let siteTitle = 'ShopDB'
try {
const facility = await getFacilityName()
if (facility && facility !== 'ShopDB') siteTitle = `${facility} ShopDB`
} catch (titleError) { /* settings unavailable - plain ShopDB */ }
const page = pageTitleFor(to)
document.title = page && page !== 'Home' ? `${siteTitle} - ${page}` : siteTitle
router.afterEach((to) => {
// Claim the route BEFORE awaiting anything: a detail view that resolves
// quickly sets the record title, and the generic write below must not
// overwrite it. See composables/pageTitle.js.
beginNavigation(to.fullPath)
applyGenericTitle(to.fullPath, pageTitleFor(to))
})
// 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 { sanitizeNotesHtml } from '@/utils/sanitizeHtml'
import { fileHref } from '@/utils/basePath'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
@@ -202,6 +203,7 @@ onMounted(async () => {
// Load application details
const response = await applicationsApi.get(route.params.id)
app.value = response.data.data
setRecordTitle(app.value?.appname)
// Load versions
try {

View File

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

View File

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

View File

@@ -153,6 +153,7 @@ 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()
@@ -223,6 +224,7 @@ async function loadEmployee() {
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'

View File

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

View File

@@ -33,8 +33,7 @@ without an author thinking about it.
import json
from datetime import datetime
from shopdb.api import db
from shopdb.core.models.base import BaseModel
from shopdb.api import db, BaseModel
def _utcnow():

View File

@@ -1,212 +1,214 @@
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>
<template>
<div class="detail-page">
<div class="page-header">
<h2>Knowledge Base Article</h2>
<div class="header-actions">
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
</div>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else-if="article">
<div class="card article-card">
<table class="info-table">
<tbody>
<tr>
<th>Description:</th>
<td>{{ article.shortdescription }}</td>
</tr>
<tr v-if="article.application">
<th>Topic:</th>
<td>
<router-link :to="`/applications/${article.application.appid}`">
{{ article.application.appname }}
</router-link>
</td>
</tr>
<tr v-if="article.linkurl">
<th>URL:</th>
<td>
<a href="#" @click.prevent="openArticle">
{{ article.linkurl }}
</a>
</td>
</tr>
<tr v-if="article.keywords">
<th>Keywords:</th>
<td>{{ article.keywords }}</td>
</tr>
<tr>
<th>Clicks:</th>
<td>{{ article.clicks }}</td>
</tr>
<tr>
<th>Last Updated:</th>
<td>{{ formatDate(article.lastupdated) }}</td>
</tr>
</tbody>
</table>
<hr />
<div class="actions-center">
<a
v-if="article.linkurl"
href="#"
class="btn btn-primary btn-lg"
@click.prevent="openArticle"
>
<span class="btn-icon">&#x2197;</span> Open Article
</a>
<div v-else class="alert alert-warning">
This article does not have a URL link defined. Please edit the article to add one.
</div>
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
<span class="btn-icon">&#x270E;</span> Edit
</router-link>
</div>
</div>
</template>
<div v-else class="card">
<p style="text-align: center; color: var(--text-light);">Article not found</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { knowledgebaseApi } from '@/api'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const loading = ref(true)
const article = ref(null)
onMounted(async () => {
try {
const response = await knowledgebaseApi.get(route.params.id)
article.value = response.data.data
setRecordTitle(article.value?.shortdescription)
} catch (error) {
console.error('Error loading article:', error)
} finally {
loading.value = false
}
})
function formatDate(dateStr) {
if (!dateStr) return '-'
return new Date(dateStr).toLocaleString()
}
async function openArticle() {
try {
await knowledgebaseApi.trackClick(article.value.linkid)
article.value.clicks = (article.value.clicks || 0) + 1
// Open the URL after tracking
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
} catch (error) {
console.error('Error tracking click:', error)
// Still open even if tracking fails
if (article.value.linkurl) {
window.open(article.value.linkurl, '_blank')
}
}
}
</script>
<style scoped>
.detail-page {
max-width: 800px;
margin: 0 auto;
}
.header-actions {
display: flex;
gap: 0.5rem;
}
.article-card {
padding: 1.5rem;
}
.info-table {
width: 100%;
border-collapse: collapse;
}
.info-table th,
.info-table td {
padding: 0.75rem;
text-align: left;
border: none;
}
.info-table th {
width: 150px;
font-weight: 600;
color: var(--text-light, #666);
vertical-align: top;
}
.info-table td a {
color: var(--primary, #1976d2);
text-decoration: none;
word-break: break-all;
overflow-wrap: break-word;
}
.info-table td a:hover {
text-decoration: underline;
}
.info-table td {
word-break: break-word;
overflow-wrap: break-word;
max-width: 500px;
}
hr {
margin: 1.5rem 0;
border: none;
border-top: 1px solid var(--border-color, #e5e5e5);
}
.actions-center {
display: flex;
justify-content: center;
gap: 1rem;
margin-top: 1rem;
}
.btn-lg {
padding: 0.75rem 1.5rem;
font-size: 1rem;
}
.btn-icon {
margin-right: 0.5rem;
}
.alert {
padding: 1rem;
border-radius: 4px;
}
.alert-warning {
background: #fff3cd;
color: #856404;
}
@media (prefers-color-scheme: dark) {
.alert-warning {
background: #3d3200;
color: #ffc107;
}
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -305,6 +305,7 @@ import PluginAssetPanels from '@/components/PluginAssetPanels.vue'
import AssetRelationships from '@/components/AssetRelationships.vue'
import { useWarrantyBadge } from '@/composables/warrantyBadge'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { setRecordTitle } from '@/composables/pageTitle'
const route = useRoute()
const { isEnabled } = useIdentifierFlags()
@@ -344,6 +345,9 @@ onMounted(async () => {
])
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.value = suppliesRes.data.data?.supplies || []
// 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 { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
import { setRecordTitle } from '@/composables/pageTitle'
const toast = useToast()
const route = useRoute()
@@ -181,6 +182,7 @@ async function loadDevice() {
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 {

View File

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