Tab titles name the record; Guides reach a site and the contract surface
This commit is contained in:
18
CHANGELOG.md
18
CHANGELOG.md
@@ -90,6 +90,24 @@ ADR-007 and ADR-002.
|
|||||||
|
|
||||||
### Fixed
|
### 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
|
- **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
|
that should never have left a test.** `HP Inc.` and `Zebra Technologies` were
|
||||||
empty rows duplicating `HP` and `Zebra`, which carry every model between
|
empty rows duplicating `HP` and `Zebra`, which carry every model between
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"computers",
|
"computers",
|
||||||
"employees",
|
"employees",
|
||||||
"geenforce",
|
"geenforce",
|
||||||
|
"guides",
|
||||||
"knowledgebase",
|
"knowledgebase",
|
||||||
"machines",
|
"machines",
|
||||||
"measuringtools",
|
"measuringtools",
|
||||||
|
|||||||
91
frontend/src/composables/pageTitle.js
Normal file
91
frontend/src/composables/pageTitle.js
Normal 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'
|
||||||
|
}
|
||||||
177
frontend/src/composables/pageTitle.spec.js
Normal file
177
frontend/src/composables/pageTitle.spec.js
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ without an author thinking about it.
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from shopdb.api import db
|
from shopdb.api import db, BaseModel
|
||||||
from shopdb.core.models.base import BaseModel
|
|
||||||
|
|
||||||
|
|
||||||
def _utcnow():
|
def _utcnow():
|
||||||
|
|||||||
@@ -1,212 +1,214 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="detail-page">
|
<div class="detail-page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2>Knowledge Base Article</h2>
|
<h2>Knowledge Base Article</h2>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
<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>
|
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="loading">Loading...</div>
|
<div v-if="loading" class="loading">Loading...</div>
|
||||||
|
|
||||||
<template v-else-if="article">
|
<template v-else-if="article">
|
||||||
<div class="card article-card">
|
<div class="card article-card">
|
||||||
<table class="info-table">
|
<table class="info-table">
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Description:</th>
|
<th>Description:</th>
|
||||||
<td>{{ article.shortdescription }}</td>
|
<td>{{ article.shortdescription }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="article.application">
|
<tr v-if="article.application">
|
||||||
<th>Topic:</th>
|
<th>Topic:</th>
|
||||||
<td>
|
<td>
|
||||||
<router-link :to="`/applications/${article.application.appid}`">
|
<router-link :to="`/applications/${article.application.appid}`">
|
||||||
{{ article.application.appname }}
|
{{ article.application.appname }}
|
||||||
</router-link>
|
</router-link>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="article.linkurl">
|
<tr v-if="article.linkurl">
|
||||||
<th>URL:</th>
|
<th>URL:</th>
|
||||||
<td>
|
<td>
|
||||||
<a href="#" @click.prevent="openArticle">
|
<a href="#" @click.prevent="openArticle">
|
||||||
{{ article.linkurl }}
|
{{ article.linkurl }}
|
||||||
</a>
|
</a>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if="article.keywords">
|
<tr v-if="article.keywords">
|
||||||
<th>Keywords:</th>
|
<th>Keywords:</th>
|
||||||
<td>{{ article.keywords }}</td>
|
<td>{{ article.keywords }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Clicks:</th>
|
<th>Clicks:</th>
|
||||||
<td>{{ article.clicks }}</td>
|
<td>{{ article.clicks }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Last Updated:</th>
|
<th>Last Updated:</th>
|
||||||
<td>{{ formatDate(article.lastupdated) }}</td>
|
<td>{{ formatDate(article.lastupdated) }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div class="actions-center">
|
<div class="actions-center">
|
||||||
<a
|
<a
|
||||||
v-if="article.linkurl"
|
v-if="article.linkurl"
|
||||||
href="#"
|
href="#"
|
||||||
class="btn btn-primary btn-lg"
|
class="btn btn-primary btn-lg"
|
||||||
@click.prevent="openArticle"
|
@click.prevent="openArticle"
|
||||||
>
|
>
|
||||||
<span class="btn-icon">↗</span> Open Article
|
<span class="btn-icon">↗</span> Open Article
|
||||||
</a>
|
</a>
|
||||||
<div v-else class="alert alert-warning">
|
<div v-else class="alert alert-warning">
|
||||||
This article does not have a URL link defined. Please edit the article to add one.
|
This article does not have a URL link defined. Please edit the article to add one.
|
||||||
</div>
|
</div>
|
||||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
|
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
|
||||||
<span class="btn-icon">✎</span> Edit
|
<span class="btn-icon">✎</span> Edit
|
||||||
</router-link>
|
</router-link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div v-else class="card">
|
<div v-else class="card">
|
||||||
<p style="text-align: center; color: var(--text-light);">Article not found</p>
|
<p style="text-align: center; color: var(--text-light);">Article not found</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
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()
|
||||||
const loading = ref(true)
|
|
||||||
const article = ref(null)
|
const loading = ref(true)
|
||||||
|
const article = ref(null)
|
||||||
onMounted(async () => {
|
|
||||||
try {
|
onMounted(async () => {
|
||||||
const response = await knowledgebaseApi.get(route.params.id)
|
try {
|
||||||
article.value = response.data.data
|
const response = await knowledgebaseApi.get(route.params.id)
|
||||||
} catch (error) {
|
article.value = response.data.data
|
||||||
console.error('Error loading article:', error)
|
setRecordTitle(article.value?.shortdescription)
|
||||||
} finally {
|
} catch (error) {
|
||||||
loading.value = false
|
console.error('Error loading article:', error)
|
||||||
}
|
} finally {
|
||||||
})
|
loading.value = false
|
||||||
|
}
|
||||||
function formatDate(dateStr) {
|
})
|
||||||
if (!dateStr) return '-'
|
|
||||||
return new Date(dateStr).toLocaleString()
|
function formatDate(dateStr) {
|
||||||
}
|
if (!dateStr) return '-'
|
||||||
|
return new Date(dateStr).toLocaleString()
|
||||||
async function openArticle() {
|
}
|
||||||
try {
|
|
||||||
await knowledgebaseApi.trackClick(article.value.linkid)
|
async function openArticle() {
|
||||||
article.value.clicks = (article.value.clicks || 0) + 1
|
try {
|
||||||
// Open the URL after tracking
|
await knowledgebaseApi.trackClick(article.value.linkid)
|
||||||
if (article.value.linkurl) {
|
article.value.clicks = (article.value.clicks || 0) + 1
|
||||||
window.open(article.value.linkurl, '_blank')
|
// Open the URL after tracking
|
||||||
}
|
if (article.value.linkurl) {
|
||||||
} catch (error) {
|
window.open(article.value.linkurl, '_blank')
|
||||||
console.error('Error tracking click:', error)
|
}
|
||||||
// Still open even if tracking fails
|
} catch (error) {
|
||||||
if (article.value.linkurl) {
|
console.error('Error tracking click:', error)
|
||||||
window.open(article.value.linkurl, '_blank')
|
// Still open even if tracking fails
|
||||||
}
|
if (article.value.linkurl) {
|
||||||
}
|
window.open(article.value.linkurl, '_blank')
|
||||||
}
|
}
|
||||||
</script>
|
}
|
||||||
|
}
|
||||||
<style scoped>
|
</script>
|
||||||
.detail-page {
|
|
||||||
max-width: 800px;
|
<style scoped>
|
||||||
margin: 0 auto;
|
.detail-page {
|
||||||
}
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
.header-actions {
|
}
|
||||||
display: flex;
|
|
||||||
gap: 0.5rem;
|
.header-actions {
|
||||||
}
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
.article-card {
|
}
|
||||||
padding: 1.5rem;
|
|
||||||
}
|
.article-card {
|
||||||
|
padding: 1.5rem;
|
||||||
.info-table {
|
}
|
||||||
width: 100%;
|
|
||||||
border-collapse: collapse;
|
.info-table {
|
||||||
}
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
.info-table th,
|
}
|
||||||
.info-table td {
|
|
||||||
padding: 0.75rem;
|
.info-table th,
|
||||||
text-align: left;
|
.info-table td {
|
||||||
border: none;
|
padding: 0.75rem;
|
||||||
}
|
text-align: left;
|
||||||
|
border: none;
|
||||||
.info-table th {
|
}
|
||||||
width: 150px;
|
|
||||||
font-weight: 600;
|
.info-table th {
|
||||||
color: var(--text-light, #666);
|
width: 150px;
|
||||||
vertical-align: top;
|
font-weight: 600;
|
||||||
}
|
color: var(--text-light, #666);
|
||||||
|
vertical-align: top;
|
||||||
.info-table td a {
|
}
|
||||||
color: var(--primary, #1976d2);
|
|
||||||
text-decoration: none;
|
.info-table td a {
|
||||||
word-break: break-all;
|
color: var(--primary, #1976d2);
|
||||||
overflow-wrap: break-word;
|
text-decoration: none;
|
||||||
}
|
word-break: break-all;
|
||||||
|
overflow-wrap: break-word;
|
||||||
.info-table td a:hover {
|
}
|
||||||
text-decoration: underline;
|
|
||||||
}
|
.info-table td a:hover {
|
||||||
|
text-decoration: underline;
|
||||||
.info-table td {
|
}
|
||||||
word-break: break-word;
|
|
||||||
overflow-wrap: break-word;
|
.info-table td {
|
||||||
max-width: 500px;
|
word-break: break-word;
|
||||||
}
|
overflow-wrap: break-word;
|
||||||
|
max-width: 500px;
|
||||||
hr {
|
}
|
||||||
margin: 1.5rem 0;
|
|
||||||
border: none;
|
hr {
|
||||||
border-top: 1px solid var(--border-color, #e5e5e5);
|
margin: 1.5rem 0;
|
||||||
}
|
border: none;
|
||||||
|
border-top: 1px solid var(--border-color, #e5e5e5);
|
||||||
.actions-center {
|
}
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
.actions-center {
|
||||||
gap: 1rem;
|
display: flex;
|
||||||
margin-top: 1rem;
|
justify-content: center;
|
||||||
}
|
gap: 1rem;
|
||||||
|
margin-top: 1rem;
|
||||||
.btn-lg {
|
}
|
||||||
padding: 0.75rem 1.5rem;
|
|
||||||
font-size: 1rem;
|
.btn-lg {
|
||||||
}
|
padding: 0.75rem 1.5rem;
|
||||||
|
font-size: 1rem;
|
||||||
.btn-icon {
|
}
|
||||||
margin-right: 0.5rem;
|
|
||||||
}
|
.btn-icon {
|
||||||
|
margin-right: 0.5rem;
|
||||||
.alert {
|
}
|
||||||
padding: 1rem;
|
|
||||||
border-radius: 4px;
|
.alert {
|
||||||
}
|
padding: 1rem;
|
||||||
|
border-radius: 4px;
|
||||||
.alert-warning {
|
}
|
||||||
background: #fff3cd;
|
|
||||||
color: #856404;
|
.alert-warning {
|
||||||
}
|
background: #fff3cd;
|
||||||
|
color: #856404;
|
||||||
@media (prefers-color-scheme: dark) {
|
}
|
||||||
.alert-warning {
|
|
||||||
background: #3d3200;
|
@media (prefers-color-scheme: dark) {
|
||||||
color: #ffc107;
|
.alert-warning {
|
||||||
}
|
background: #3d3200;
|
||||||
}
|
color: #ffc107;
|
||||||
</style>
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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' }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user