Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,42 @@
// Enabled-plugin gating for the router. Fetches the enabled plugin-name list
// ONCE (cached promise) so a disabled backend plugin's frontend routes become
// unreachable by direct URL. Fail-open: any fetch error treats every plugin as
// enabled, so an API blip can never brick navigation.
import { pluginsApi } from '../api'
let enabledPromise = null
let enabledSet = null // resolved Set<string>, or null while pending / on failure
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
// works on unauthenticated kiosk routes (e.g. /tv) too.
export function loadEnabledPlugins() {
if (!enabledPromise) {
enabledPromise = pluginsApi.enabled()
.then(response => {
const names = response?.data?.data
// Only trust a real array; anything else -> fail open (null).
enabledSet = Array.isArray(names) ? new Set(names) : null
return enabledSet
})
.catch(() => {
// Fail open: leave enabledSet null so isPluginEnabled returns true.
enabledSet = null
return null
})
}
return enabledPromise
}
// True if the plugin is enabled OR the list is unknown (fail-open). A null
// enabledSet means we never got a trustworthy answer, so allow everything.
export function isPluginEnabled(name) {
if (!name) return true
if (enabledSet === null) return true
return enabledSet.has(name)
}
// Test/hot-reload hook: forget the cached result so the next call refetches.
export function resetEnabledPlugins() {
enabledPromise = null
enabledSet = null
}

View File

@@ -0,0 +1,71 @@
// Per-type enable/disable flags for optional asset identifiers, read from the
// settings table. Keys follow identifier_<name>_<assettype>_enabled. A legacy
// global key identifier_<name>_enabled is honored as a fallback for older
// installs. Missing = enabled, so a fresh install shows every identifier.
import { reactive } from 'vue'
import { settingsApi } from '../api'
// scope[name][assettype] = boolean. legacy[name] = boolean (old global flag).
const state = reactive({
scope: {},
legacy: {},
loaded: false
})
let inflight = null
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/
function applySetting(key, value) {
const match = KEY_RE.exec(key)
if (!match) return
const name = match[1]
const assettype = match[2]
if (assettype) {
if (!state.scope[name]) state.scope[name] = {}
state.scope[name][assettype] = value !== false
} else {
state.legacy[name] = value !== false
}
}
function fetchFlags() {
inflight = settingsApi.list()
.then(({ data }) => {
;(data.data || []).forEach(s => applySetting(s.key, s.value))
state.loaded = true
})
.catch(() => { state.loaded = true })
.finally(() => { inflight = null })
return inflight
}
function loadFlags() {
if (!state.loaded && !inflight) fetchFlags()
}
// Re-read flags from the server. Call after an identifier setting changes so
// other open views pick it up without a full page reload.
export function reloadIdentifierFlags() {
return fetchFlags()
}
// Optimistically update one flag in the shared state (e.g. right after a
// Settings toggle) so dependent views react immediately.
export function setIdentifierFlag(name, assettype, enabled) {
applySetting(`identifier_${name}_${assettype}_enabled`, enabled)
}
// True when identifier `name` should show on `assettype`. Per-type flag wins,
// then the legacy global flag, then default-on.
function isEnabled(name, assettype) {
const perType = state.scope[name]
if (perType && assettype in perType) return perType[assettype]
if (name in state.legacy) return state.legacy[name]
return true
}
export function useIdentifierFlags() {
loadFlags()
return { state, isEnabled }
}

View File

