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:
9
frontend/src/utils/apiError.js
Normal file
9
frontend/src/utils/apiError.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Extract a human-readable message from an API error. The backend nests the
|
||||
// message at data.data.error.message (see error_response); older call sites read
|
||||
// shallower paths, so try them all before falling back.
|
||||
export function apiError(err, fallback = 'Something went wrong') {
|
||||
return err?.response?.data?.data?.error?.message
|
||||
|| err?.response?.data?.error?.message
|
||||
|| err?.response?.data?.message
|
||||
|| fallback
|
||||
}
|
||||
63
frontend/src/utils/assetTypes.js
Normal file
63
frontend/src/utils/assetTypes.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Single source of truth for asset-type display labels + detail routing.
|
||||
// The map and shopfloor views used to each hardcode these identical maps.
|
||||
// Keys are lowercase; both 'network_device' and 'network device' are accepted
|
||||
// because the API sends the underscore form and some map data the spaced form.
|
||||
|
||||
const ASSET_TYPE_LABELS = {
|
||||
'machine': 'Machines',
|
||||
'computer': 'Computers',
|
||||
'printer': 'Printers',
|
||||
'network_device': 'Network Devices',
|
||||
'network device': 'Network Devices',
|
||||
'measuring_tool': 'Measuring Tools',
|
||||
}
|
||||
|
||||
const ASSET_TYPE_ROUTES = {
|
||||
'machine': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'network device': '/network',
|
||||
'measuring_tool': '/measuringtools',
|
||||
}
|
||||
|
||||
// Plugin-specific id field inside asset.typedata for each asset type.
|
||||
const ASSET_TYPE_ID_KEYS = {
|
||||
'machine': 'machineid',
|
||||
'computer': 'computerid',
|
||||
'printer': 'printerid',
|
||||
'network_device': 'networkdeviceid',
|
||||
'network device': 'networkdeviceid',
|
||||
'measuring_tool': 'measuringtoolid',
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
return String(text || '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
// Plural, human-friendly label for an asset type. Falls back to title-cased
|
||||
// input so an unknown/new type still reads sensibly.
|
||||
export function assetTypeLabel(type) {
|
||||
if (!type) return type
|
||||
return ASSET_TYPE_LABELS[String(type).toLowerCase()] || titleCase(type)
|
||||
}
|
||||
|
||||
// Base list route for an asset type (e.g. 'computer' -> '/pcs').
|
||||
export function assetTypeRoute(type) {
|
||||
return ASSET_TYPE_ROUTES[String(type || '').toLowerCase()] || '/machines'
|
||||
}
|
||||
|
||||
// Full detail route for a unified-format asset, preferring the plugin-specific
|
||||
// id in typedata and falling back to the asset id.
|
||||
export function assetDetailRoute(asset) {
|
||||
const type = (asset.assettype || '').toLowerCase()
|
||||
const base = assetTypeRoute(type)
|
||||
const idKey = ASSET_TYPE_ID_KEYS[type]
|
||||
let id = asset.assetid
|
||||
if (asset.typedata && idKey && asset.typedata[idKey]) {
|
||||
id = asset.typedata[idKey]
|
||||
}
|
||||
return `${base}/${id}`
|
||||
}
|
||||
13
frontend/src/utils/basePath.js
Normal file
13
frontend/src/utils/basePath.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Single source for the app's mount path. Vite injects import.meta.env.BASE_URL
|
||||
// from the build-time `base` (default '/', or e.g. '/ops/' for a subpath IIS
|
||||
// mount). Every root-absolute URL to a Flask-served asset or route must go
|
||||
// through withBase() so it resolves under the mount instead of the server root.
|
||||
export const BASE_URL = import.meta.env.BASE_URL
|
||||
|
||||
// Prefix a root-absolute path (e.g. '/ge-aerospace-logo.svg', '/api', '/tv')
|
||||
// with the mount base. Leaves full URLs (http, data:) untouched.
|
||||
export function withBase(path) {
|
||||
if (!path) return path
|
||||
if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path
|
||||
return BASE_URL + String(path).replace(/^\//, '')
|
||||
}
|
||||
39
frontend/src/utils/colorStyle.js
Normal file
39
frontend/src/utils/colorStyle.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Data-driven badge colors. A record stores a hex color; the UI renders it with
|
||||
// an auto-picked readable text color, so any color stays legible. Pickers should
|
||||
// offer PALETTE (curated, distinct, accessible) with custom hex as a fallback.
|
||||
|
||||
// Curated categorical palette - distinct + accessible. ~12 is the practical
|
||||
// ceiling for at-a-glance distinguishability, which is plenty per category.
|
||||
export const PALETTE = [
|
||||
'#f5365c', // red
|
||||
'#fb6340', // orange
|
||||
'#ff8800', // amber
|
||||
'#ffc107', // gold
|
||||
'#2dce89', // green
|
||||
'#04b962', // emerald
|
||||
'#11cdef', // cyan
|
||||
'#14abef', // blue
|
||||
'#0d6efd', // royal blue
|
||||
'#7934f3', // purple
|
||||
'#e83e8c', // pink
|
||||
'#6c757d', // gray
|
||||
]
|
||||
|
||||
// Black or white text for a given background, by perceived brightness.
|
||||
export function readableText(bg) {
|
||||
if (!bg || typeof bg !== 'string') return '#ffffff'
|
||||
let hex = bg.trim().replace('#', '')
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('')
|
||||
if (hex.length !== 6) return '#ffffff'
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return brightness > 0.6 ? '#1a1a1a' : '#ffffff'
|
||||
}
|
||||
|
||||
// Style object for a badge/pill from a stored color (with a neutral fallback).
|
||||
export function colorStyle(color, fallback = '#6c757d') {
|
||||
const backgroundColor = color || fallback
|
||||
return { backgroundColor, color: readableText(backgroundColor) }
|
||||
}
|
||||
54
frontend/src/utils/mapColors.js
Normal file
54
frontend/src/utils/mapColors.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Marker color logic for the shop-floor map, shared so the PDF export renders
|
||||
// the exact same colors the map shows on screen. Mirrors the maps defined in
|
||||
// ShopFloorMap.vue - keep the two in sync if the palette changes.
|
||||
|
||||
export const assetTypeColorsMap = {
|
||||
machine: '#F44336', // Red
|
||||
computer: '#2196F3', // Blue
|
||||
printer: '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
network_device: '#FF9800', // Orange (alternate key)
|
||||
measuring_tool: '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = '#BDBDBD'
|
||||
|
||||
// Canonical form for comparing asset-type strings. The API sends the machine
|
||||
// type as 'network_device' but subtype keys and labels use 'network device',
|
||||
// so normalize underscores to spaces before any comparison. Without this,
|
||||
// network-device subtypes silently fail to match (machine/computer/printer
|
||||
// are single words and were unaffected, which is why only network broke).
|
||||
export function normalizeAssetType(assettype) {
|
||||
return (assettype || '').toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
// Color for an asset by its top-level asset type (used when no type filter is
|
||||
// active, so every type is distinguished by color).
|
||||
export function getAssetTypeColor(assettype) {
|
||||
if (!assettype) return DEFAULT_COLOR
|
||||
return assetTypeColorsMap[assettype.toLowerCase()] || DEFAULT_COLOR
|
||||
}
|
||||
|
||||
// The subtype id for an asset, read from the plugin-specific typedata block.
|
||||
// Returns null when the asset has no subtype.
|
||||
export function getSubtypeId(asset) {
|
||||
if (!asset || !asset.typedata) return null
|
||||
const typeLower = normalizeAssetType(asset.assettype)
|
||||
if (typeLower === 'machine') return asset.typedata.machinetypeid
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve the marker color for an asset the same way ShopFloorMap does: when a
|
||||
// type is selected, color by subtype (grey fallback); otherwise by asset type.
|
||||
export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) {
|
||||
if (selectedType) {
|
||||
const id = getSubtypeId(asset)
|
||||
return (id != null && subtypeColors[id]) || DEFAULT_COLOR
|
||||
}
|
||||
return getAssetTypeColor(asset.assettype)
|
||||
}
|
||||
135
frontend/src/utils/mapPdf.js
Normal file
135
frontend/src/utils/mapPdf.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// Export the shop-floor map (current filtered assets on the facility blueprint)
|
||||
// to a PDF, entirely client-side. The blueprint image is drawn full-size and
|
||||
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
|
||||
// vector-over-raster page rather than a screen capture.
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors'
|
||||
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('Failed to load blueprint image: ' + url))
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = (hex || '#BDBDBD').replace('#', '')
|
||||
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
||||
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
// Build the legend entries (color + label) for the assets present, matching the
|
||||
// active coloring mode.
|
||||
function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) {
|
||||
const seen = new Map()
|
||||
for (const a of assets) {
|
||||
let key, label, color
|
||||
if (selectedType) {
|
||||
const id = getSubtypeId(a)
|
||||
key = id != null ? String(id) : 'none'
|
||||
label = (id != null && subtypeNames[id]) || 'Unspecified'
|
||||
color = (id != null && subtypeColors[id]) || '#BDBDBD'
|
||||
} else {
|
||||
key = (a.assettype || 'unknown').toLowerCase()
|
||||
label = a.assettype || 'Unknown'
|
||||
color = getAssetTypeColor(a.assettype)
|
||||
}
|
||||
if (!seen.has(key)) seen.set(key, { label, color, count: 0 })
|
||||
seen.get(key).count += 1
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
export async function exportMapPdf(opts) {
|
||||
const {
|
||||
assets = [],
|
||||
blueprintUrl,
|
||||
mapWidth = 3300,
|
||||
mapHeight = 2550,
|
||||
selectedType = '',
|
||||
subtypeColors = {},
|
||||
subtypeNames = {},
|
||||
filters = [],
|
||||
facility = '',
|
||||
title = 'Shop Floor Map'
|
||||
} = opts
|
||||
|
||||
const img = await loadImage(blueprintUrl)
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true })
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const pageH = doc.internal.pageSize.getHeight()
|
||||
const margin = 28
|
||||
|
||||
// ---- Header ----
|
||||
doc.setTextColor('#111111')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(16)
|
||||
doc.text(title, margin, margin + 6)
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor('#555555')
|
||||
const stamp = new Date().toLocaleString()
|
||||
const subParts = [facility, stamp, `${assets.length} asset${assets.length === 1 ? '' : 's'}`].filter(Boolean)
|
||||
doc.text(subParts.join(' | '), margin, margin + 22)
|
||||
|
||||
const filterText = filters.length ? 'Filters: ' + filters.join(' ') : 'Filters: none (all assets)'
|
||||
doc.text(filterText, margin, margin + 35)
|
||||
|
||||
// ---- Legend (wraps under the header, pushes the map down) ----
|
||||
const legend = buildLegend(assets, { selectedType, subtypeColors, subtypeNames })
|
||||
const swatch = 8
|
||||
const legendY0 = margin + 50
|
||||
const lineH = 15
|
||||
let lx = margin
|
||||
let ly = legendY0
|
||||
doc.setFontSize(8.5)
|
||||
for (const entry of legend) {
|
||||
const label = `${entry.label} (${entry.count})`
|
||||
const w = swatch + 4 + doc.getTextWidth(label) + 16
|
||||
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
|
||||
const [r, g, b] = hexToRgb(entry.color)
|
||||
doc.setFillColor(r, g, b)
|
||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4)
|
||||
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F')
|
||||
doc.setTextColor('#333333')
|
||||
doc.text(label, lx + swatch + 4, ly)
|
||||
lx += w
|
||||
}
|
||||
const legendBottom = legend.length ? ly + 6 : legendY0
|
||||
|
||||
// ---- Blueprint image, fit into the area below the legend ----
|
||||
const aspect = mapWidth / mapHeight
|
||||
const contentTop = legendBottom + 8
|
||||
const availW = pageW - margin * 2
|
||||
const availH = pageH - contentTop - margin
|
||||
let imgW = availW
|
||||
let imgH = availW / aspect
|
||||
if (imgH > availH) { imgH = availH; imgW = availH * aspect }
|
||||
const imgX = margin + (availW - imgW) / 2
|
||||
const imgY = contentTop + (availH - imgH) / 2
|
||||
|
||||
doc.addImage(img, 'PNG', imgX, imgY, imgW, imgH, undefined, 'FAST')
|
||||
doc.setDrawColor('#cccccc'); doc.setLineWidth(0.5)
|
||||
doc.rect(imgX, imgY, imgW, imgH)
|
||||
|
||||
// ---- Markers (database Y is top-down, same origin as the drawn image) ----
|
||||
const radius = 3.2
|
||||
for (const a of assets) {
|
||||
if (a.mapx == null || a.mapy == null) continue
|
||||
const x = imgX + (a.mapx / mapWidth) * imgW
|
||||
const y = imgY + (a.mapy / mapHeight) * imgH
|
||||
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
|
||||
const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors }))
|
||||
doc.setFillColor(r, g, b)
|
||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6)
|
||||
doc.circle(x, y, radius, 'FD')
|
||||
}
|
||||
|
||||
const dateSlug = new Date().toISOString().slice(0, 10)
|
||||
doc.save(`shopfloor-map-${dateSlug}.pdf`)
|
||||
}
|
||||
26
frontend/src/utils/qrTarget.js
Normal file
26
frontend/src/utils/qrTarget.js
Normal 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)
|
||||
}
|
||||
169
frontend/src/utils/siteSettings.js
Normal file
169
frontend/src/utils/siteSettings.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// Shared read-through for public site settings (site_base_url, facility_name).
|
||||
// The settings GET is public (jwt optional), so the kiosk dashboard and the
|
||||
// print views can read these without auth. Fetched once and cached per page.
|
||||
import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
let settingsCache = null
|
||||
|
||||
async function loadSettings() {
|
||||
if (settingsCache) return settingsCache
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
const items = response.data?.data || response.data || []
|
||||
settingsCache = {}
|
||||
for (const s of items) settingsCache[s.key] = s.value
|
||||
} catch (err) {
|
||||
console.error('Error loading site settings:', err)
|
||||
settingsCache = {}
|
||||
}
|
||||
return settingsCache
|
||||
}
|
||||
|
||||
export async function getSetting(key, fallback = '') {
|
||||
const settings = await loadSettings()
|
||||
const value = settings[key]
|
||||
return (value === undefined || value === null || value === '') ? fallback : value
|
||||
}
|
||||
|
||||
// Public base URL for QR codes / absolute links. Falls back to the current
|
||||
// browsing origin when a site has not set one.
|
||||
export async function getSiteBaseUrl() {
|
||||
return getSetting('site_base_url', window.location.origin)
|
||||
}
|
||||
|
||||
// Facility name shown on the shopfloor dashboard.
|
||||
export async function getFacilityName() {
|
||||
return getSetting('facility_name', 'ShopDB')
|
||||
}
|
||||
|
||||
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
|
||||
export async function getSiteLogo() {
|
||||
return withBase(await 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 withBase((value === undefined || value === null) ? '/ge-monogram.svg' : value)
|
||||
}
|
||||
|
||||
// Logo printed on machine inspection badges.
|
||||
export async function getBadgeLogo() {
|
||||
return withBase(await getSetting('badge_logo', '/ge-aerospace-logo.svg'))
|
||||
}
|
||||
|
||||
// Browser-tab favicon. Empty = keep the shipped /favicon.svg.
|
||||
export async function getFavicon() {
|
||||
const value = await getSetting('site_favicon', '')
|
||||
return value ? withBase(value) : value
|
||||
}
|
||||
|
||||
// Brand primary color override. Empty = built-in palette from style.css.
|
||||
export async function getBrandPrimaryColor() {
|
||||
return getSetting('brand_primary_color', '')
|
||||
}
|
||||
|
||||
// Primary hover/active color (--primary-dark). Empty = derive from primary.
|
||||
export async function getBrandPrimaryDarkColor() {
|
||||
return getSetting('brand_primary_dark_color', '')
|
||||
}
|
||||
|
||||
// Accent color (--secondary). Empty = built-in palette from style.css.
|
||||
export async function getBrandAccentColor() {
|
||||
return getSetting('brand_accent_color', '')
|
||||
}
|
||||
|
||||
// Sidebar background color (--sidebar-bg). Empty = built-in palette.
|
||||
export async function getBrandSidebarColor() {
|
||||
return getSetting('brand_sidebar_color', '')
|
||||
}
|
||||
|
||||
// Darken a #rrggbb hex color by the given fraction (0.15 = 15% darker).
|
||||
// Returns null for anything that is not a 6-digit hex so callers can skip it.
|
||||
function darkenHexColor(hex, fraction) {
|
||||
const match = /^#([0-9a-fA-F]{6})$/.exec(hex)
|
||||
if (!match) return null
|
||||
const num = parseInt(match[1], 16)
|
||||
const scale = 1 - fraction
|
||||
const red = Math.round(((num >> 16) & 0xff) * scale)
|
||||
const green = Math.round(((num >> 8) & 0xff) * scale)
|
||||
const blue = Math.round((num & 0xff) * scale)
|
||||
const toHex = (channel) => channel.toString(16).padStart(2, '0')
|
||||
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`
|
||||
}
|
||||
|
||||
// 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')
|
||||
}
|
||||
|
||||
// Email domain appended to a contact SSO to build email/Teams links
|
||||
// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value
|
||||
// means "disable contact action buttons", so read the raw setting rather than
|
||||
// getSetting (which swaps '' for the fallback).
|
||||
export async function getContactEmailDomain() {
|
||||
const settings = await loadSettings()
|
||||
const value = settings['contact_email_domain']
|
||||
return (value === undefined || value === null) ? 'geaerospace.com' : value
|
||||
}
|
||||
|
||||
// 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 rootStyle = document.documentElement.style
|
||||
const primaryColor = await getBrandPrimaryColor()
|
||||
if (primaryColor) {
|
||||
rootStyle.setProperty('--primary', primaryColor)
|
||||
}
|
||||
// Hover color: use the explicit value, else darken primary ~15%.
|
||||
const primaryDarkColor = await getBrandPrimaryDarkColor()
|
||||
if (primaryDarkColor) {
|
||||
rootStyle.setProperty('--primary-dark', primaryDarkColor)
|
||||
} else if (primaryColor) {
|
||||
const derived = darkenHexColor(primaryColor, 0.15)
|
||||
if (derived) rootStyle.setProperty('--primary-dark', derived)
|
||||
}
|
||||
const accentColor = await getBrandAccentColor()
|
||||
if (accentColor) {
|
||||
rootStyle.setProperty('--secondary', accentColor)
|
||||
}
|
||||
const sidebarColor = await getBrandSidebarColor()
|
||||
if (sidebarColor) {
|
||||
rootStyle.setProperty('--sidebar-bg', sidebarColor)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user