Multi-site distribution readiness: settings-driven site config, security closeout, release engineering, v0.5.0
Some checks failed
CI / backend (push) Failing after 2s
CI / naming (push) Successful in 1s
CI / frontend (push) Successful in 7s

Make the app distributable to other GE Aerospace sites (one self-hosted
instance per site, ADR-004). GE values remain the shipped defaults; every
site-specific behavior is now a Setting an admin can change in the UI.

Settings-driven site config:
- Branding: site/QR/badge logos, favicon, primary color (upload endpoints
  mirror the map-blueprint pattern; new Settings > Branding section).
- ServiceNow: search/incident/change URL templates ({ticket}), ticket
  prefixes, enable toggle. Defaults point at the current
  geaerospaceqa.service-now.com global search. Disabled = plain-text tickets.
- Employee-id regex (employeeid_pattern), printer hostname template,
  QR label targets (qr_target_printer / qr_target_usb, blank = asset page,
  else URL template with placeholders), usb_label_style (barcode|qr).
- West Jefferson floor-plan PNGs removed from the tree; generic placeholder
  ships as the map default and sites upload their own blueprint.

Security closeout:
- dashboarddefaults writes now require admin.
- Collector: generic error messages (no str(exc) leak); API key accepted
  via X-API-Key header only (BREAKING: querystring api_key removed).
- IP-based login rate limiting (AUTH_RATELIMIT_* knobs) atop account lockout.
- Setting.set() creation race fixed (IntegrityError retry).

Release engineering and docs:
- __version__ 0.5.0 (distinct from __contract_version__, ADR-007),
  CHANGELOG.md, Gitea Actions CI config, frontend version aligned.
- One wizard-first install story across README/DEPLOY; new CONFIG.md,
  UPGRADE.md, BACKUP-RESTORE.md; CLAUDE.md and ROADMAP de-staled.
- Dockerfile multi-stage build now bundles the frontend; compose binds
  MySQL to 127.0.0.1; stale database/schema.sql and one-off SQL removed.

Debt and fixes:
- .query.get() -> db.session.get() sweep; datetime.utcnow() removed
  (naive-UTC via timezone-aware now); users.py on authz decorators.
- Fixed 4 stale tests (slides feed shape, shopfloor splitperemployee,
  plugin contract purity) and the USB label page field mapping (both usb
  modes emit the cmmc shape: device_id/device_desc).
- Health endpoint reports the real version.

248 tests pass; naming/style check green; frontend builds; fresh-DB
flask db upgrade + seeds verified; QR targets verified by decoding
rendered codes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-10 15:02:07 -04:00
parent bf9e60e607
commit b8c22244a1
96 changed files with 3818 additions and 1942 deletions

View File

@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ShopDB</title>
<link rel="icon" href="/favicon.svg">
<link rel="stylesheet" href="/src/assets/style.css">
</head>
<body>

View File

@@ -1,6 +1,6 @@
{
"name": "shopdb-frontend",
"version": "1.0.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {

View File

@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
<rect x="2" y="2" width="60" height="60" rx="14" fill="#4181ff"/>
<text x="32" y="33" fill="#ffffff" font-family="Arial, Helvetica, sans-serif" font-size="40" font-weight="700" text-anchor="middle" dominant-baseline="central">S</text>
</svg>

After

Width:  |  Height:  |  Size: 330 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>

After

Width:  |  Height:  |  Size: 4.6 KiB

View File

@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" width="3300" height="2550" viewBox="0 0 3300 2550">
<defs>
<pattern id="grid" width="150" height="150" patternUnits="userSpaceOnUse">
<path d="M 150 0 L 0 0 0 150" fill="none" stroke="#d0d4d9" stroke-width="2"/>
</pattern>
</defs>
<rect width="3300" height="2550" fill="#f4f6f8"/>
<rect width="3300" height="2550" fill="url(#grid)"/>
<rect x="20" y="20" width="3260" height="2510" fill="none" stroke="#b8bec6" stroke-width="4"/>
<text x="1650" y="1275" fill="#8a9099" font-family="Arial, Helvetica, sans-serif" font-size="90" font-weight="600" text-anchor="middle" dominant-baseline="central">Upload your facility floor plan in Settings &gt; Map</text>
</svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@@ -808,6 +808,13 @@ export const settingsApi = {
form.append('theme', theme)
form.append('file', file)
return api.post('/settings/map-blueprint', form, { headers: { 'Content-Type': 'multipart/form-data' } })
},
uploadBrandingLogo(kind, file) {
// kind is one of site/qr/badge/favicon; backend writes the matching setting
const form = new FormData()
form.append('kind', kind)
form.append('file', file)
return api.post('/settings/branding-logo', form, { headers: { 'Content-Type': 'multipart/form-data' } })
}
}

View File

@@ -1,15 +1,15 @@
// 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 West Jefferson sitemap so a fresh or offline
// install still renders.
// 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'
// Fallback defaults - the original hardcoded West Jefferson values.
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
const DEFAULTS = {
blueprintLight: '/static/images/sitemap2025-light.png',
blueprintDark: '/static/images/sitemap2025-dark.png',
blueprintLight: '/static/images/floorplan-placeholder.svg',
blueprintDark: '/static/images/floorplan-placeholder.svg',
width: 3300,
height: 2550
}

View File

@@ -5,6 +5,7 @@ import App from './App.vue'
// Initialize theme on app load
import './stores/theme'
import { applyBranding } from './utils/siteSettings'
const app = createApp(App)
@@ -12,3 +13,6 @@ app.use(createPinia())
app.use(router)
app.mount('#app')
// Apply per-site favicon + brand color once settings load (no-op on defaults).
applyBranding()

View File