@@ -0,0 +1,102 @@
import { ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
// Keep a list page's current page and search term in the URL query so the
// browser Back button restores them. Without this a list keeps page in local
// state and remounts at page 1 after visiting a detail page and going Back.
//
// Usage in a list view:
// const { page, search, setPage, setSearch } = useListQuery({ onChange: loadRows })
// goToPage(p) -> setPage(p); loadRows()
// debouncedSearch -> setSearch(search.value); loadRows()
// filter reset -> setPage(1); loadRows()
//
// Options:
// onChange callback run when the query changes from outside (Back/Forward,
// deep link) so the list reloads at the restored page.
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
// gets a ref exposed under the returned `extras` object.
export function useListQuery(options = {}) {
const route = useRoute()
const router = useRouter()
const onChange = options.onChange || (() => {})
const extraKeys = options.extraKeys || []
const page = ref(parseInt(route.query.page, 10) || 1)
const search = ref(route.query.q || '')
const extras = {}
for (const key of extraKeys) {
extras[key] = ref(route.query[key] || '')
}
// Build the next query. Keep unrelated keys intact. Write page only when > 1
// and search only when non-empty so the clean state is a bare path.
function buildQuery() {
const query = { ...route.query }
if (page.value > 1) query.page = String(page.value)
else delete query.page
if (search.value) query.q = search.value
else delete query.q
for (const key of extraKeys) {
if (extras[key].value) query[key] = String(extras[key].value)
else delete query[key]
}
return query
}
// replace (not push) so paging does not spam history; Back leaves the list.
function syncUrl() {
const query = buildQuery()
const current = route.query
const keys = new Set([...Object.keys(query), ...Object.keys(current)])
let same = true
for (const key of keys) {
if (String(query[key] ?? '') !== String(current[key] ?? '')) {
same = false
break
}
}
if (!same) router.replace({ query })
}
function setPage(newPage) {
page.value = newPage
syncUrl()
}
// Changing the search resets to page 1.
function setSearch(term) {
search.value = term
page.value = 1
syncUrl()
}
// Changing an extra filter resets to page 1.
function setExtra(key, value) {
extras[key].value = value
page.value = 1
syncUrl()
}
// Re-sync refs when the query changes from outside (Back/Forward, deep link).
// Fire onChange only when page/search/extras actually changed so a self
// syncUrl() call does not trigger a redundant reload.
watch(() => route.query, (newQuery) => {
const newPage = parseInt(newQuery.page, 10) || 1
const newSearch = newQuery.q || ''
let changed = false
if (newPage !== page.value) { page.value = newPage; changed = true }
if (newSearch !== search.value) { search.value = newSearch; changed = true }
for (const key of extraKeys) {
const newValue = newQuery[key] || ''
if (newValue !== String(extras[key].value || '')) {
extras[key].value = newValue
changed = true
}
}
if (changed) onChange()
})
return { page, search, extras, setPage, setSearch, setExtra }
}

View File

@@ -0,0 +1,69 @@
// Facility floor-map blueprint config, read from the settings table so each
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
// Missing keys fall back to the generic placeholder so a fresh or offline
// install still renders; each site uploads its own blueprint in Settings.
import { reactive } from 'vue'
import { settingsApi } from '../api'
import { withBase } from '../utils/basePath'
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
const DEFAULTS = {
blueprintLight: '/static/images/floorplan-placeholder.svg',
blueprintDark: '/static/images/floorplan-placeholder.svg',
width: 3300,
height: 2550
}
// Shared reactive config. Import as `state` to read width/height/blueprint.
export const state = reactive({ ...DEFAULTS, loaded: false })
let inflight = null
function applySetting(key, value) {
if (value === null || value === undefined || value === '') return
if (key === 'map_blueprint_light') state.blueprintLight = value
else if (key === 'map_blueprint_dark') state.blueprintDark = value
else if (key === 'map_width') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.width = n
} else if (key === 'map_height') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.height = n
}
}
function fetchConfig() {
inflight = settingsApi.list({ category: 'map' })
.then(({ data }) => {
;(data.data || []).forEach(s => applySetting(s.key, s.value))
state.loaded = true
})
.catch(() => { state.loaded = true })
.finally(() => { inflight = null })
return inflight
}
// Fetch the map config once (shared across all map components). Returns a
// promise that resolves when state is populated, so a caller can await it
// before initializing a Leaflet map that needs the dimensions.
export function loadMapConfig() {
if (state.loaded) return Promise.resolve()
if (inflight) return inflight
return fetchConfig()
}
// Re-read config from the server after a map setting changes.
export function reloadMapConfig() {
return fetchConfig()
}
// Blueprint image URL for the given theme ('light' | 'dark').
export function blueprintUrlFor(theme) {
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
}
export function useMapConfig() {
loadMapConfig()
return { state, blueprintUrlFor }
}

View File