@@ -0,0 +1,26 @@
// Shared QR target resolution for printed labels. Each label surface has a
// qr_target_<domain> setting: blank means the QR links to the asset's own
// detail page on this instance; a non-blank value is treated as a URL
// template and every {placeholder} is substituted from the tokens map.
import { getSetting, getSiteBaseUrl } from './siteSettings'
// Substitute {name} placeholders. Values are URL-encoded; unknown or empty
// placeholders collapse to an empty string rather than leaking the braces.
export function fillUrlTemplate(template, tokens) {
return template.replace(/\{([a-z0-9_]+)\}/gi, (match, name) => {
const value = tokens[name]
return (value === undefined || value === null) ? '' : encodeURIComponent(String(value))
})
}
// Resolve the QR URL for one asset. settingKey is the qr_target_<domain>
// setting, defaultPath the in-app detail route (e.g. /printers/12), tokens
// the placeholder values available to a custom template.
export async function buildQrUrl(settingKey, defaultPath, tokens = {}) {
const template = (await getSetting(settingKey, '')).trim()
if (!template) {
const baseUrl = await getSiteBaseUrl()
return `${baseUrl}${defaultPath}`
}
return fillUrlTemplate(template, tokens)
}

View File

@@ -33,5 +33,79 @@ export async function getSiteBaseUrl() {
// Facility name shown on the shopfloor dashboard.
export async function getFacilityName() {
return getSetting('facility_name', 'West Jefferson')
return getSetting('facility_name', 'ShopDB')
}
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
export async function getSiteLogo() {
return getSetting('site_logo', '/ge-aerospace-logo.svg')
}
// Logo composited into the center of printer QR codes. Empty = no overlay.
// Note: '' is a valid "no overlay" value, so read the raw setting rather than
// getSetting (which swaps '' for the fallback).
export async function getQrLogo() {
const settings = await loadSettings()
const value = settings['qr_logo']
return (value === undefined || value === null) ? '/ge-monogram.svg' : value
}
// Logo printed on equipment inspection badges.
export async function getBadgeLogo() {
return getSetting('badge_logo', '/ge-aerospace-logo.svg')
}
// Browser-tab favicon. Empty = keep the shipped /favicon.svg.
export async function getFavicon() {
return getSetting('site_favicon', '')
}
// Brand primary color override. Empty = built-in palette from style.css.
export async function getBrandPrimaryColor() {
return getSetting('brand_primary_color', '')
}
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
// templates, and the global-search URL template (all with a {ticket}
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
export async function getServicenowUrls() {
const enabledRaw = await getSetting('servicenow_enabled', 'true')
const enabled = enabledRaw !== 'false' && enabledRaw !== '0' && enabledRaw !== false
const incidentUrl = await getSetting(
'servicenow_incident_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const changeUrl = await getSetting(
'servicenow_change_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
const searchUrl = await getSetting(
'servicenow_search_url',
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
)
return { enabled, incidentUrl, changeUrl, searchUrl }
}
// Printer hostname template. {ip} is replaced with the dash-separated IP.
export async function getPrinterHostnameTemplate() {
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net')
}
// Apply per-site favicon + brand color at bootstrap. Empty settings keep the
// shipped defaults. Do not touch the style.css palette here.
export async function applyBranding() {
const favicon = await getFavicon()
if (favicon) {
let link = document.querySelector('link[rel="icon"]')
if (!link) {
link = document.createElement('link')
link.rel = 'icon'
document.head.appendChild(link)
}
link.href = favicon
}
const primaryColor = await getBrandPrimaryColor()
if (primaryColor) {
document.documentElement.style.setProperty('--primary', primaryColor)
}
}

View File

@@ -2,8 +2,8 @@
<div class="app-layout">
<aside class="sidebar">
<div class="sidebar-header">
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="sidebar-logo" />
<h1>West Jefferson</h1>
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
<h1>{{ facilityName }}</h1>
</div>
<div class="sidebar-search">
@@ -63,12 +63,15 @@
<span class="notification-text">{{ n.notification }}</span>
<span class="notification-meta">
<span v-if="n.starttime" class="notification-date">{{ formatDate(n.starttime) }}</span>
<a
v-if="n.ticketnumber"
:href="`https://geit.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/${n.ticketnumber}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/back-button-label/IT4IT%20Homepage/search-context/now%2Fnav%2Fui`"
target="_blank"
class="notification-ticket"
>{{ n.ticketnumber }}</a>
<template v-if="n.ticketnumber">
<a
v-if="getTicketSearchUrl(n.ticketnumber)"
:href="getTicketSearchUrl(n.ticketnumber)"
target="_blank"
class="notification-ticket"
>{{ n.ticketnumber }}</a>
<span v-else class="notification-ticket">{{ n.ticketnumber }}</span>
</template>
</span>
</div>
</div>
@@ -89,12 +92,24 @@ import {
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
import { dashboardApi, notificationsApi } from '../api'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
const router = useRouter()
const authStore = useAuthStore()
const searchQuery = ref('')
const navItems = ref([])
const activeNotifications = ref([])
const facilityName = ref('ShopDB')
const siteLogo = ref('/ge-aerospace-logo.svg')
const servicenowConfig = ref({ enabled: true, searchUrl: '' })
function getTicketSearchUrl(ticketnumber) {
// Null when ServiceNow is disabled or no search template is set: the
// ticket number renders as plain text instead of a link.
const config = servicenowConfig.value
if (!ticketnumber || !config.enabled || !config.searchUrl) return null
return config.searchUrl.replace('{ticket}', encodeURIComponent(ticketnumber))
}
// Map backend icon names to Lucide components
const iconMap = {
@@ -161,6 +176,10 @@ function buildNavItems(items) {
}
onMounted(async () => {
getFacilityName().then(name => { facilityName.value = name })
getSiteLogo().then(logo => { siteLogo.value = logo })
getServicenowUrls().then(config => { servicenowConfig.value = config })
try {
const response = await dashboardApi.navigation()
navItems.value = buildNavItems(response.data.data || [])

View File

@@ -1,7 +1,7 @@
<template>
<div class="login-container">
<div class="login-box">
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="login-logo" />
<img :src="siteLogo" alt="Site logo" class="login-logo" />
<h1>ShopDB</h1>
<div v-if="error" class="error-message">{{ error }}</div>
@@ -51,10 +51,12 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import { setupApi } from '../api'
import { getSiteLogo } from '../utils/siteSettings'
const router = useRouter()
const authStore = useAuthStore()
const siteLogo = ref('/ge-aerospace-logo.svg')
const mode = ref('login')
const username = ref('')
const email = ref('')
@@ -63,6 +65,8 @@ const error = ref('')
const loading = ref(false)
onMounted(async () => {
getSiteLogo().then(logo => { siteLogo.value = logo })
// If the instance has no users yet, offer to create the first admin.
try {
const response = await setupApi.needsAdmin()

View File

@@ -25,6 +25,12 @@
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>
<input v-model="form.site_base_url" type="url" class="form-control" placeholder="https://shopdb.example.net" />
</div>
<div class="form-group">
<label>Site logo <span class="hint">(optional)</span></label>
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings &gt; Branding.</small>
</div>
</div>
<!-- Plugins -->
@@ -187,6 +193,24 @@ const seedResult = ref('')
const mapUploading = ref(false)
const blueprintLight = ref('')
const blueprintDark = ref('')
const logoUploading = ref(false)
const siteLogo = ref('')
async function uploadSiteLogo(event) {
const file = event.target.files[0]
if (!file) return
logoUploading.value = true
try {
const { data } = await settingsApi.uploadBrandingLogo('site', file)
siteLogo.value = data?.data?.value || ''
toast.success('Logo uploaded')
} catch (err) {
toast.error(apiError(err, 'Upload failed'))
} finally {
logoUploading.value = false
event.target.value = ''
}
}
async function uploadBlueprint(theme, event) {
const file = event.target.files[0]

View File

@@ -2,7 +2,7 @@
<div class="shopfloor-dashboard">
<header class="dashboard-header">
<div class="logo-container">
<img src="/ge-aerospace-logo.svg" alt="GE Aerospace" class="logo" />
<img :src="siteLogo" alt="Site logo" class="logo" />
</div>
<div class="header-center">
@@ -54,8 +54,8 @@
/>
<img
v-else
src="/ge-aerospace-logo.svg"
alt="GE Aerospace"
:src="siteLogo"
alt="Site logo"
class="recognition-photo ge-logo-fallback"
/>
</div>
@@ -98,8 +98,8 @@
/>
<img
v-else
src="/ge-aerospace-logo.svg"
alt="GE Aerospace"
:src="siteLogo"
alt="Site logo"
class="recert-photo ge-logo-fallback"
/>
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
@@ -132,9 +132,12 @@
</template>
</div>
</div>
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
<template v-if="n.ticketnumber">
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
</template>
</div>
</div>
</section>
@@ -155,9 +158,12 @@
<strong>{{ formatDateTime(n.starttime) }}</strong>
</div>
</div>
<a v-if="n.ticketnumber" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
<template v-if="n.ticketnumber">
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
{{ n.ticketnumber }}
</a>
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
</template>
</div>
</div>
</section>
@@ -179,10 +185,13 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
import { getFacilityName } from '@/utils/siteSettings'
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
const loading = ref(true)
const facilityName = ref('West Jefferson')
const facilityName = ref('ShopDB')
const siteLogo = ref('/ge-aerospace-logo.svg')
// ServiceNow ticket-link config; loaded on mount. Empty/disabled = plain text.
const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
const businessUnit = ref('')
const businessUnits = ref([])
const notifications = ref({ current: [], upcoming: [] })
@@ -266,6 +275,8 @@ onMounted(async () => {
setInterval(updateClock, 1000)
getFacilityName().then(name => { facilityName.value = name })
getSiteLogo().then(logo => { siteLogo.value = logo })
getServicenowUrls().then(config => { servicenowConfig.value = config })
// Load business units
try {
@@ -383,19 +394,22 @@ function formatDateTime(dateStr) {
}
function getTicketUrl(ticketnumber) {
if (!ticketnumber) return '#'
// ServiceNow ticket URLs
if (ticketnumber.startsWith('GEINC')) {
return `https://ge.service-now.com/nav_to.do?uri=incident.do?sysparm_query=number=${ticketnumber}`
// Return a URL only when ServiceNow is enabled and a matching template is set.
// Null means "render the ticket number as plain text, no link".
if (!ticketnumber) return null
const config = servicenowConfig.value
if (!config.enabled) return null
if (ticketnumber.startsWith('GEINC') && config.incidentUrl) {
return config.incidentUrl.replace('{ticket}', ticketnumber)
}
if (ticketnumber.startsWith('GECHG')) {
return `https://ge.service-now.com/nav_to.do?uri=change_request.do?sysparm_query=number=${ticketnumber}`
if (ticketnumber.startsWith('GECHG') && config.changeUrl) {
return config.changeUrl.replace('{ticket}', ticketnumber)
}
return '#'
return null
}
function handlePhotoError(e) {
e.target.src = '/ge-aerospace-logo.svg'
e.target.src = siteLogo.value
e.target.classList.add('ge-logo-fallback')
}
</script>

View File

@@ -29,6 +29,7 @@
import { ref, computed, onMounted, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi } from '../../api'
import { getBadgeLogo } from '@/utils/siteSettings'
import JsBarcode from 'jsbarcode'
const route = useRoute()
@@ -36,7 +37,7 @@ const loading = ref(true)
const equipment = ref(null)
const barcodeEl = ref(null)
const geLogo = '/images/applications/GE-Logo.png'
const geLogo = ref('/ge-aerospace-logo.svg')
const isInspection = computed(() => {
if (!equipment.value) return false
@@ -57,6 +58,7 @@ const imageUrl = computed(() => {
})
onMounted(async () => {
getBadgeLogo().then(logo => { geLogo.value = logo })
try {
const response = await equipmentApi.get(route.params.id)
equipment.value = response.data.data

View File

@@ -66,7 +66,7 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '../../api'
import { renderQrDataUrl } from './qrLogo'
import { getSiteBaseUrl } from '@/utils/siteSettings'
import { buildQrUrl } from '@/utils/qrTarget'
const printers = ref([])
const selectedPrinters = ref([])
@@ -106,7 +106,6 @@ watch(selectedPrinters, async () => {
}, { deep: true })
async function generateQRCodes() {
const baseUrl = await getSiteBaseUrl()
const next = {}
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
const page = pages.value[pageIdx]
@@ -114,7 +113,15 @@ async function generateQRCodes() {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const qrUrl = `${baseUrl}/printers/${printer.printer?.printerid || printer.assetid}`
const detailId = printer.printer?.printerid || printer.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: printer.printer?.printerid || '',
assetid: printer.assetid || '',
assetnumber: printer.assetnumber || '',
serialnumber: printer.serialnumber || '',
ip: getIp(printer) || '',
hostname: printer.printer?.windowsname || printer.name || '',
})
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}

View File

@@ -48,7 +48,7 @@ import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '../../api'
import { renderQrDataUrl } from './qrLogo'
import { getSiteBaseUrl } from '@/utils/siteSettings'
import { buildQrUrl } from '@/utils/qrTarget'
const route = useRoute()
const loading = ref(true)
@@ -87,8 +87,16 @@ watch(position, async () => {
async function generateQR() {
if (!printer.value) return
const baseUrl = await getSiteBaseUrl()
const qrUrl = `${baseUrl}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
const p = printer.value
const detailId = p.printer?.printerid || p.assetid
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
printerid: p.printer?.printerid || '',
assetid: p.assetid || '',
assetnumber: p.assetnumber || '',
serialnumber: p.serialnumber || '',
ip: ipAddress.value || '',
hostname: p.printer?.windowsname || p.name || '',
})
qrImage.value = await renderQrDataUrl(qrUrl)
}

View File

@@ -1,374 +1,425 @@
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print USB Barcode Labels</h3>
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
<div v-else class="usb-grid">
<div
v-for="device in devices"
:key="device.machineid"
class="usb-item"
:class="{ selected: isSelected(device) }"
@click="toggleDevice(device)"
>
<input type="checkbox" :checked="isSelected(device)" @click.stop />
<label>
<strong><code>{{ device.serialnumber || '-' }}</code></strong>
<div class="alias">{{ device.alias || device.machinenumber || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
(<span class="pages">{{ pageCount }}</span> pages)
<label style="margin-left: 20px;">Start at cell:
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in sheetPages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="cellNum in 6"
:key="cellNum"
class="label-cell"
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
>
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
<div
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
:key="miniIdx"
class="mini-label"
:class="miniItem ? 'filled' : 'empty'"
>
<template v-if="miniItem">
<div class="barcode-container">
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
</div>
<div class="serial-text">{{ miniItem.serialnumber }}</div>
</template>
</div>
</div>
<div v-else class="empty-cell-text">Empty</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usbApi } from '../../api'
import JsBarcode from 'jsbarcode'
const MINI_LABELS_PER_CELL = 12
const CELLS_PER_PAGE = 6
const devices = ref([])
const selectedDevices = ref([])
const loadingDevices = ref(true)
const startCell = ref('1')
const barcodeRefs = ref({})
const pageCount = computed(() => {
if (selectedDevices.value.length === 0) return 0
const skippedCells = parseInt(startCell.value) - 1
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
const totalCellsNeeded = numCells + skippedCells
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
})
const sheetPages = computed(() => {
const result = []
if (selectedDevices.value.length === 0) return result
const startCellNum = parseInt(startCell.value)
let usbIdx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
const skipThisCell = page === 0 && cellNum < startCellNum
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
const items = []
if (hasContent) {
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
if (usbIdx < selectedDevices.value.length) {
items.push(selectedDevices.value[usbIdx])
usbIdx++
} else {
items.push(null)
}
}
}
cells.push({ hasContent, items })
}
result.push(cells)
}
return result
})
onMounted(async () => {
try {
const response = await usbApi.list({ perpage: 500 })
devices.value = response.data.data || []
} catch (error) {
console.error('Error loading USB devices:', error)
} finally {
loadingDevices.value = false
}
})
watch([selectedDevices, startCell], async () => {
await nextTick()
generateBarcodes()
}, { deep: true })
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
if (el) {
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
}
}
function generateBarcodes() {
sheetPages.value.forEach((page, pageIdx) => {
page.forEach((cell, cellIdx) => {
if (!cell.hasContent) return
const cellNum = cellIdx + 1
cell.items.forEach((item, miniIdx) => {
if (!item) return
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
if (!el) return
try {
JsBarcode(el, item.serialnumber, {
format: 'CODE128',
width: 1,
height: 22,
displayValue: false,
margin: 0,
background: 'transparent'
})
} catch (e) {
console.error('Barcode error:', item.serialnumber, e)
}
})
})
})
}
function isSelected(device) {
return selectedDevices.value.some(d => d.machineid === device.machineid)
}
function toggleDevice(device) {
const idx = selectedDevices.value.findIndex(d => d.machineid === device.machineid)
if (idx > -1) {
selectedDevices.value.splice(idx, 1)
} else {
selectedDevices.value.push(device)
}
}
function clearSelection() {
selectedDevices.value = []
}
function selectAll() {
selectedDevices.value = [...devices.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--danger);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--success);
color: white;
border: none;
border-radius: 5px;
}
.usb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.usb-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.usb-item:hover { border-color: var(--primary); }
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.usb-item input { margin-right: 10px; }
.usb-item label { cursor: pointer; flex: 1; }
.usb-item .alias { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label-cell {
width: 3in;
height: 3in;
position: absolute;
box-sizing: border-box;
border: 1px dashed #ccc;
overflow: hidden;
}
.label-cell.has-content { border: 1px solid var(--primary); }
.label-cell.empty { background: #fafafa; }
.cell-1 { top: 0.875in; left: 1.1875in; }
.cell-2 { top: 0.875in; left: 4.3125in; }
.cell-3 { top: 4in; left: 1.1875in; }
.cell-4 { top: 4in; left: 4.3125in; }
.cell-5 { top: 7.125in; left: 1.1875in; }
.cell-6 { top: 7.125in; left: 4.3125in; }
.mini-grid {
display: grid;
grid-template-columns: repeat(3, 1in);
grid-template-rows: repeat(4, 0.75in);
width: 3in;
height: 3in;
}
.mini-label {
width: 1in;
height: 0.75in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0.02in;
border: 1px dotted #ddd;
overflow: hidden;
}
.mini-label.filled { border: 1px solid #999; }
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
.barcode-container { text-align: center; line-height: 0; }
.barcode-container svg { max-width: 0.9in; height: 24px; }
.serial-text {
font-size: 6pt;
font-weight: bold;
font-family: monospace;
text-align: center;
margin-top: 1px;
letter-spacing: 0.3px;
}
.empty-cell-text {
color: #ccc;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
@media print {
/* Force the barcodes/borders to print even when "Background graphics" is
off. */
body, .print-sheet, .label-cell, .mini-label, .barcode-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label-cell { border: none !important; }
.label-cell.empty { visibility: hidden; }
.mini-label { border: 1px dotted #ccc !important; }
.mini-label.empty { visibility: hidden; }
}
</style>
<template>
<div>
<div class="no-print">
<div class="controls">
<h3>Batch Print USB Barcode Labels</h3>
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
<div v-else class="usb-grid">
<div
v-for="device in devices"
:key="device.device_id"
class="usb-item"
:class="{ selected: isSelected(device) }"
@click="toggleDevice(device)"
>
<input type="checkbox" :checked="isSelected(device)" @click.stop />
<label>
<strong><code>{{ device.device_id || '-' }}</code></strong>
<div class="alias">{{ device.device_desc || '' }}</div>
</label>
</div>
</div>
<div class="selected-count">
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
(<span class="pages">{{ pageCount }}</span> pages)
<label style="margin-left: 20px;">Start at cell:
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
<option value="1">1 - Top Left</option>
<option value="2">2 - Top Right</option>
<option value="3">3 - Middle Left</option>
<option value="4">4 - Middle Right</option>
<option value="5">5 - Bottom Left</option>
<option value="6">6 - Bottom Right</option>
</select>
</label>
</div>
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</button>
<button class="clear-btn" @click="clearSelection">Clear All</button>
<button class="select-all-btn" @click="selectAll">Select All</button>
</div>
</div>
<div class="sheets-container">
<div v-for="(page, pageIdx) in sheetPages" :key="pageIdx" class="print-sheet">
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
<div
v-for="cellNum in 6"
:key="cellNum"
class="label-cell"
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
>
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
<div
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
:key="miniIdx"
class="mini-label"
:class="miniItem ? 'filled' : 'empty'"
>
<template v-if="miniItem">
<img
v-if="labelStyle === 'qr'"
class="qr-img"
:src="qrImages[`${pageIdx}-${cellNum}-${miniIdx}`] || ''"
/>
<div v-else class="barcode-container">
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
</div>
<div class="serial-text">{{ miniItem.device_id }}</div>
</template>
</div>
</div>
<div v-else class="empty-cell-text">Empty</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { usbApi } from '../../api'
import JsBarcode from 'jsbarcode'
import QRCode from 'qrcode'
import { getSetting } from '@/utils/siteSettings'
import { buildQrUrl } from '@/utils/qrTarget'
const MINI_LABELS_PER_CELL = 12
const CELLS_PER_PAGE = 6
const devices = ref([])
const selectedDevices = ref([])
const loadingDevices = ref(true)
const startCell = ref('1')
const barcodeRefs = ref({})
// usb_label_style setting: 'barcode' (CODE128 of the serial) or 'qr' (QR code
// linking to the qr_target_usb target, default = the device page).
const labelStyle = ref('barcode')
// QR data-URL images keyed like barcodeRefs. Images print reliably; live
// canvases do not.
const qrImages = ref({})
const pageCount = computed(() => {
if (selectedDevices.value.length === 0) return 0
const skippedCells = parseInt(startCell.value) - 1
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
const totalCellsNeeded = numCells + skippedCells
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
})
const sheetPages = computed(() => {
const result = []
if (selectedDevices.value.length === 0) return result
const startCellNum = parseInt(startCell.value)
let usbIdx = 0
for (let page = 0; page < pageCount.value; page++) {
const cells = []
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
const skipThisCell = page === 0 && cellNum < startCellNum
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
const items = []
if (hasContent) {
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
if (usbIdx < selectedDevices.value.length) {
items.push(selectedDevices.value[usbIdx])
usbIdx++
} else {
items.push(null)
}
}
}
cells.push({ hasContent, items })
}
result.push(cells)
}
return result
})
onMounted(async () => {
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
try {
const response = await usbApi.list({ perpage: 500 })
devices.value = response.data.data || []
} catch (error) {
console.error('Error loading USB devices:', error)
} finally {
loadingDevices.value = false
}
})
watch([selectedDevices, startCell], async () => {
await nextTick()
if (labelStyle.value === 'qr') {
await generateQrImages()
} else {
generateBarcodes()
}
}, { deep: true })
async function generateQrImages() {
const next = {}
for (let pageIdx = 0; pageIdx < sheetPages.value.length; pageIdx++) {
const page = sheetPages.value[pageIdx]
for (let cellIdx = 0; cellIdx < page.length; cellIdx++) {
const cell = page[cellIdx]
if (!cell.hasContent) continue
for (let miniIdx = 0; miniIdx < cell.items.length; miniIdx++) {
const item = cell.items[miniIdx]
if (!item) continue
try {
const url = await buildQrUrl('qr_target_usb', `/usb/${encodeURIComponent(item.device_id)}`, {
id: item.device_id || '',
serialnumber: item.device_id || '',
alias: item.device_desc || '',
})
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] = await QRCode.toDataURL(url, {
margin: 0,
width: 150,
errorCorrectionLevel: 'M',
})
} catch (e) {
console.error('QR error:', item.device_id, e)
}
}
}
}
qrImages.value = next
}
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
if (el) {
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
}
}
function generateBarcodes() {
sheetPages.value.forEach((page, pageIdx) => {
page.forEach((cell, cellIdx) => {
if (!cell.hasContent) return
const cellNum = cellIdx + 1
cell.items.forEach((item, miniIdx) => {
if (!item) return
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
if (!el) return
try {
JsBarcode(el, item.device_id, {
format: 'CODE128',
width: 1,
height: 22,
displayValue: false,
margin: 0,
background: 'transparent'
})
} catch (e) {
console.error('Barcode error:', item.device_id, e)
}
})
})
})
}
function isSelected(device) {
return selectedDevices.value.some(d => d.device_id === device.device_id)
}
function toggleDevice(device) {
const idx = selectedDevices.value.findIndex(d => d.device_id === device.device_id)
if (idx > -1) {
selectedDevices.value.splice(idx, 1)
} else {
selectedDevices.value.push(device)
}
}
function clearSelection() {
selectedDevices.value = []
}
function selectAll() {
selectedDevices.value = [...devices.value]
}
function print() {
window.print()
}
</script>
<style scoped>
@page { size: letter; margin: 0; }
.no-print { margin-bottom: 20px; padding: 20px; }
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
.controls h3 { margin-top: 0; }
.print-btn {
padding: 10px 30px;
font-size: 16px;
cursor: pointer;
background: var(--primary);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
.clear-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--danger);
color: white;
border: none;
border-radius: 5px;
margin-right: 10px;
}
.select-all-btn {
padding: 10px 20px;
font-size: 14px;
cursor: pointer;
background: var(--success);
color: white;
border: none;
border-radius: 5px;
}
.usb-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 10px;
max-height: 300px;
overflow-y: auto;
border: 1px solid var(--border);
padding: 10px;
background: var(--bg);
}
.usb-item {
display: flex;
align-items: center;
padding: 8px;
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
}
.usb-item:hover { border-color: var(--primary); }
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
.usb-item input { margin-right: 10px; }
.usb-item label { cursor: pointer; flex: 1; }
.usb-item .alias { font-size: 11px; color: var(--text-light); }
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
.selected-count .count { color: var(--primary); }
.selected-count .pages { color: var(--success); }
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
.print-sheet {
width: 8.5in;
height: 11in;
background: white;
margin: 0 auto;
position: relative;
border: 1px solid #ccc;
page-break-after: always;
}
.print-sheet:last-child { page-break-after: auto; }
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
.label-cell {
width: 3in;
height: 3in;
position: absolute;
box-sizing: border-box;
border: 1px dashed #ccc;
overflow: hidden;
}
.label-cell.has-content { border: 1px solid var(--primary); }
.label-cell.empty { background: #fafafa; }
.cell-1 { top: 0.875in; left: 1.1875in; }
.cell-2 { top: 0.875in; left: 4.3125in; }
.cell-3 { top: 4in; left: 1.1875in; }
.cell-4 { top: 4in; left: 4.3125in; }
.cell-5 { top: 7.125in; left: 1.1875in; }
.cell-6 { top: 7.125in; left: 4.3125in; }
.mini-grid {
display: grid;
grid-template-columns: repeat(3, 1in);
grid-template-rows: repeat(4, 0.75in);
width: 3in;
height: 3in;
}
.mini-label {
width: 1in;
height: 0.75in;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-sizing: border-box;
padding: 0.02in;
border: 1px dotted #ddd;
overflow: hidden;
}
.mini-label.filled { border: 1px solid #999; }
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
.barcode-container { text-align: center; line-height: 0; }
.barcode-container svg { max-width: 0.9in; height: 24px; }
.qr-img { width: 0.48in; height: 0.48in; }
.serial-text {
font-size: 6pt;
font-weight: bold;
font-family: monospace;
text-align: center;
margin-top: 1px;
letter-spacing: 0.3px;
}
.empty-cell-text {
color: #ccc;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
@media print {
/* Force the barcodes/borders to print even when "Background graphics" is
off. */
body, .print-sheet, .label-cell, .mini-label, .barcode-container {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body { padding: 0; margin: 0; background: white; }
.no-print { display: none !important; }
.sheets-container { gap: 0; }
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
.sheet-label { display: none; }
.label-cell { border: none !important; }
.label-cell.empty { visibility: hidden; }
.mini-label { border: 1px dotted #ccc !important; }
.mini-label.empty { visibility: hidden; }
}
</style>

View File

@@ -2,18 +2,26 @@
// Both PrinterQRBatch and PrinterQRSingle render each QR to a data-URL image
// (canvases print unreliably) with the GE monogram composited in the center.
import QRCode from 'qrcode'
import { getQrLogo } from '@/utils/siteSettings'
const GE_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
let logoImage = null
function loadLogo() {
// Load the configured QR overlay image. On any load failure, fall back to the
// built-in GE monogram (last-resort constant above).
function loadLogo(url) {
if (logoImage) return Promise.resolve(logoImage)
return new Promise(resolve => {
const img = new Image()
img.onload = () => { logoImage = img; resolve(img) }
img.onerror = () => resolve(null)
img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG)
img.onerror = () => {
const fallback = new Image()
fallback.onload = () => { logoImage = fallback; resolve(fallback) }
fallback.onerror = () => resolve(null)
fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG)
}
img.src = url
})
}
@@ -31,18 +39,21 @@ function drawLogoOverlay(canvas, logo) {
canvasContext.fill()
if (logo) {
canvasContext.drawImage(logo, 0, 0, 32.5, 32, x, y, logoSize, logoSize)
canvasContext.drawImage(logo, x, y, logoSize, logoSize)
}
}
// Render a QR for `url` to a PNG data URL with the GE monogram composited in.
// Returns the data URL string, or '' on failure.
// Render a QR for `url` to a PNG data URL with the configured logo composited
// in the center. Empty qr_logo setting = no overlay. Returns '' on failure.
export async function renderQrDataUrl(url) {
const logo = await loadLogo()
const qrLogoUrl = await getQrLogo()
const canvas = document.createElement('canvas')
try {
await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' })
drawLogoOverlay(canvas, logo)
if (qrLogoUrl) {
const logo = await loadLogo(qrLogoUrl)
drawLogoOverlay(canvas, logo)
}
return canvas.toDataURL('image/png')
} catch (err) {
console.error('QR error:', err)

View File

@@ -299,6 +299,7 @@ import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
import { apiError } from '../../utils/apiError'
import { getPrinterHostnameTemplate } from '../../utils/siteSettings'
const { isEnabled } = useIdentifierFlags()
@@ -385,11 +386,13 @@ function getModelShortDesc(modelNumber) {
return match ? match[1] : modelNumber.substring(0, 5)
}
// Auto-generate hostname from IP address
function generateHostname(ip) {
// Auto-generate hostname from IP address using the site template ({ip} =
// dash-separated IP).
async function generateHostname(ip) {
if (!ip) return ''
const ipDashed = ip.replace(/\./g, '-')
return `Printer-${ipDashed}.printer.geaerospace.net`
const template = await getPrinterHostnameTemplate()
return template.replace('{ip}', ipDashed)
}
// Auto-generate Windows name (machinenumber)
@@ -431,9 +434,9 @@ function generateWindowsName() {
}
// Watch IP address and auto-generate hostname
watch(() => form.value.ipaddress, (newIp) => {
watch(() => form.value.ipaddress, async (newIp) => {
if (!manualHostname.value && newIp) {
form.value.hostname = generateHostname(newIp)
form.value.hostname = await generateHostname(newIp)
}
})

View File

@@ -10,8 +10,8 @@
<div v-else class="form-grid">
<label v-for="s in items" :key="s.key" class="field">
<span>{{ prettyLabel(s.key) }}</span>
<input v-model="s.value" type="text" :placeholder="s.description" />
<small class="muted">{{ s.description }}</small>
<input v-model="s.value" type="text" :placeholder="fieldHelp(s.key) ? '' : s.description" />
<small class="muted">{{ fieldHelp(s.key) || s.description }}</small>
</label>
<div v-if="!items.length" class="muted">No site settings found.</div>
@@ -42,12 +42,23 @@ const error = ref('')
const LABELS = {
site_base_url: 'Site URL / FQDN',
facility_name: 'Facility Name',
pc_access_domain: 'PC Access Domain'
pc_access_domain: 'PC Access Domain',
employeeid_pattern: 'Employee ID Pattern',
printer_hostname_template: 'Printer Hostname Template'
}
function prettyLabel(key) {
return LABELS[key] || key
}
// Inline help for site fields that need more than the stored description.
const HELP = {
employeeid_pattern: 'Regular expression that a scanned/typed employee ID must match to be recognized. Default: ^\\d{9}$ (9 digits). An invalid regex is ignored and the default is used.',
printer_hostname_template: 'Template for generating printer hostnames from an IP. Use {ip} where the dash-separated IP goes. Example: Printer-{ip}.printer.geaerospace.net'
}
function fieldHelp(key) {
return HELP[key] || ''
}
async function load() {
loading.value = true
try {

View File

@@ -157,6 +157,198 @@
<span>{{ dellMessage }}</span>
</div>
</div>
<div class="setting-group">
<h3>ServiceNow</h3>
<p class="setting-description">
Wire global search and ticket links to your ServiceNow instance. When
enabled, matching ticket numbers become clickable links and can trigger
a smart-redirect from global search. Defaults ship for GE; change them
for your site.
</p>
<div class="setting-row">
<label class="toggle-label">
<span>Enable ServiceNow</span>
<button
class="toggle-btn"
:class="{ active: settings.servicenow_enabled }"
@click="toggleSetting('servicenow_enabled')"
:disabled="saving"
>
<span class="toggle-slider"></span>
</button>
</label>
</div>
<template v-if="settings.servicenow_enabled">
<div class="setting-row">
<label>
<span>Search URL</span>
<input
type="url"
v-model="settings.servicenow_search_url"
placeholder="https://geaerospaceqa.service-now.com/now/nav/ui/search/.../{ticket}/..."
@blur="saveSetting('servicenow_search_url', settings.servicenow_search_url)"
:disabled="saving"
>
<small class="input-hint">Global-search redirect target. Use {ticket} where the ticket number goes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Ticket Prefixes</span>
<input
type="text"
v-model="settings.servicenow_ticket_prefixes"
placeholder="GEINC,GECHG,GERIT,GESCT"
@blur="saveSetting('servicenow_ticket_prefixes', settings.servicenow_ticket_prefixes)"
:disabled="saving"
>
<small class="input-hint">Comma-separated ticket-number prefixes that this site recognizes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Incident URL</span>
<input
type="url"
v-model="settings.servicenow_incident_url"
placeholder="(blank = plain text; use {ticket} in a URL template)"
@blur="saveSetting('servicenow_incident_url', settings.servicenow_incident_url)"
:disabled="saving"
>
<small class="input-hint">Link template for incident tickets. Use {ticket} where the ticket number goes.</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Change URL</span>
<input
type="url"
v-model="settings.servicenow_change_url"
placeholder="(blank = plain text; use {ticket} in a URL template)"
@blur="saveSetting('servicenow_change_url', settings.servicenow_change_url)"
:disabled="saving"
>
<small class="input-hint">Link template for change tickets. Use {ticket} where the ticket number goes.</small>
</label>
</div>
</template>
</div>
</div>
<!-- Branding Section -->
<div class="section-card" v-show="isVisible('branding')">
<h2 class="section-title">Branding</h2>
<div class="setting-group">
<p class="setting-description">
Replace the shipped GE logos with your own site branding. Each logo can
be uploaded, or set to a path/URL directly. Leave blank to use the
bundled default.
</p>
<div class="setting-row" v-for="logo in brandingLogos" :key="logo.kind">
<label>
<span>{{ logo.label }}</span>
<input
type="text"
v-model="settings[logo.key]"
:placeholder="logo.placeholder"
@blur="saveSetting(logo.key, settings[logo.key])"
:disabled="saving"
>
<div class="map-upload-row">
<input type="file" :accept="logo.accept" @change="uploadLogo(logo.kind, logo.key, $event)" :disabled="brandingUploading" />
<img v-if="settings[logo.key]" :src="settings[logo.key]" class="map-thumb" :alt="logo.label" />
</div>
<small class="input-hint">{{ logo.hint }}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Primary brand color</span>
<div class="color-input-row">
<input
type="color"
:value="settings.brand_primary_color || '#000000'"
@input="settings.brand_primary_color = $event.target.value"
@change="saveSetting('brand_primary_color', settings.brand_primary_color)"
:disabled="saving"
>
<input
type="text"
v-model="settings.brand_primary_color"
placeholder="(blank = built-in)"
@blur="saveSetting('brand_primary_color', settings.brand_primary_color)"
:disabled="saving"
>
</div>
<small class="input-hint">Hex color for the primary accent. Leave blank to use the built-in palette.</small>
</label>
</div>
</div>
</div>
<!-- Printing & Labels Section -->
<div class="section-card" v-show="isVisible('printing')">
<h2 class="section-title">Printing &amp; Labels</h2>
<div class="setting-group">
<p class="setting-description">
Where printed QR codes point. Leave a target blank to link to the
asset's own page on this instance, or enter a custom URL template
with {placeholder} substitution.
</p>
<div class="setting-row">
<label>
<span>Printer QR target</span>
<input
type="text"
v-model="settings.qr_target_printer"
placeholder="(blank = printer page)"
@blur="saveSetting('qr_target_printer', settings.qr_target_printer)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {printerid}, {assetid}, {assetnumber}, {serialnumber}, {ip}, {hostname}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>USB label QR target</span>
<input
type="text"
v-model="settings.qr_target_usb"
placeholder="(blank = USB device page)"
@blur="saveSetting('qr_target_usb', settings.qr_target_usb)"
:disabled="saving"
>
<small class="input-hint">Placeholders: {id}, {serialnumber}, {alias}</small>
</label>
</div>
<div class="setting-row">
<label>
<span>USB label style</span>
<select
v-model="settings.usb_label_style"
@change="saveSetting('usb_label_style', settings.usb_label_style)"
:disabled="saving"
>
<option value="barcode">Barcode (CODE128 of the serial number)</option>
<option value="qr">QR code (links to the USB label QR target)</option>
</select>
<small class="input-hint">Applies to the batch USB mini-label print sheet.</small>
</label>
</div>
</div>
</div>
<!-- Email Section -->
@@ -670,7 +862,9 @@ import { apiError } from '../../utils/apiError'
// Section tabs - one section visible at a time to avoid a long scroll. The
// search box filters tabs (and, while searching, shows every matching section).
const SETTINGS_TABS = [
{ key: 'integrations', label: 'Integrations', keywords: 'zabbix toner supply printer monitoring api integration' },
{ key: 'integrations', label: 'Integrations', keywords: 'zabbix toner supply printer monitoring api integration servicenow ticket incident change dell warranty' },
{ key: 'branding', label: 'Branding', keywords: 'branding logo site qr badge favicon color brand primary theme image' },
{ key: 'printing', label: 'Printing & Labels', keywords: 'printing qr label barcode usb printer target url template sticker' },
{ key: 'email', label: 'Email / SMTP', keywords: 'email smtp mail notifications alerts tls from recipients' },
{ key: 'audit', label: 'Audit & Logging', keywords: 'audit log retention history' },
{ key: 'auth', label: 'Authentication', keywords: 'auth saml sso login users idp' },
@@ -719,6 +913,22 @@ const settings = reactive({
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: '',
// Printing and labels
qr_target_printer: '',
qr_target_usb: '',
usb_label_style: 'barcode',
// Email
smtp_enabled: false,
smtp_host: '',
@@ -746,6 +956,23 @@ const settings = reactive({
saml_admin_group: ''
})
// Branding logo upload widgets. kind maps to the backend endpoint; key is the
// setting the resulting URL is stored under.
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: 'Equipment badge logo', accept: 'image/*',
placeholder: '/ge-aerospace-logo.svg',
hint: 'Logo printed on equipment 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).
const identifierRows = [
@@ -797,6 +1024,7 @@ 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 testingEmail = ref(false)
const error = ref('')
const success = ref('')
@@ -938,6 +1166,26 @@ async function uploadBlueprint(theme, event) {
}
}
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 = ''
}
}
async function toggleSetting(key) {
const newValue = !settings[key]
await saveSetting(key, newValue)
@@ -1349,6 +1597,9 @@ onMounted(loadSettings)
.identifier-matrix .identifier-name {
color: var(--text);
}
.color-input-row { display: flex; align-items: center; gap: 0.75rem; }
.color-input-row input[type="color"] { width: 48px; height: 34px; padding: 2px; cursor: pointer; }
.color-input-row input[type="text"] { max-width: 160px; }
.map-upload-row { display: flex; align-items: center; gap: 0.75rem; margin-top: 0.4rem; }
.map-thumb { height: 40px; border: 1px solid var(--border); border-radius: 4px; background: #fff; }
.map-thumb-dark { background: #222; }

View File

@@ -7,8 +7,9 @@ export const settingsGroups = [
{
title: 'Site & Facility',
cards: [
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, and PC access domain' },
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, PC access domain, employee-ID pattern, printer hostname template' },
{ to: '/settings/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' },
{ to: '/settings/system?tab=branding', icon: Palette, title: 'Branding', description: 'Site, QR, and badge logos, favicon, and primary brand color' },
],
},
{
@@ -72,7 +73,7 @@ export const settingsGroups = [
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations (ServiceNow, Zabbix), branding, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},