@@ -0,0 +1,86 @@
// Settings catalog: the static core groups (settingsNav.js) merged with the
// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by
// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex
// (landing) read the merged catalog so plugins add settings cards with no core
// edit. Fetched once into a module-level ref and shared across callers.
import { ref } from 'vue'
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next'
import api from '../api'
import { settingsGroups } from '../views/settings/settingsNav'
// Icon string keys (as returned by the hook) mapped to Lucide components,
// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece.
const iconMap = {
ruler: Ruler,
wrench: Wrench,
cog: Cog,
package: Package,
settings: Settings,
puzzle: Puzzle,
sliders: SlidersHorizontal,
palette: Palette,
bell: Bell,
network: Network,
printer: Printer,
droplets: Droplets,
}
function resolveIcon(key) {
return iconMap[key] || Puzzle
}
// Merge plugin cards into a fresh copy of the core groups. A card joins the
// group whose title matches its `group`; a new group is appended at the end.
function mergeCards(baseGroups, cards) {
const merged = baseGroups.map(group => ({
title: group.title,
cards: [...group.cards],
}))
const byTitle = new Map(merged.map(group => [group.title, group]))
for (const card of cards) {
const entry = {
to: card.to,
icon: resolveIcon(card.icon),
title: card.title,
description: card.description || '',
position: card.position ?? 99,
}
let group = byTitle.get(card.group)
if (!group) {
group = { title: card.group, cards: [] }
byTitle.set(card.group, group)
merged.push(group)
}
group.cards.push(entry)
}
// Order plugin cards within a group by position; core cards keep their order.
for (const group of merged) {
group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
}
return merged
}
// Shared across component instances: seeded with the core groups, replaced with
// the merged catalog once the hook endpoint answers.
const groups = ref(settingsGroups)
let loaded = false
async function loadCatalog() {
try {
const response = await api.get('/pluginui/settings-cards')
groups.value = mergeCards(settingsGroups, response.data.data || [])
} catch (error) {
// Degrade to the core-only catalog; the rail still works without plugins.
groups.value = settingsGroups
}
}
export function useSettingsCatalog() {
if (!loaded) {
loaded = true
loadCatalog()
}
return { groups }
}

View File

@@ -0,0 +1,28 @@
// Tracks whether first-run setup is complete, so the app can steer a fresh
// admin into the /setup wizard. Defaults to "complete" so the wizard never
// flashes before the real value loads.
import { ref } from 'vue'
import { settingsApi } from '../api'
export const setupComplete = ref(true)
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
// until the next login/reload.
export const setupSkipped = ref(false)
const loaded = ref(false)
export function isSetupLoaded() {
return loaded.value
}
export async function refreshSetupState() {
try {
const response = await settingsApi.get('setup_complete')
const value = response.data?.data?.value
setupComplete.value = value === true || value === 'true'
} catch (err) {
// If we cannot read it, assume complete so we do not trap the user.
setupComplete.value = true
}
loaded.value = true
return setupComplete.value
}

View File

@@ -0,0 +1,359 @@
// Shared plumbing for the individual system-settings pages.
// Each settings page calls useSystemSettings() in its own setup, so state is
// per-page (no cross-page leak). The composable owns the load-all/save/toggle/
// upload machinery plus the static display catalogs, keeping the pages thin.
// Per-page connection-status computeds (zabbix/dell/smtp/saml) stay in the pages.
import { ref, reactive } from 'vue'
import { settingsApi, computersApi } from '../api'
import { setIdentifierFlag } from './identifierSettings'
import { apiError } from '../utils/apiError'
// Branding logo widgets. kind maps to the backend endpoint; key is the setting
// the resulting URL is stored under.
export const brandingLogos = [
{ kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Shown in the app header and login. Upload an image or type a path/URL.' },
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
placeholder: '/ge-monogram.svg',
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
{ kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' },
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
placeholder: '(blank = shipped /favicon.svg)',
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
]
// Asset identifier matrix: identifier x asset type. Keys follow
// identifier_<name>_<assettype>_enabled. Missing = enabled (default on).
export const identifierRows = [
{ name: 'gaugelabreference', label: 'Gauge Lab Reference' },
{ name: 'maintenancereference', label: 'Maintenance Reference' },
{ name: 'fqdn', label: 'FQDN / Hostname' }
]
export const assetTypeCols = [
{ key: 'machine', label: 'Machine' },
{ key: 'computer', label: 'PC' },
{ key: 'printer', label: 'Printer' },
{ key: 'network_device', label: 'Network' },
{ key: 'measuring_tool', label: 'Measuring Tool' }
]
// Global-search domain toggles: keys follow search_<type>_enabled.
// Missing = enabled (default on).
export const searchDomains = [
{ key: 'application', label: 'Applications' },
{ key: 'knowledgebase', label: 'Knowledge Base' },
{ key: 'employee', label: 'Employees' },
{ key: 'machine', label: 'Machines' },
{ key: 'computer', label: 'PCs' },
{ key: 'printer', label: 'Printers' },
{ key: 'network_device', label: 'Network Devices' },
{ key: 'measuring_tool', label: 'Measuring Tools' },
{ key: 'notification', label: 'Notifications' },
{ key: 'subnet', label: 'Subnets' }
]
export function useSystemSettings() {
// Every settings key across all pages. A page binds only its own subset.
const settings = reactive({
// Zabbix
zabbix_enabled: false,
zabbix_url: '',
zabbix_token: '',
// Dell warranty
warranty_dell_enabled: false,
warranty_dell_clientid: '',
warranty_dell_clientsecret: '',
warranty_dell_tokenurl: '',
warranty_dell_apiurl: '',
// ServiceNow
servicenow_enabled: true,
servicenow_search_url: '',
servicenow_ticket_prefixes: '',
servicenow_incident_url: '',
servicenow_change_url: '',
// Branding
site_logo: '',
qr_logo: '',
badge_logo: '',
site_favicon: '',
brand_primary_color: '',
brand_primary_dark_color: '',
brand_accent_color: '',
brand_sidebar_color: '',
// Printing and labels
qr_target_printer: '',
qr_target_usb: '',
usb_label_style: 'barcode',
qr_target_machine: '',
qr_target_computer: '',
qr_target_network_device: '',
qr_target_measuring_tool: '',
label_default_style: 'card',
label_default_codetype: 'qr',
label_default_encodes_machine: 'assetnumber',
label_default_encodes_computer: 'assetpage',
label_default_encodes_printer: 'assetpage',
label_default_encodes_network_device: 'assetpage',
label_default_encodes_measuring_tool: 'location',
// Email
smtp_enabled: false,
smtp_host: '',
smtp_port: 587,
smtp_username: '',
smtp_password: '',
smtp_use_tls: true,
smtp_from_address: '',
smtp_from_name: 'ShopDB',
alert_recipients: '',
// Audit
audit_retention_days: 90,
// Floor map blueprint (per-facility)
map_blueprint_light: '',
map_blueprint_dark: '',
map_width: 3300,
map_height: 2550,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',
saml_entity_id: '',
saml_acs_url: '',
saml_allow_local_login: true,
saml_auto_create_users: true,
saml_admin_group: ''
})
const identifierMatrix = reactive({})
const searchMatrix = reactive({})
const pcTypeMappings = ref([]) // [{ pxetype, computertype }]
const computerTypes = ref([]) // ComputerType names for the dropdown
const loading = ref(true)
const saving = ref(false)
const mapUploading = ref(false)
const brandingUploading = ref(false)
const error = ref('')
const success = ref('')
function flashSaved() {
success.value = 'Setting saved'
setTimeout(() => { success.value = '' }, 2000)
}
function identifierKey(name, assettype) {
return `identifier_${name}_${assettype}_enabled`
}
function matrixValue(name, assettype) {
const key = identifierKey(name, assettype)
return key in identifierMatrix ? identifierMatrix[key] : true
}
function searchValue(domainKey) {
const key = `search_${domainKey}_enabled`
return key in searchMatrix ? searchMatrix[key] : true
}
// Load all settings and fan them into settings / matrices / pctype rows.
async function loadSettings() {
try {
loading.value = true
const { data } = await settingsApi.list()
const pctypeRows = []
for (const setting of data.data) {
if (setting.key in settings) {
settings[setting.key] = setting.value
} else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) {
identifierMatrix[setting.key] = setting.value !== false
} else if (/^search_.+_enabled$/.test(setting.key)) {
searchMatrix[setting.key] = setting.value !== false
} else if (setting.key.startsWith('pctypemap_')) {
pctypeRows.push({
pxetype: setting.key.slice('pctypemap_'.length),
computertype: setting.value
})
}
}
pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype))
} catch (e) {
error.value = 'Failed to load settings'
console.error(e)
} finally {
loading.value = false
}
}
// Computer type options for the pctype mapping dropdown (own request).
async function loadComputerTypes() {
try {
const typesResponse = await computersApi.types.list({ perpage: 100 })
computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype)
} catch (typesError) {
console.error('Failed to load computer types', typesError)
}
}
async function saveSetting(key, value) {
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, value)
settings[key] = value
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function toggleSetting(key) {
await saveSetting(key, !settings[key])
}
// Toggle a per-type identifier flag. The key may not be seeded on older
// installs, so create it when the update returns 404.
async function toggleIdentifier(name, assettype) {
const key = identifierKey(name, assettype)
const newValue = !matrixValue(name, assettype)
try {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, newValue)
} catch (e) {
if (e.response?.status === 404) {
await settingsApi.create({
key,
value: newValue,
valuetype: 'boolean',
category: 'identifiers',
description: `Show the ${name} identifier on ${assettype} assets`
})
} else {
throw e
}
}
identifierMatrix[key] = newValue
// Push into the shared composable so open asset views react without a
// full page reload (the composable otherwise fetches only once).
setIdentifierFlag(name, assettype, newValue)
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function toggleSearchDomain(domainKey) {
const key = `search_${domainKey}_enabled`
const newValue = !searchValue(domainKey)
const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey
try {
saving.value = true
error.value = ''
success.value = ''
try {
await settingsApi.update(key, newValue)
} catch (e) {
if (e.response?.status === 404) {
await settingsApi.create({
key,
value: newValue,
valuetype: 'boolean',
category: 'search',
description: `Include ${label} in global search results`
})
} else {
throw e
}
}
searchMatrix[key] = newValue
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function changePcTypeMapping(pxetype, computertype) {
const key = `pctypemap_${pxetype}`
try {
saving.value = true
error.value = ''
success.value = ''
await settingsApi.update(key, computertype)
const row = pcTypeMappings.value.find(r => r.pxetype === pxetype)
if (row) row.computertype = computertype
flashSaved()
} catch (e) {
error.value = apiError(e, 'Failed to save setting')
console.error(e)
} finally {
saving.value = false
}
}
async function uploadBlueprint(theme, event) {
const file = event.target.files[0]
if (!file) return
mapUploading.value = true
error.value = ''
success.value = ''
try {
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
const url = data?.data?.value
if (theme === 'light') settings.map_blueprint_light = url
else settings.map_blueprint_dark = url
success.value = 'Blueprint uploaded'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = apiError(e, 'Upload failed')
} finally {
mapUploading.value = false
event.target.value = ''
}
}
async function uploadLogo(kind, key, event) {
const file = event.target.files[0]
if (!file) return
brandingUploading.value = true
error.value = ''
success.value = ''
try {
const { data } = await settingsApi.uploadBrandingLogo(kind, file)
const url = data?.data?.value
if (url) settings[key] = url
success.value = 'Logo uploaded'
setTimeout(() => { success.value = '' }, 2000)
} catch (e) {
error.value = apiError(e, 'Upload failed')
} finally {
brandingUploading.value = false
event.target.value = ''
}
}
return {
settings,
identifierMatrix, searchMatrix, pcTypeMappings, computerTypes,
loading, saving, mapUploading, brandingUploading, error, success,
loadSettings, loadComputerTypes,
saveSetting, toggleSetting,
identifierKey, matrixValue, toggleIdentifier,
searchValue, toggleSearchDomain,
changePcTypeMapping,
uploadBlueprint, uploadLogo,
}
}

View File

@@ -0,0 +1,35 @@
// Global toast notifications. Import useToast() anywhere and call
// toast.success('...') / toast.error('...') / toast.info('...'). A single
// <ToastHost /> mounted in AppLayout renders the stack.
import { reactive } from 'vue'
const state = reactive({
items: [],
})
let nextId = 1
function dismiss(id) {
const index = state.items.findIndex(t => t.id === id)
if (index !== -1) state.items.splice(index, 1)
}
function push(message, type = 'info', duration = 5000) {
if (!message) return
const id = nextId++
state.items.push({ id, message, type })
if (duration > 0) {
setTimeout(() => dismiss(id), duration)
}
return id
}
export function useToast() {
return {
items: state.items,
dismiss,
success: (message, duration) => push(message, 'success', duration),
error: (message, duration) => push(message, 'error', duration ?? 8000),
info: (message, duration) => push(message, 'info', duration),
}
}

View File

@@ -0,0 +1,44 @@
// Shared warranty fetch + hero-badge derivation for asset detail pages.
// Pass a getter for the asset id; get back the warranties list (to feed a
// WarrantyPanel) and a heroWarranty for a compact status badge.
import { ref, computed, watch } from 'vue'
import { warrantyApi } from '../api'
const STATUS_RANK = { expired: 0, expiring: 1, active: 2, unknown: 3 }
const STATUS_LABELS = {
active: 'Under Warranty',
expiring: 'Warranty Expiring',
expired: 'Warranty Expired',
unknown: 'Warranty',
}
export function useWarrantyBadge(getAssetId) {
const warranties = ref([])
// Worst-case warranty drives the badge: expired > expiring > active > unknown.
const heroWarranty = computed(() => {
if (!warranties.value.length) return null
const worst = [...warranties.value]
.sort((a, b) => (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9))[0]
return { ...worst, label: STATUS_LABELS[worst.status] || 'Warranty' }
})
function warrantyDate(d) {
return d ? new Date(d + 'T00:00:00').toLocaleDateString() : ''
}
async function load() {
const assetid = getAssetId()
if (!assetid) { warranties.value = []; return }
try {
const response = await warrantyApi.forAsset(assetid)
warranties.value = response.data.data || []
} catch (err) {
warranties.value = []
}
}
watch(getAssetId, load, { immediate: true })
return { warranties, heroWarranty, warrantyDate, reloadWarranties: load }
}