Add custom fields + warranty plugin, rework settings into two-pane shell

Feature work from the 2026-07 session:

Settings IA
- Replace the flat 27-card settings hub with a persistent two-pane shell
  (SettingsLayout.vue): grouped, searchable left rail + content pane.
- Nest all settings/* routes under the shell via router post-processing;
  shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers,
  Equipment, Network) so per-type settings stop scattering.

Custom fields (core)
- customfields + customfieldvalues tables (migration 7d14), CRUD API at
  /api/customfields, per-asset value get/save.
- Settings management page + reusable CustomFieldsSection (detail) and
  CustomFieldsInputs (form) wired into all four asset types.

Warranty (new plugin)
- plugins/warranty: warranties + warrantyassets (migration 7d15), derived
  coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs).
- API CRUD + per-asset panel + report buckets; WarrantyPanel on all four
  detail pages; Warranties management page; Warranty report + Reports card.
- Seed warranty.* permissions.

Printer drivers
- printerdrivers table (migration 7d13) linked to printer models; drivers now
  surface on the matching printer's detail page.

Other
- PCDetail rebalanced (Network + Status + Warranty + custom fields on the right).
- Rename PCs list "Features" column to "Remote Access"; fix badge hover underline.
- Drop equipment islocationonly field.
- Centralize asset-type label/route maps into utils/assetTypes.js.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -94,6 +94,9 @@ export const equipmentApi = {
},
update(id, data) {
return api.put(`/equipment/types/${id}`, data)
},
remove(id) {
return api.delete(`/equipment/types/${id}`)
}
}
}
@@ -137,6 +140,24 @@ export const computersApi = {
},
update(id, data) {
return api.put(`/computers/types/${id}`, data)
},
remove(id) {
return api.delete(`/computers/types/${id}`)
}
},
// Remote-access protocol catalog
protocols: {
list(params = {}) {
return api.get('/computers/protocols', { params })
},
create(data) {
return api.post('/computers/protocols', data)
},
update(id, data) {
return api.put(`/computers/protocols/${id}`, data)
},
remove(id) {
return api.delete(`/computers/protocols/${id}`)
}
}
}
@@ -148,6 +169,12 @@ export const relationshipTypesApi = {
},
create(data) {
return api.post('/assets/relationshiptypes', data)
},
update(id, data) {
return api.put(`/assets/relationshiptypes/${id}`, data)
},
remove(id) {
return api.delete(`/assets/relationshiptypes/${id}`)
}
}
@@ -204,8 +231,17 @@ export const locationsApi = {
return api.delete(`/locations/${id}`)
},
types: {
list() {
return api.get('/locations/types')
list(params = {}) {
return api.get('/locations/types', { params })
},
create(data) {
return api.post('/locations/types', data)
},
update(id, data) {
return api.put(`/locations/types/${id}`, data)
},
remove(id) {
return api.delete(`/locations/types/${id}`)
}
}
}
@@ -236,6 +272,12 @@ export const printersApi = {
},
create(data) {
return api.post('/printers/types', data)
},
update(id, data) {
return api.put(`/printers/types/${id}`, data)
},
remove(id) {
return api.delete(`/printers/types/${id}`)
}
},
updateCommunication(id, data) {
@@ -260,8 +302,8 @@ export const printersApi = {
return api.get('/printers/dashboard/summary')
},
drivers: {
list() {
return api.get('/printers/drivers')
list(params = {}) {
return api.get('/printers/drivers', { params })
},
create(data) {
return api.post('/printers/drivers', data)
@@ -521,6 +563,9 @@ export const assetsApi = {
},
get(id) {
return api.get(`/assets/types/${id}`)
},
update(id, data) {
return api.put(`/assets/types/${id}`, data)
}
},
statuses: {
@@ -575,8 +620,14 @@ export const notificationsApi = {
list() {
return api.get('/notifications/types')
},
get(id) {
return api.get(`/notifications/types/${id}`)
},
create(data) {
return api.post('/notifications/types', data)
},
update(id, data) {
return api.put(`/notifications/types/${id}`, data)
}
}
}
@@ -718,6 +769,24 @@ export const settingsApi = {
}
}
// Slide manager (lobby display + shopfloor screensaver)
export const slidesApi = {
list(surface) {
return api.get(`/slides/${surface}`)
},
upload(surface, formData) {
return api.post(`/slides/${surface}/upload`, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
})
},
reorder(surface, order) {
return api.post(`/slides/${surface}/order`, { order })
},
remove(surface, files) {
return api.post(`/slides/${surface}/delete`, { files })
}
}
// Audit Logs API
export const auditLogsApi = {
list(params = {}) {
@@ -811,6 +880,9 @@ export const networkApi = {
},
update(id, data) {
return api.put(`/network/types/${id}`, data)
},
remove(id) {
return api.delete(`/network/types/${id}`)
}
},
// VLANs
@@ -850,3 +922,55 @@ export const networkApi = {
}
}
}
// Custom fields: site-defined attributes per asset type.
export const customFieldsApi = {
// Definitions
list(params = {}) {
return api.get('/customfields', { params })
},
create(data) {
return api.post('/customfields', data)
},
update(fieldid, data) {
return api.put(`/customfields/${fieldid}`, data)
},
remove(fieldid) {
return api.delete(`/customfields/${fieldid}`)
},
// Per-asset values (defs merged with the asset's stored values)
forAsset(assetid) {
return api.get(`/customfields/asset/${assetid}`)
},
saveForAsset(assetid, values) {
return api.put(`/customfields/asset/${assetid}`, { values })
}
}
// Warranty plugin: asset warranty tracking (manual + provider lookups).
export const warrantyApi = {
list(params = {}) {
return api.get('/warranty', { params })
},
get(id) {
return api.get(`/warranty/${id}`)
},
forAsset(assetid) {
return api.get(`/warranty/asset/${assetid}`)
},
create(data) {
return api.post('/warranty', data)
},
update(id, data) {
return api.put(`/warranty/${id}`, data)
},
remove(id) {
return api.delete(`/warranty/${id}`)
},
refresh(id) {
return api.post(`/warranty/${id}/refresh`)
},
report() {
return api.get('/warranty/report')
}
}

View File

@@ -1585,3 +1585,15 @@ td.actions {
}
/* Light mode is now default, dark mode via prefers-color-scheme */
/* "Show inactive" toggle in a type-settings page header */
.show-inactive {
margin-left: auto;
margin-right: 0.75rem;
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.9rem;
color: var(--text-light);
cursor: pointer;
}

View File

@@ -33,7 +33,7 @@
{{ rel.targetasset?.name || rel.targetasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.targetasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
@@ -65,7 +65,7 @@
{{ rel.sourceasset?.name || rel.sourceasset?.assetnumber || 'Unknown' }}
</router-link>
<div class="rel-meta">
<span class="badge badge-outline">{{ rel.relationshiptypename }}</span>
<span class="badge" :style="colorStyle(colorForType(rel.relationshiptypename))">{{ rel.relationshiptypename }}</span>
<span class="rel-type-badge">{{ rel.sourceasset?.assettype }}</span>
</div>
<div v-if="rel.notes" class="rel-notes">{{ rel.notes }}</div>
@@ -175,6 +175,7 @@
import { ref, computed, onMounted, watch } from 'vue'
import { Cog, Monitor, Printer, Globe, Package } from 'lucide-vue-next'
import { assetsApi, relationshipTypesApi } from '../api'
import { colorStyle } from '@/utils/colorStyle'
import { useAuthStore } from '../stores/auth'
const props = defineProps({
@@ -199,6 +200,11 @@ const lookupFailed = ref(false)
const outgoing = ref([])
const incoming = ref([])
const relationshipTypes = ref([])
function colorForType(name) {
const t = relationshipTypes.value.find(rt => rt.relationshiptype === name)
return t?.color || null
}
const showAddModal = ref(false)
// New relationship form

View File

@@ -0,0 +1,101 @@
<template>
<div class="color-swatch-picker">
<div class="swatches">
<button
v-for="c in palette"
:key="c"
type="button"
class="swatch"
:class="{ active: modelValue === c }"
:style="{ backgroundColor: c }"
:title="c"
@click="$emit('update:modelValue', c)"
></button>
</div>
<div class="custom">
<input
type="color"
:value="isHex(modelValue) ? modelValue : '#000000'"
@input="$emit('update:modelValue', $event.target.value)"
/>
<input
type="text"
:value="modelValue"
placeholder="#RRGGBB"
maxlength="20"
@input="$emit('update:modelValue', $event.target.value)"
/>
<span class="preview" :style="colorStyle(modelValue)">Aa</span>
</div>
</div>
</template>
<script setup>
import { PALETTE, colorStyle } from '@/utils/colorStyle'
defineProps({ modelValue: { type: String, default: '' } })
defineEmits(['update:modelValue'])
const palette = PALETTE
function isHex(c) {
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
}
</script>
<style scoped>
.color-swatch-picker {
display: flex;
flex-direction: column;
gap: 8px;
}
.swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.swatch {
width: 26px;
height: 26px;
border-radius: 6px;
border: 2px solid transparent;
cursor: pointer;
padding: 0;
}
.swatch.active {
border-color: var(--bg);
box-shadow: 0 0 0 2px var(--text);
}
.custom {
display: flex;
align-items: center;
gap: 8px;
}
.custom input[type="text"] {
width: 120px;
padding: 6px 8px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
font-family: monospace;
}
.custom input[type="color"] {
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--border);
border-radius: 6px;
background: none;
cursor: pointer;
}
.preview {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
border-radius: 6px;
font-weight: 700;
border: 1px solid var(--border);
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<div v-if="fields.length" class="custom-fields">
<div class="form-group" v-for="f in fields" :key="f.fieldid">
<label>{{ f.label }}</label>
<select v-if="f.datatype === 'select'" v-model="local[f.fieldid]" class="form-control">
<option value="">-- none --</option>
<option v-for="opt in f.options" :key="opt" :value="opt">{{ opt }}</option>
</select>
<label v-else-if="f.datatype === 'boolean'" class="checkbox-label">
<input type="checkbox"
:checked="local[f.fieldid] === 'true'"
@change="local[f.fieldid] = $event.target.checked ? 'true' : 'false'" />
Yes
</label>
<input v-else-if="f.datatype === 'date'" type="date" v-model="local[f.fieldid]" class="form-control" />
<input v-else-if="f.datatype === 'number'" type="number" v-model="local[f.fieldid]" class="form-control" />
<input v-else type="text" v-model="local[f.fieldid]" class="form-control" />
</div>
</div>
</template>
<script setup>
import { ref, reactive, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
// Asset type whose fields to render (needed even for a brand-new asset).
assettypeid: { type: [Number, String], default: null },
// Existing asset id, if editing - used to preload stored values.
assetid: { type: [Number, String], default: null },
})
const fields = ref([])
const local = reactive({})
async function load() {
fields.value = []
Object.keys(local).forEach(k => delete local[k])
if (!props.assettypeid) return
try {
// Definitions for this asset type (form-visible, active).
const defsResponse = await customFieldsApi.list({ assettypeid: props.assettypeid })
const defs = (defsResponse.data.data || []).filter(f => f.showonform)
fields.value = defs
// Seed blanks, then overlay stored values if editing.
for (const f of defs) local[f.fieldid] = f.datatype === 'boolean' ? 'false' : ''
if (props.assetid) {
const valResponse = await customFieldsApi.forAsset(props.assetid)
for (const f of (valResponse.data.data || [])) {
if (f.value != null) local[f.fieldid] = String(f.value)
}
}
} catch (err) {
console.error('Error loading custom field defs:', err)
}
}
// Persist current values against an asset id (parent calls after asset save).
async function save(assetid) {
if (!assetid || !fields.value.length) return
const values = {}
for (const f of fields.value) values[f.fieldid] = local[f.fieldid]
await customFieldsApi.saveForAsset(assetid, values)
}
onMounted(load)
watch(() => [props.assettypeid, props.assetid], load)
defineExpose({ save, hasFields: () => fields.value.length > 0 })
</script>
<style scoped>
.custom-fields {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<!-- Only render the card when there is at least one field with a value to show -->
<div class="section-card" v-if="visibleFields.length">
<h3 class="section-title">{{ title }}</h3>
<div class="info-list">
<div class="info-row" v-for="f in visibleFields" :key="f.fieldid">
<span class="info-label">{{ f.label }}</span>
<span class="info-value">{{ displayValue(f) }}</span>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { customFieldsApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
title: { type: String, default: 'Additional Details' },
})
const fields = ref([])
const visibleFields = computed(() =>
fields.value.filter(f => f.showondetail && f.value != null && String(f.value).trim() !== ''))
function displayValue(f) {
if (f.datatype === 'boolean') {
return ['true', '1', 'yes'].includes(String(f.value).toLowerCase()) ? 'Yes' : 'No'
}
return f.value
}
async function load() {
if (!props.assetid) { fields.value = []; return }
try {
const response = await customFieldsApi.forAsset(props.assetid)
fields.value = response.data.data || []
} catch (err) {
console.error('Error loading custom fields:', err)
fields.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
</script>

View File

@@ -7,6 +7,7 @@ import { ref, onMounted, onUnmounted, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
const props = defineProps({
left: { type: Number, default: null },
@@ -19,14 +20,17 @@ const mapContainer = ref(null)
let map = null
let marker = null
// Map dimensions
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Map dimensions - facility blueprint size, loaded from settings.
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
function initMap() {
if (!mapContainer.value || props.left === null || props.top === null) return
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -3,
@@ -35,11 +39,7 @@ function initMap() {
zoomControl: true
})
const blueprintUrl = currentTheme.value === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
L.imageOverlay(blueprintUrl, bounds).addTo(map)
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
// Convert database coordinates to Leaflet (y is inverted)
const leafletY = MAP_HEIGHT - props.top
@@ -71,7 +71,8 @@ function initMap() {
map.setMaxBounds(bounds)
}
onMounted(() => {
onMounted(async () => {
await loadMapConfig()
initMap()
})

View File

@@ -43,6 +43,11 @@
<script setup>
import { ref, computed, nextTick, watch } from 'vue'
import { currentTheme } from '../stores/theme'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
// Fetch this facility's blueprint + dimensions once; computeds below react
// when it loads.
loadMapConfig()
const props = defineProps({
left: { type: Number, default: null },
@@ -58,29 +63,22 @@ const isOverTooltip = ref(false)
const zoom = ref(1)
const imageLoaded = ref(false)
// Map dimensions
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const hasPosition = computed(() => {
return props.left !== null && props.top !== null
})
const blueprintUrl = computed(() => {
// Force re-evaluation when theme changes by including theme in the computed
const theme = currentTheme.value
return theme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
// Reading currentTheme keeps this reactive to theme changes.
return blueprintUrlFor(currentTheme.value)
})
// Calculate marker position as percentage
// Calculate marker position as percentage of the facility blueprint size
const markerX = computed(() => {
return (props.left / MAP_WIDTH) * 100
return (props.left / mapConfig.width) * 100
})
const markerY = computed(() => {
return (props.top / MAP_HEIGHT) * 100
return (props.top / mapConfig.height) * 100
})
// Marker style with counter-scale to maintain constant size

View File

@@ -86,6 +86,8 @@
import { ref, onMounted, onUnmounted, computed, watch } from 'vue'
import L from 'leaflet'
import 'leaflet/dist/leaflet.css'
import { loadMapConfig, blueprintUrlFor, state as mapConfig } from '../composables/mapConfig'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const props = defineProps({
machines: { type: Array, default: () => [] },
@@ -119,10 +121,10 @@ const filters = ref({
search: ''
})
// Map dimensions (matching old system)
const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Map dimensions - facility blueprint size, loaded from settings before
// initMap runs (mutable so the loaded values replace the fallback defaults).
let MAP_WIDTH = mapConfig.width
let MAP_HEIGHT = mapConfig.height
// Asset type colors (for unified map mode) - normalized lookup
const assetTypeColorsMap = {
@@ -140,21 +142,10 @@ function getAssetTypeColor(assettype) {
return assetTypeColorsMap[normalized] || '#BDBDBD'
}
// Asset type labels for display (case-insensitive lookup)
const assetTypeLabelsMap = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
}
// Asset-type display labels come from the shared util (single source of truth).
const assetTypeLabels = new Proxy({}, {
get(target, prop) {
if (typeof prop === 'string') {
return assetTypeLabelsMap[prop.toLowerCase()] || prop
}
return prop
return typeof prop === 'string' ? assetTypeLabel(prop) : prop
}
})
@@ -232,7 +223,8 @@ const visibleAssetTypes = computed(() => {
// Get subtype ID from asset based on asset type
function getSubtypeId(asset) {
if (!asset.typedata) return null
const typeLower = asset.assettype?.toLowerCase() || ''
// Normalize network_device -> network device so the subtype id resolves.
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
if (typeLower === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
@@ -271,11 +263,8 @@ function initMap() {
renderer: canvasRenderer
})
const blueprintUrl = props.theme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
imageOverlay = L.imageOverlay(blueprintUrl, bounds)
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
imageOverlay = L.imageOverlay(blueprintUrlFor(props.theme), bounds)
imageOverlay.addTo(map)
// Set initial view - zoom out to show full floor plan
@@ -390,7 +379,8 @@ function renderMarkers() {
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
} else {
color = getAssetTypeColor(item.assettype)
// Prefer the stored AssetType.color; fall back to the built-in map.
color = item.assettypecolor || getAssetTypeColor(item.assettype)
typeName = item.assettype || ''
}
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
@@ -517,33 +507,9 @@ function renderMarkers() {
applyFilters()
}
// Get detail route for unified asset format
// Get detail route for unified asset format (shared util = single source).
function getAssetDetailRoute(asset) {
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network'
}
const basePath = routeMap[assetType] || '/machines'
// Get the plugin-specific ID from typedata
let id = asset.assetid // fallback
if (asset.typedata) {
if (assetType === 'equipment' && asset.typedata.equipmentid) {
id = asset.typedata.equipmentid
} else if (assetType === 'computer' && asset.typedata.computerid) {
id = asset.typedata.computerid
} else if (assetType === 'printer' && asset.typedata.printerid) {
id = asset.typedata.printerid
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
id = asset.typedata.networkdeviceid
}
}
return `${basePath}/${id}`
return assetDetailRoute(asset)
}
function applyFilters() {
@@ -581,14 +547,16 @@ watch(() => props.machines, (newVal, oldVal) => {
watch(() => props.theme, (newTheme) => {
if (imageOverlay && map) {
const blueprintUrl = newTheme === 'light'
? '/static/images/sitemap2025-light.png'
: '/static/images/sitemap2025-dark.png'
imageOverlay.setUrl(blueprintUrl)
imageOverlay.setUrl(blueprintUrlFor(newTheme))
}
})
onMounted(() => {
onMounted(async () => {
// Load this facility's blueprint + dimensions before building the map so
// bounds and coordinate math use the right size. Falls back to defaults.
await loadMapConfig()
MAP_WIDTH = mapConfig.width
MAP_HEIGHT = mapConfig.height
initMap()
})

View File

@@ -0,0 +1,75 @@
<template>
<div class="section-card" v-if="warranties.length || showEmpty">
<h3 class="section-title">Warranty</h3>
<div v-if="warranties.length" class="warranty-list">
<div v-for="w in warranties" :key="w.warrantyid" class="warranty-item">
<div class="warranty-top">
<span class="warranty-vendor">{{ w.vendor }}</span>
<span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span>
</div>
<div class="warranty-meta">
<span v-if="w.servicelevel">{{ w.servicelevel }}</span>
<span v-if="w.enddate">Ends {{ formatDate(w.enddate) }}</span>
<span v-if="w.servicetag" class="mono">Tag {{ w.servicetag }}</span>
</div>
</div>
<router-link to="/warranties" class="warranty-manage">Manage warranties</router-link>
</div>
<div v-else class="warranty-empty">
<span class="muted">No warranty on record.</span>
<router-link to="/warranties" class="warranty-manage">Add one</router-link>
</div>
</div>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi } from '../api'
const props = defineProps({
assetid: { type: [Number, String], default: null },
// When true, render the card even with no warranties (shows an "Add one" link).
showEmpty: { type: Boolean, default: false },
})
const warranties = ref([])
function statusLabel(status) {
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
}
function formatDate(d) {
if (!d) return '-'
return new Date(d + 'T00:00:00').toLocaleDateString()
}
async function load() {
if (!props.assetid) { warranties.value = []; return }
try {
const response = await warrantyApi.forAsset(props.assetid)
warranties.value = response.data.data || []
} catch (err) {
console.error('Error loading warranties:', err)
warranties.value = []
}
}
onMounted(load)
watch(() => props.assetid, load)
</script>
<style scoped>
.warranty-list { display: flex; flex-direction: column; gap: 0.75rem; }
.warranty-item { padding: 0.6rem 0.75rem; background: var(--bg); border-radius: 6px; }
.warranty-top { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; }
.warranty-vendor { font-weight: 600; color: var(--text); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.75rem; font-weight: 600; }
.warranty-meta { margin-top: 0.35rem; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.82rem; color: var(--text-light); }
.warranty-empty { display: flex; align-items: center; gap: 0.6rem; }
.warranty-manage { font-size: 0.82rem; }
.mono { font-family: monospace; }
.muted { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,68 @@
// 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.
import { reactive } from 'vue'
import { settingsApi } from '../api'
// Fallback defaults - the original hardcoded West Jefferson values.
const DEFAULTS = {
blueprintLight: '/static/images/sitemap2025-light.png',
blueprintDark: '/static/images/sitemap2025-dark.png',
width: 3300,
height: 2550
}
// Shared reactive config. Import as `state` to read width/height/blueprint.
export const state = reactive({ ...DEFAULTS, loaded: false })
let inflight = null
function applySetting(key, value) {
if (value === null || value === undefined || value === '') return
if (key === 'map_blueprint_light') state.blueprintLight = value
else if (key === 'map_blueprint_dark') state.blueprintDark = value
else if (key === 'map_width') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.width = n
} else if (key === 'map_height') {
const n = parseInt(value, 10)
if (!isNaN(n) && n > 0) state.height = n
}
}
function fetchConfig() {
inflight = settingsApi.list({ category: 'map' })
.then(({ data }) => {
;(data.data || []).forEach(s => applySetting(s.key, s.value))
state.loaded = true
})
.catch(() => { state.loaded = true })
.finally(() => { inflight = null })
return inflight
}
// Fetch the map config once (shared across all map components). Returns a
// promise that resolves when state is populated, so a caller can await it
// before initializing a Leaflet map that needs the dimensions.
export function loadMapConfig() {
if (state.loaded) return Promise.resolve()
if (inflight) return inflight
return fetchConfig()
}
// Re-read config from the server after a map setting changes.
export function reloadMapConfig() {
return fetchConfig()
}
// Blueprint image URL for the given theme ('light' | 'dark').
export function blueprintUrlFor(theme) {
return theme === 'light' ? state.blueprintLight : state.blueprintDark
}
export function useMapConfig() {
loadMapConfig()
return { state, blueprintUrlFor }
}

View File

@@ -1,10 +1,39 @@
import { createRouter, createWebHistory } from 'vue-router'
import { useAuthStore } from '../stores/auth'
import AppLayout from '../views/AppLayout.vue'
import SettingsLayout from '../views/settings/SettingsLayout.vue'
// Auto-discover all route modules from routes/ directory
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
const appChildren = Object.values(routeModules).flatMap(m => m.default)
const rawChildren = Object.values(routeModules).flatMap(m => m.default)
// Gather the settings pages (spread across plugin route files) and nest them
// under a single two-pane shell so the grouped rail stays put while the right
// pane swaps. Slides is a sidebar page that happens to live at /settings/slides;
// keep it full-width (not inside the settings rail).
const SETTINGS_STANDALONE = new Set(['settings/slides'])
const settingsChildren = []
const otherChildren = []
for (const route of rawChildren) {
const path = route.path
if (path === 'settings') {
// Old index becomes the shell's default child (keeps name 'settings').
settingsChildren.unshift({ ...route, path: '' })
} else if (typeof path === 'string' && path.startsWith('settings/') && !SETTINGS_STANDALONE.has(path)) {
settingsChildren.push({ ...route, path: path.replace(/^settings\//, '') })
} else {
otherChildren.push(route)
}
}
const appChildren = [
...otherChildren,
{
path: 'settings',
component: SettingsLayout,
meta: { requiresAuth: true, requiresAdmin: true },
children: settingsChildren,
},
]
const routes = [
{

View File

@@ -36,5 +36,11 @@ export default [
name: 'operatingsystems',
component: () => import('../../views/settings/OperatingSystemsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/accessprotocols',
name: 'access-protocols',
component: () => import('../../views/settings/AccessProtocolsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -92,6 +92,60 @@ export default [
component: () => import('../../views/settings/DashboardDefaultsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/site',
name: 'site-settings',
component: () => import('../../views/settings/SiteSettings.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/slides',
name: 'slide-manager',
component: () => import('../../views/settings/SlideManager.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/equipmenttypes',
name: 'equipment-types',
component: () => import('../../views/settings/EquipmentTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/networktypes',
name: 'network-types',
component: () => import('../../views/settings/NetworkTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/printertypes',
name: 'printer-types',
component: () => import('../../views/settings/PrinterTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/relationshiptypes',
name: 'relationship-types',
component: () => import('../../views/settings/RelationshipTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/locationtypes',
name: 'location-types',
component: () => import('../../views/settings/LocationTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/assettypes',
name: 'asset-types',
component: () => import('../../views/settings/AssetTypesList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/customfields',
name: 'custom-fields',
component: () => import('../../views/settings/CustomFieldsList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
},
{
path: 'settings/system',
name: 'system-settings',

View File

@@ -13,6 +13,12 @@ export default [
component: () => import('../../views/notifications/NotificationForm.vue'),
meta: { requiresAuth: true }
},
{
path: 'settings/notificationtypes',
name: 'notification-types',
component: () => import('../../views/notifications/NotificationTypesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'notifications/:id',
name: 'notification-detail',

View File

@@ -30,5 +30,11 @@ export default [
name: 'model-supplies',
component: () => import('../../views/settings/ModelSuppliesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'settings/printerdrivers',
name: 'printer-drivers',
component: () => import('../../views/settings/PrinterDriversList.vue'),
meta: { requiresAuth: true, requiresAdmin: true }
}
]

View File

@@ -0,0 +1,16 @@
/**
* Warranty plugin routes
*/
export default [
{
path: 'warranties',
name: 'warranties',
component: () => import('../../views/warranty/WarrantiesList.vue'),
meta: { requiresAuth: true }
},
{
path: 'reports/warranty',
name: 'warranty-report',
component: () => import('../../views/reports/WarrantyReport.vue')
}
]

View File

@@ -0,0 +1,60 @@
// 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 = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network_device': 'Network Devices',
'network device': 'Network Devices',
}
const ASSET_TYPE_ROUTES = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network',
}
// Plugin-specific id field inside asset.typedata for each asset type.
const ASSET_TYPE_ID_KEYS = {
'equipment': 'equipmentid',
'computer': 'computerid',
'printer': 'printerid',
'network_device': 'networkdeviceid',
'network device': 'networkdeviceid',
}
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}`
}

View 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) }
}

View File

@@ -0,0 +1,51 @@
// 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 = {
equipment: '#F44336', // Red
computer: '#2196F3', // Blue
printer: '#4CAF50', // Green
'network device': '#FF9800', // Orange
network_device: '#FF9800' // Orange (alternate 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 (equipment/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 === 'equipment') return asset.typedata.equipmenttypeid
if (typeLower === 'computer') return asset.typedata.computertypeid
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
if (typeLower === 'printer') return asset.typedata.printertypeid
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)
}

View 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`)
}

View File

@@ -0,0 +1,37 @@
// 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'
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', 'West Jefferson')
}

View File

@@ -77,7 +77,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import {
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck
} from 'lucide-vue-next'
import { useAuthStore } from '../stores/auth'
import { currentTheme, toggleTheme } from '../stores/theme'
@@ -103,6 +103,8 @@ const iconMap = {
'app-window': AppWindow,
'book-open': BookOpen,
'bar-chart-3': BarChart3,
'image': Image,
'shield': ShieldCheck,
}
// Default navigation (used as fallback if API fails)

View File

@@ -29,13 +29,13 @@
<!-- Event details modal -->
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
<div class="modal">
<!-- Recognition event with employee highlight -->
<div v-if="selectedEvent.extendedProps?.typecolor === 'recognition'" class="recognition-header">
<!-- Employee-photo event (recognition/recertification) with highlight -->
<div v-if="selectedEvent.extendedProps?.showemployeephoto" class="recognition-header">
<div class="recognition-badge">
<span class="recognition-icon"><Trophy :size="24" /></span>
</div>
<div class="recognition-info">
<div class="recognition-label">Recognition</div>
<div class="recognition-label">{{ selectedEvent.extendedProps?.typename || 'Recognition' }}</div>
<h2 class="recognition-title">{{ selectedEvent.extendedProps?.message || selectedEvent.title }}</h2>
<div v-if="selectedEvent.extendedProps?.employeename || selectedEvent.extendedProps?.employeesso" class="recognition-employee">
<span class="employee-icon"><User :size="16" /></span>
@@ -48,7 +48,7 @@
<h2 v-else>{{ selectedEvent.title }}</h2>
<div class="event-details">
<p v-if="selectedEvent.extendedProps?.typename && selectedEvent.extendedProps?.typecolor !== 'recognition'">
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
</p>
<p>
@@ -57,7 +57,7 @@
<p v-if="selectedEvent.end">
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
</p>
<p v-if="selectedEvent.extendedProps?.message && selectedEvent.extendedProps?.typecolor !== 'recognition'" class="message-block">
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
<strong>Details:</strong>
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
</p>

View File

@@ -47,6 +47,15 @@
@input="debouncedSearch"
/>
<button
class="btn btn-secondary export-btn"
@click="exportPdf"
:disabled="exporting || !filteredAssets.length"
:title="filteredAssets.length ? 'Export the filtered map to PDF' : 'No assets to export'"
>
{{ exporting ? 'Exporting...' : 'Export PDF' }}
</button>
<span class="result-count">{{ filteredAssets.length }} assets</span>
</div>
@@ -73,6 +82,9 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
import { assetsApi } from '../api'
import { currentTheme } from '../stores/theme'
import { useAuthStore } from '../stores/auth'
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
import { exportMapPdf } from '../utils/mapPdf'
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
const router = useRouter()
const authStore = useAuthStore()
@@ -89,18 +101,20 @@ const selectedSubtype = ref('')
const selectedBusinessUnit = ref('')
const selectedStatus = ref('')
const searchQuery = ref('')
const exporting = ref(false)
let searchTimeout = null
// Case-insensitive lookup helper for subtypes
// Lookup helper for subtypes. Normalizes case AND underscores-vs-spaces, since
// the asset-type value is 'network_device' but the subtypes key is
// 'Network Device' - without normalizing, network subtypes never match.
function getSubtypesForType(typeName) {
if (!typeName || !subtypes.value) return []
// Try exact match first
if (subtypes.value[typeName]) return subtypes.value[typeName]
// Try case-insensitive match
const lowerType = typeName.toLowerCase()
const norm = typeName.toLowerCase().replace(/_/g, ' ')
for (const [key, value] of Object.entries(subtypes.value)) {
if (key.toLowerCase() === lowerType) return value
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
}
return []
}
@@ -119,7 +133,7 @@ const subtypeLabel = computed(() => {
'network device': 'All Device Types',
'printer': 'All Printer Types'
}
return labels[selectedType.value.toLowerCase()] || 'All Subtypes'
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
})
// Generate distinct colors for subtypes
@@ -130,12 +144,13 @@ const subtypeColorPalette = [
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
]
// Map subtype IDs to colors
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
// the auto palette (by index) for subtypes that have not been given one.
const subtypeColorMap = computed(() => {
const colorMap = {}
const allSubtypes = currentSubtypes.value
allSubtypes.forEach((st, index) => {
colorMap[st.id] = subtypeColorPalette[index % subtypeColorPalette.length]
colorMap[st.id] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
})
return colorMap
})
@@ -158,10 +173,10 @@ const filteredAssets = computed(() => {
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
}
// Filter by subtype (case-insensitive type check)
// Filter by subtype (normalize network_device -> network device)
if (selectedSubtype.value) {
const subtypeId = parseInt(selectedSubtype.value)
const typeLower = selectedType.value?.toLowerCase() || ''
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
result = result.filter(a => {
if (!a.typedata) return false
// Check different ID fields based on asset type
@@ -202,7 +217,51 @@ const filteredAssets = computed(() => {
return result
})
// Human-readable labels for the filters currently applied, for the PDF header.
function activeFilterLabels() {
const labels = []
if (selectedType.value) labels.push(`Type: ${formatTypeName(selectedType.value)}`)
if (selectedSubtype.value) {
const st = currentSubtypes.value.find(s => String(s.id) === String(selectedSubtype.value))
if (st) labels.push(`Subtype: ${st.name}`)
}
if (selectedBusinessUnit.value) {
const bu = businessunits.value.find(b => String(b.businessunitid) === String(selectedBusinessUnit.value))
if (bu) labels.push(`Business Unit: ${bu.businessunit}`)
}
if (selectedStatus.value) {
const s = statuses.value.find(x => String(x.statusid) === String(selectedStatus.value))
if (s) labels.push(`Status: ${s.status}`)
}
if (searchQuery.value) labels.push(`Search: "${searchQuery.value}"`)
return labels
}
async function exportPdf() {
if (!filteredAssets.value.length) return
exporting.value = true
try {
await loadMapConfig()
await exportMapPdf({
assets: filteredAssets.value,
blueprintUrl: mapConfig.blueprintLight,
mapWidth: mapConfig.width,
mapHeight: mapConfig.height,
selectedType: selectedType.value,
subtypeColors: subtypeColorMap.value,
subtypeNames: subtypeNameMap.value,
filters: activeFilterLabels()
})
} catch (e) {
console.error('Map PDF export failed:', e)
alert('Failed to export map PDF. See console for details.')
} finally {
exporting.value = false
}
}
onMounted(async () => {
loadMapConfig()
try {
const response = await assetsApi.getMap()
const data = response.data.data || {}
@@ -220,15 +279,7 @@ onMounted(async () => {
})
function formatTypeName(assettype) {
if (!assettype) return assettype
const names = {
'equipment': 'Equipment',
'computer': 'Computers',
'printer': 'Printers',
'network device': 'Network Devices',
'network_device': 'Network Devices'
}
return names[assettype.toLowerCase()] || assettype
return assetTypeLabel(assettype)
}
function getTypeCount(assettype) {
@@ -255,33 +306,7 @@ function debouncedSearch() {
}
function handleMarkerClick(asset) {
// Route based on asset type (lowercase keys to match API data)
const assetType = (asset.assettype || '').toLowerCase()
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network',
'network device': '/network'
}
const basePath = routeMap[assetType] || '/machines'
// Get the plugin-specific ID from typedata
let id = asset.assetid // fallback
if (asset.typedata) {
if (assetType === 'equipment' && asset.typedata.equipmentid) {
id = asset.typedata.equipmentid
} else if (assetType === 'computer' && asset.typedata.computerid) {
id = asset.typedata.computerid
} else if (assetType === 'printer' && asset.typedata.printerid) {
id = asset.typedata.printerid
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
id = asset.typedata.networkdeviceid
}
}
router.push(`${basePath}/${id}`)
router.push(assetDetailRoute(asset))
}
</script>
@@ -331,10 +356,30 @@ function handleMarkerClick(asset) {
min-width: 180px;
}
.export-btn {
margin-left: auto;
padding: 0.5rem 0.9rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.875rem;
cursor: pointer;
}
.export-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
}
.export-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.result-count {
color: var(--text-light);
font-size: 0.875rem;
margin-left: auto;
}
.map-page :deep(.shopfloor-map) {

View File

@@ -6,7 +6,7 @@
</div>
<div class="header-center">
<div class="location-title">West Jefferson</div>
<div class="location-title">{{ facilityName }}</div>
<h1>Shopfloor Dashboard</h1>
</div>
@@ -22,6 +22,18 @@
</header>
<main class="dashboard-content">
<!-- Banner - single prominent full-width message -->
<section v-if="banners.length" class="banner-section">
<div
v-for="n in banners"
:key="n.notificationid"
class="banner-strip"
:style="{ backgroundColor: getTypeColor(n.typecolor) }"
>
{{ n.notification }}
</div>
</section>
<!-- Recognition Carousel -->
<section v-if="recognitions.length" class="recognition-section">
<div class="section-title recognition">Employee Recognition</div>
@@ -49,7 +61,6 @@
</div>
<div class="recognition-content">
<div class="recognition-header">
<span class="recognition-star">&#9733;</span>
<div class="recognition-name">{{ rec.employeename }}</div>
</div>
<div class="recognition-message">{{ rec.notification }}</div>
@@ -58,6 +69,44 @@
</div>
</section>
<!-- Recertification grid - everyone due shown at once, so nobody has to
wait for a carousel to rotate to their name -->
<section v-if="recertifications.length" class="recert-section">
<div class="section-title recert-title">
<span>Recertification Required ({{ recertifications.length }})</span>
<span v-if="recertRangeLabel" class="recert-range">{{ recertRangeLabel }}</span>
</div>
<div
v-for="msg in recertDescriptions"
:key="msg"
class="recert-description"
>
{{ msg }}
</div>
<div class="recert-row">
<div
v-for="rec in recertPage"
:key="`recert-${rec.notificationid}-${rec.employeesso}`"
class="recert-tile"
>
<img
v-if="rec.employeepicture"
:src="`/static/employees/${rec.employeepicture}`"
:alt="rec.employeename"
class="recert-photo"
@error="handlePhotoError"
/>
<img
v-else
src="/ge-aerospace-logo.svg"
alt="GE Aerospace"
class="recert-photo ge-logo-fallback"
/>
<div class="recert-name">{{ rec.employeename || rec.employeesso }}</div>
</div>
</div>
</section>
<!-- Current Notifications -->
<section v-if="currentNotifications.length" class="notifications-section">
<div class="section-title" :class="getSectionClass(currentNotifications)">
@@ -114,7 +163,7 @@
</section>
<!-- No notifications -->
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length" class="no-events">
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length && !recertifications.length && !banners.length" class="no-events">
No active notifications
</div>
@@ -130,27 +179,75 @@
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
import { getFacilityName } from '@/utils/siteSettings'
const loading = ref(true)
const facilityName = ref('West Jefferson')
const businessUnit = ref('')
const businessUnits = ref([])
const notifications = ref({ current: [], upcoming: [] })
const currentRecognition = ref(0)
// Layout-config fingerprint from the feed; when it changes the kiosk reloads.
const loadedConfigVersion = ref(null)
let refreshInterval = null
let recognitionInterval = null
let recertPageInterval = null
// The board groups cards by each type's configured display style, so any custom
// type set to carousel/grid/banner renders that way - not just the built-ins.
const SPECIAL_STYLES = ['carousel', 'grid', 'banner']
// Separate recognition notifications from others
const recognitions = computed(() =>
notifications.value.current.filter(n => n.typecolor === 'recognition')
notifications.value.current.filter(n => n.displaystyle === 'carousel')
)
const recertifications = computed(() =>
notifications.value.current.filter(n => n.displaystyle === 'grid')
)
const banners = computed(() =>
notifications.value.current.filter(n => n.displaystyle === 'banner')
)
// Distinct training descriptions shown once above the grid (the per-person
// tiles only carry photo + name).
const recertDescriptions = computed(() => {
const seen = new Set()
const out = []
for (const r of recertifications.value) {
const msg = (r.notification || '').trim()
if (msg && !seen.has(msg)) { seen.add(msg); out.push(msg) }
}
return out
})
// Recertification shows as a single rotating row: one page of tiles at a time
// so it stays compact on any screen, cycling through everyone due.
const RECERT_PAGE_SIZE = 8
const currentRecertPage = ref(0)
const recertPageCount = computed(() =>
Math.max(1, Math.ceil(recertifications.value.length / RECERT_PAGE_SIZE))
)
const recertPage = computed(() => {
const page = currentRecertPage.value % recertPageCount.value
const start = page * RECERT_PAGE_SIZE
return recertifications.value.slice(start, start + RECERT_PAGE_SIZE)
})
const recertRangeLabel = computed(() => {
if (recertPageCount.value <= 1) return ''
const page = currentRecertPage.value % recertPageCount.value
const start = page * RECERT_PAGE_SIZE
const end = Math.min(start + RECERT_PAGE_SIZE, recertifications.value.length)
return `${start + 1}-${end} of ${recertifications.value.length}`
})
const currentNotifications = computed(() =>
notifications.value.current.filter(n => n.typecolor !== 'recognition')
notifications.value.current.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
)
const upcomingNotifications = computed(() =>
notifications.value.upcoming.filter(n => n.typecolor !== 'recognition')
notifications.value.upcoming.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
)
// Clock
@@ -168,6 +265,8 @@ onMounted(async () => {
updateClock()
setInterval(updateClock, 1000)
getFacilityName().then(name => { facilityName.value = name })
// Load business units
try {
const response = await businessUnitsApi.list()
@@ -200,11 +299,19 @@ onMounted(async () => {
currentRecognition.value = (currentRecognition.value + 1) % recognitions.value.length
}
}, 8000)
// Cycle the recertification row through pages of employees every 7 seconds.
recertPageInterval = setInterval(() => {
if (recertPageCount.value > 1) {
currentRecertPage.value = (currentRecertPage.value + 1) % recertPageCount.value
}
}, 7000)
})
onUnmounted(() => {
if (refreshInterval) clearInterval(refreshInterval)
if (recognitionInterval) clearInterval(recognitionInterval)
if (recertPageInterval) clearInterval(recertPageInterval)
})
async function loadData() {
@@ -214,7 +321,21 @@ async function loadData() {
params.businessunit = businessUnit.value
}
const response = await notificationsApi.getShopfloor(params)
notifications.value = response.data.data || { current: [], upcoming: [] }
const data = response.data.data || { current: [], upcoming: [] }
// Reload the kiosk when the board's layout config changes, so type/style
// edits (and deploys, via SHOPFLOOR_BUILD) reach already-open pages without
// anyone touching the machine.
const version = data.configversion
if (version) {
if (loadedConfigVersion.value && loadedConfigVersion.value !== version) {
window.location.reload()
return
}
loadedConfigVersion.value = version
}
notifications.value = { current: data.current || [], upcoming: data.upcoming || [] }
} catch (err) {
console.error('Error loading shopfloor data:', err)
} finally {
@@ -223,15 +344,17 @@ async function loadData() {
}
function getTypeColor(typecolor) {
const colors = {
// Types store a hex color, used directly. Only legacy Bootstrap color names
// still need aliasing; anything else (a hex) passes straight through.
const aliases = {
success: '#04b962',
warning: '#ff8800',
danger: '#f5365c',
info: '#14abef',
primary: '#7934f3',
recognition: '#0d6efd'
secondary: '#94614f'
}
return colors[typecolor] || typecolor || '#14abef'
return aliases[typecolor] || typecolor || '#14abef'
}
function getSectionClass(notifications) {
@@ -362,7 +485,69 @@ function handlePhotoError(e) {
}
.section-title.recognition {
background: #ffc107;
color: #3a2e00;
}
/* Recertification grid - blue, compact tiles so 20-30 people are all visible
at once (no carousel to wait through) */
.recert-section {
margin-bottom: 25px;
}
.section-title.recert-title {
background: #0d6efd;
color: #fff;
display: flex;
align-items: center;
justify-content: space-between;
}
.recert-range {
font-size: 16px;
font-weight: 600;
letter-spacing: 1px;
opacity: 0.85;
}
.recert-description {
font-size: 22px;
font-weight: 600;
color: #cbd5e1;
margin: -4px 0 14px;
line-height: 1.35;
}
/* Single row that cycles through pages of employees */
.recert-row {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 14px;
}
.recert-tile {
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border: 2px solid #0d6efd;
border-radius: 10px;
padding: 14px 10px;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
text-align: center;
}
.recert-photo {
width: 90px;
height: 90px;
border-radius: 50%;
object-fit: cover;
border: 3px solid #0d6efd;
background: #1a1a2e;
}
.recert-photo.ge-logo-fallback {
object-fit: contain;
padding: 12px;
background: #fff;
}
.recert-name {
font-size: 20px;
font-weight: 700;
line-height: 1.15;
}
.section-title.danger {
@@ -374,6 +559,22 @@ function handlePhotoError(e) {
}
/* Recognition carousel */
.banner-section {
margin-bottom: 25px;
}
.banner-strip {
padding: 22px 30px;
border-radius: 10px;
margin-bottom: 12px;
color: #fff;
font-size: 2rem;
font-weight: 700;
text-align: center;
text-wrap: balance;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
.recognition-section {
margin-bottom: 25px;
}
@@ -388,8 +589,8 @@ function handlePhotoError(e) {
top: 0;
left: 0;
right: 0;
background: linear-gradient(135deg, #1e3a5f 0%, #0d2137 100%);
border: 3px solid #0d6efd;
background: linear-gradient(135deg, #4a3a0a 0%, #2a2200 100%);
border: 3px solid #ffc107;
border-radius: 12px;
padding: 20px 25px;
display: flex;
@@ -411,7 +612,7 @@ function handlePhotoError(e) {
height: 140px;
border-radius: 50%;
object-fit: cover;
border: 4px solid #0d6efd;
border: 4px solid #ffc107;
background: #1a1a2e;
}
@@ -438,6 +639,7 @@ function handlePhotoError(e) {
animation: starPulse 2s ease-in-out infinite;
}
@keyframes starPulse {
0%, 100% { transform: scale(1); }
50% { transform: scale(1.1); }

View File

@@ -66,12 +66,13 @@ onUnmounted(() => {
async function fetchSlides() {
try {
const response = await api.get('/slides')
// Flat feed shape: { success, surface, basepath, interval, slides:[...] }
const response = await api.get('/slides/feed', { params: { surface: 'lobby' } })
const data = response.data
if (data.success && data.data?.slides?.length > 0) {
slides.value = data.data.slides
basePath.value = data.data.basepath || '/static/slides/'
if (data.success && data.slides?.length > 0) {
slides.value = data.slides
basePath.value = data.basepath || '/api/slides/img/lobby/'
error.value = ''
// Restart slideshow if slides changed
@@ -79,7 +80,8 @@ async function fetchSlides() {
startSlideshow()
}
} else {
error.value = data.message || 'No slides found'
slides.value = []
error.value = 'No slides configured'
}
} catch (err) {
console.error('Error fetching slides:', err)

View File

@@ -75,7 +75,7 @@
<!-- Application Notes -->
<div class="section-card" v-if="app.applicationnotes">
<h3 class="section-title">Application Notes</h3>
<div class="notes-text" v-html="app.applicationnotes"></div>
<div class="notes-text">{{ app.applicationnotes }}</div>
</div>
<!-- Versions -->
@@ -326,13 +326,9 @@ function handleImageError(e) {
font-size: 1.125rem;
}
/* Notes styling */
.notes-text :deep(a) {
color: var(--primary);
text-decoration: none;
}
.notes-text :deep(a:hover) {
text-decoration: underline;
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
.notes-text {
white-space: pre-wrap;
word-break: break-word;
}
</style>

View File

@@ -129,14 +129,6 @@
</span>
</span>
</div>
<div class="info-row">
<span class="info-label">Location Only</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: equipment.equipment?.islocationonly }">
{{ equipment.equipment?.islocationonly ? 'Yes' : 'No' }}
</span>
</span>
</div>
</div>
</div>
@@ -211,6 +203,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="equipment.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="equipment.assetid" />
<!-- Notes -->
<div class="section-card" v-if="equipment.notes">
<h3 class="section-title">Notes</h3>
@@ -237,6 +235,8 @@ import { ref, computed, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { equipmentApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()

View File

@@ -271,15 +271,7 @@
<input type="checkbox" v-model="form.requiresmanualconfig" />
Requires Manual Config
</label>
<small class="form-help">Multi-PC machine needs manual configuration</small>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.islocationonly" />
Location Only
</label>
<small class="form-help">Virtual location marker (not actual equipment)</small>
<small class="form-help">Machine a tech must configure by hand (e.g. driven by multiple PCs)</small>
</div>
</div>
@@ -336,6 +328,9 @@
></textarea>
</div>
<!-- Site-defined custom fields for equipment -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="EQUIPMENT_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -355,6 +350,7 @@ import { useRoute, useRouter } from 'vue-router'
import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
@@ -365,6 +361,11 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for equipment (see /api/assets/types).
const EQUIPMENT_ASSETTYPEID = 1
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const loading = ref(true)
const saving = ref(false)
const error = ref('')
@@ -481,6 +482,7 @@ onMounted(async () => {
if (isEdit.value) {
const response = await equipmentApi.get(route.params.id)
const data = response.data.data
currentAssetId.value = data.assetid || null
currentEquipment.value = data
form.value = {
@@ -592,6 +594,15 @@ async function saveEquipment() {
// Handle relationship (controlling PC)
await saveRelationship(assetId)
// Persist custom-field values against the asset id.
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push(`/machines/${savedEquipment.equipment?.equipmentid || route.params.id}`)
} catch (err) {
console.error('Error saving equipment:', err)

View File

@@ -42,7 +42,7 @@
<td>{{ item.equipment?.equipmenttypename || '-' }}</td>
<td>{{ item.equipment?.vendorname || '-' }}</td>
<td>
<span class="badge" :class="getStatusClass(item.statusname)">
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
@@ -81,6 +81,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { equipmentApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
@@ -134,15 +135,6 @@ function changePerPage(newPerPage) {
page.value = 1
loadEquipment()
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>

View File

@@ -18,7 +18,7 @@
</router-link>
</div>
<div class="hero-meta">
<span class="badge" :class="getStatusClass(device.statusname)">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
<span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
@@ -87,6 +87,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="device.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="device.assetid" />
<!-- Notes -->
<div class="section-card" v-if="device.notes">
<h3 class="section-title">Notes</h3>
@@ -130,7 +136,7 @@
<div class="info-row">
<span class="info-label">Status</span>
<span class="info-value">
<span class="badge" :class="getStatusClass(device.statusname)">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
</span>
@@ -184,11 +190,14 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute, useRouter } from 'vue-router'
import { Network, Router, Shield, Wifi, Camera, Server, Server as Rack, Globe } from 'lucide-vue-next'
import { useAuthStore } from '../../stores/auth'
import { networkApi } from '../../api'
import AssetRelationships from '../../components/AssetRelationships.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()

View File

@@ -226,6 +226,11 @@
</div>
</fieldset>
<!-- Site-defined custom fields for network devices -->
<fieldset>
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="NETWORK_ASSETTYPEID" :assetid="currentAssetId" />
</fieldset>
<!-- Form Actions -->
<div class="form-actions">
<button type="button" class="btn btn-secondary" @click="cancel">Cancel</button>
@@ -250,6 +255,7 @@ import {
assetsApi,
businessunitsApi
} from '../../api'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
@@ -260,6 +266,11 @@ const router = useRouter()
const deviceId = route.params.id
const isEdit = computed(() => !!deviceId)
// Seeded asset-type id for network devices (see /api/assets/types).
const NETWORK_ASSETTYPEID = 3
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const form = ref({
assetnumber: '',
name: '',
@@ -353,6 +364,7 @@ async function loadDevice() {
try {
const response = await networkApi.get(deviceId)
const data = response.data.data
currentAssetId.value = data.assetid || null
// Populate form with existing data
form.value.assetnumber = data.assetnumber || ''
@@ -411,14 +423,26 @@ async function submitForm() {
notes: form.value.notes || null
}
let assetId = currentAssetId.value
let redirectId = deviceId
if (isEdit.value) {
await networkApi.update(deviceId, payload)
router.push(`/network/${deviceId}`)
const response = await networkApi.update(deviceId, payload)
assetId = assetId || response.data?.data?.assetid
} else {
const response = await networkApi.create(payload)
const newId = response.data.data?.networkdevice?.networkdeviceid
router.push(newId ? `/network/${newId}` : '/network')
assetId = response.data.data?.assetid
redirectId = response.data.data?.networkdevice?.networkdeviceid
}
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push(redirectId ? `/network/${redirectId}` : '/network')
} catch (err) {
console.error('Error saving device:', err)
error.value = err.response?.data?.message || 'Failed to save device'

View File

@@ -79,7 +79,7 @@
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
</td>
<td>
<span class="badge" :class="getStatusClass(device.statusname)">
<span class="badge" :style="colorStyle(device.statuscolor)">
{{ device.statusname || 'Unknown' }}
</span>
</td>
@@ -117,6 +117,7 @@
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { networkApi, vendorsApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'

View File

@@ -31,7 +31,7 @@
</div>
<div class="form-group">
<label for="notification">{{ isRecognition ? 'Recognition Message' : 'Notification' }} *</label>
<label for="notification">{{ messageLabel }} *</label>
<textarea
id="notification"
v-model="form.notification"
@@ -39,12 +39,12 @@
rows="4"
required
:disabled="isDetail"
:placeholder="isRecognition ? 'Enter the recognition message...' : 'Enter the notification message...'"
:placeholder="messagePlaceholder"
></textarea>
</div>
<!-- Employee Search - Only for Recognition -->
<div v-if="isRecognition" class="form-group">
<!-- Employee Search - for Recognition and Recertification -->
<div v-if="isEmployeeType" class="form-group">
<label>Employee(s) *</label>
<div class="employee-search-container">
<input
@@ -68,7 +68,7 @@
</div>
</div>
</div>
<small class="form-hint">Search for employees or press Enter to add a custom name</small>
<small class="form-hint">Search and pick, or paste multiple SSOs / names separated by commas and press Enter</small>
<!-- Selected Employees -->
<div v-if="selectedEmployees.length" class="selected-employees">
@@ -83,7 +83,7 @@
</div>
</div>
<div v-if="!isRecognition" class="form-group">
<div v-if="!isEmployeeType" class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
@@ -103,7 +103,7 @@
<small class="form-hint">Leave blank to apply to all</small>
</div>
<div v-if="!isRecognition" class="form-group">
<div v-if="!isEmployeeType" class="form-group">
<label for="appid">Related Application</label>
<select
id="appid"
@@ -123,7 +123,7 @@
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
</div>
<div v-if="!isRecognition" class="form-group">
<div v-if="!isEmployeeType" class="form-group">
<label for="ticketnumber">Ticket Number</label>
<input
id="ticketnumber"
@@ -151,7 +151,7 @@
</div>
<!-- Time fields - Hidden for Recognition (auto-set) -->
<div v-if="!isRecognition" class="form-row">
<div v-if="!isEmployeeType" class="form-row">
<div class="form-group">
<label for="starttime">Start Time *</label>
<div class="input-group">
@@ -275,11 +275,28 @@ const form = ref({
employeesso: ''
})
// Check if selected type is Recognition
const isRecognition = computed(() => {
function selectedTypeName() {
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
return selectedType?.typename?.toLowerCase() === 'recognition'
})
return selectedType?.typename?.toLowerCase() || ''
}
// Recognition and recertification are the employee-photo types: both show the
// employee picker, hide the time/BU/app fields, and get a server-computed
// display window.
const isRecognition = computed(() => selectedTypeName() === 'recognition')
const isRecertification = computed(() => selectedTypeName() === 'recertification')
const isEmployeeType = computed(() => isRecognition.value || isRecertification.value)
const messageLabel = computed(() =>
isRecertification.value ? 'Recertification Message'
: isRecognition.value ? 'Recognition Message'
: 'Notification'
)
const messagePlaceholder = computed(() =>
isRecertification.value ? 'Enter the recertification message...'
: isRecognition.value ? 'Enter the recognition message...'
: 'Enter the notification message...'
)
onMounted(async () => {
try {
@@ -313,16 +330,18 @@ onMounted(async () => {
employeesso: n.employeesso || ''
}
// Parse existing employee data
// Parse existing employee data: SSOs are comma-joined and names are
// ", "-joined, so pair them up by index (one chip per person). Falling
// back to the SSO when a name is missing.
if (n.employeesso) {
const ssos = n.employeesso.split(',')
for (const sso of ssos) {
if (sso.trim()) {
// Try to look up the employee name
const name = n.employeename || sso.trim()
selectedEmployees.value.push({ sso: sso.trim(), name })
}
}
const names = (n.employeename || '').split(',')
ssos.forEach((rawSso, i) => {
const sso = rawSso.trim()
if (!sso) return
const name = (names[i] || '').trim() || sso
selectedEmployees.value.push({ sso, name })
})
}
} else {
// Set default start date to now
@@ -353,13 +372,12 @@ function setNow(field) {
}
function onTypeChange() {
// If switching to Recognition, auto-set times
if (isRecognition.value) {
const now = new Date()
form.value.starttime = formatDateForInput(now.toISOString())
// End time = now + 30 days
const endDate = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000)
form.value.endtime = formatDateForInput(endDate.toISOString())
// Recognition and recertification get a server-computed display window
// (recognition clears at 8 AM Eastern; recertification runs two weeks). Set
// start to now and leave end blank so the backend applies the per-type rule.
if (isEmployeeType.value) {
form.value.starttime = formatDateForInput(new Date().toISOString())
form.value.endtime = ''
}
}
@@ -401,15 +419,31 @@ function selectEmployee(emp) {
updateEmployeeSso()
}
function addCustomEmployee() {
const name = employeeSearch.value.trim()
if (!name) return
// Add one or many at once. The input may hold a single value or a
// comma-separated list of SSOs and/or names. Numeric tokens are treated as
// SSOs and resolved to a real name; everything else is a custom name.
async function addCustomEmployee() {
const raw = employeeSearch.value.trim()
if (!raw) return
// Add as custom name (no SSO)
selectedEmployees.value.push({
sso: `NAME:${name}`,
name: name
})
const tokens = raw.split(',').map(t => t.trim()).filter(Boolean)
for (const token of tokens) {
if (/^\d{4,}$/.test(token)) {
if (selectedEmployees.value.some(e => e.sso === token)) continue
let name = token
try {
const emp = (await employeesApi.lookup(token)).data.data
if (emp) name = `${emp.First_Name} ${emp.Last_Name}`.trim() || token
} catch (err) {
// SSO not found - keep the number as the label
}
selectedEmployees.value.push({ sso: token, name })
} else {
const key = `NAME:${token}`
if (selectedEmployees.value.some(e => e.sso === key)) continue
selectedEmployees.value.push({ sso: key, name: token })
}
}
employeeSearch.value = ''
employeeResults.value = []
@@ -428,9 +462,9 @@ function updateEmployeeSso() {
async function saveNotification() {
error.value = ''
// Validation for Recognition type
if (isRecognition.value && selectedEmployees.value.length === 0) {
error.value = 'Please select at least one employee for recognition'
// Employee-photo types require at least one employee
if (isEmployeeType.value && selectedEmployees.value.length === 0) {
error.value = `Please select at least one employee for ${isRecertification.value ? 'recertification' : 'recognition'}`
return
}
@@ -451,8 +485,8 @@ async function saveNotification() {
employeesso: form.value.employeesso || null
}
// For recognition, also send employeename
if (isRecognition.value && selectedEmployees.value.length > 0) {
// For employee-photo types, also send employeename
if (isEmployeeType.value && selectedEmployees.value.length > 0) {
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
}

View File

@@ -0,0 +1,349 @@
<template>
<div>
<div class="page-header">
<h1>Notification Types</h1>
<div class="actions">
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
<button class="btn btn-primary" @click="openNew">New Type</button>
</div>
</div>
<div class="card">
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Color</th>
<th>Display style</th>
<th>Employee</th>
<th>Auto-expiry</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="t in types" :key="t.notificationtypeid">
<td>
<strong>{{ t.typename }}</strong>
<div v-if="t.typedescription" class="muted">{{ t.typedescription }}</div>
</td>
<td>
<span class="swatch" :style="{ backgroundColor: swatchColor(t.typecolor) }"></span>
<span class="mono">{{ t.typecolor }}</span>
</td>
<td><span class="badge">{{ t.displaystyle || 'standard' }}</span></td>
<td>
<span v-if="t.splitperemployee" class="badge badge-success">split</span>
<span v-if="t.showemployeephoto" class="badge badge-success">photo</span>
<span v-if="!t.splitperemployee && !t.showemployeephoto" class="muted">-</span>
</td>
<td>{{ expiryLabel(t) }}</td>
<td>
<span class="badge" :class="t.isactive ? 'badge-success' : 'badge-secondary'">
{{ t.isactive ? 'yes' : 'no' }}
</span>
</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openEdit(t)">Edit</button>
</td>
</tr>
<tr v-if="!loading && !types.length">
<td colspan="7" class="muted" style="text-align:center;">No notification types.</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Editor -->
<div v-if="editing" class="modal-overlay" @click.self="close">
<div class="modal-panel">
<h2>{{ form.notificationtypeid ? 'Edit' : 'New' }} Notification Type</h2>
<div class="form-grid">
<label class="field">
<span>Name</span>
<input v-model="form.typename" type="text" maxlength="50" placeholder="e.g. Safety Alert" />
</label>
<label class="field">
<span>Description</span>
<input v-model="form.typedescription" type="text" placeholder="Shown to editors" />
</label>
<label class="field">
<span>Color</span>
<ColorSwatchPicker v-model="form.typecolor" />
</label>
<label class="field">
<span>Display style</span>
<select v-model="form.displaystyle">
<option value="standard">Standard rows</option>
<option value="carousel">Carousel (rotating photo card)</option>
<option value="grid">Grid (cycling row of tiles)</option>
<option value="banner">Banner (full-width strip)</option>
</select>
</label>
<label class="field checkbox">
<input v-model="form.splitperemployee" type="checkbox" />
<span>Split one card per employee</span>
</label>
<label class="field checkbox">
<input v-model="form.showemployeephoto" type="checkbox" />
<span>Show employee photo + name (HR lookup)</span>
</label>
<label class="field">
<span>Auto-expiry</span>
<select v-model="form.expirymode">
<option value="none">None (stays until end time / indefinite)</option>
<option value="duration">Duration (N days after posting)</option>
<option value="dailytime">Daily reset (clears at an hour, Eastern)</option>
</select>
</label>
<label v-if="form.expirymode === 'duration'" class="field">
<span>Days</span>
<input v-model.number="form.expirydays" type="number" min="1" />
</label>
<label v-if="form.expirymode === 'dailytime'" class="field">
<span>Hour (0-23, Eastern)</span>
<input v-model.number="form.expiryhour" type="number" min="0" max="23" />
</label>
<label class="field checkbox">
<input v-model="form.isactive" type="checkbox" />
<span>Active</span>
</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="close">Cancel</button>
<button class="btn btn-primary" :disabled="saving || !form.typename" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { notificationsApi } from '@/api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
const types = ref([])
const loading = ref(true)
const editing = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
// Keyword typecolors the shopfloor board maps to fixed accent colors; anything
// else is a literal hex.
const KEYWORD_COLORS = {
recognition: '#ffc107',
recertification: '#0d6efd',
training: '#17a2b8',
success: '#04b962',
warning: '#ff8800',
danger: '#f5365c',
info: '#14abef',
primary: '#7934f3'
}
function isHex(c) {
return typeof c === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(c)
}
function swatchColor(c) {
if (!c) return '#888'
return KEYWORD_COLORS[c] || c
}
function expiryLabel(t) {
if (t.expirymode === 'duration' && t.expirydays) return `${t.expirydays} day(s)`
if (t.expirymode === 'dailytime') return `daily @ ${String(t.expiryhour ?? 8).padStart(2, '0')}:00 ET`
return 'none'
}
async function load() {
loading.value = true
try {
const response = await notificationsApi.types.list()
types.value = response.data.data || []
} catch (err) {
console.error('Error loading notification types:', err)
} finally {
loading.value = false
}
}
function openNew() {
error.value = ''
form.value = {
typename: '',
typedescription: '',
typecolor: '#17a2b8',
displaystyle: 'standard',
splitperemployee: false,
showemployeephoto: false,
expirymode: 'none',
expirydays: null,
expiryhour: null,
isactive: true
}
editing.value = true
}
function openEdit(t) {
error.value = ''
form.value = {
notificationtypeid: t.notificationtypeid,
typename: t.typename || '',
typedescription: t.typedescription || '',
typecolor: t.typecolor || '#17a2b8',
displaystyle: t.displaystyle || 'standard',
splitperemployee: !!t.splitperemployee,
showemployeephoto: !!t.showemployeephoto,
expirymode: t.expirymode || 'none',
expirydays: t.expirydays ?? null,
expiryhour: t.expiryhour ?? null,
isactive: t.isactive !== false
}
editing.value = true
}
function close() {
editing.value = false
}
async function save() {
saving.value = true
error.value = ''
const payload = { ...form.value }
try {
if (payload.notificationtypeid) {
await notificationsApi.types.update(payload.notificationtypeid, payload)
} else {
await notificationsApi.types.create(payload)
}
editing.value = false
await load()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Save failed.'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.muted {
color: var(--text-light);
font-size: 0.85rem;
}
.mono {
font-family: monospace;
font-size: 0.85rem;
}
.swatch {
display: inline-block;
width: 16px;
height: 16px;
border-radius: 3px;
vertical-align: middle;
margin-right: 6px;
border: 1px solid var(--border);
}
.swatch.lg {
width: 28px;
height: 28px;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
z-index: 1000;
}
.modal-panel {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 100%;
max-width: 560px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.modal-panel h2 {
margin: 0 0 18px;
}
.form-grid {
display: flex;
flex-direction: column;
gap: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-size: 0.85rem;
color: var(--text-light);
}
.field input[type="text"],
.field input[type="number"],
.field select {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.field.checkbox {
flex-direction: row;
align-items: center;
gap: 8px;
}
.field.checkbox > span {
color: var(--text);
font-size: 1rem;
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
}
.color-row input[type="text"] {
flex: 1;
}
.error {
color: var(--danger);
margin: 12px 0 0;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 22px;
}
</style>

View File

@@ -1,7 +1,10 @@
<template>
<div class="page-header">
<h1>Notifications</h1>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
<div class="actions">
<router-link to="/settings/notificationtypes" class="btn btn-secondary">Manage Types</router-link>
<router-link to="/notifications/new" class="btn btn-primary">New Notification</router-link>
</div>
</div>
<div class="filters">
@@ -45,7 +48,7 @@
<tbody>
<tr v-for="notification in notifications" :key="notification.notificationid">
<td>
<router-link :to="`/notifications/${notification.notificationid}`">
<router-link :to="`/notifications/${notification.notificationid}/edit`">
{{ notification.title }}
</router-link>
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>

View File

@@ -20,7 +20,7 @@
</div>
<div class="hero-meta">
<span class="badge badge-lg badge-info">Computer</span>
<span class="badge badge-lg" :class="getStatusClass(computer.statusname)">
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
{{ computer.statusname || 'Unknown' }}
</span>
</div>
@@ -84,6 +84,14 @@
<span class="info-label">Computer Type</span>
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Vendor</span>
<span class="info-value">{{ computer.computer?.vendorname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Model</span>
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Operating System</span>
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
@@ -91,36 +99,58 @@
</div>
</div>
<!-- PC Status -->
<!-- Remote Access -->
<div class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row" v-if="computer.computer?.loggedinuser">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer.loggedinuser }}</span>
</div>
<div class="info-row">
<span class="info-label">Features</span>
<span class="info-value">
<span class="feature-tag" :class="{ active: computer.computer?.isvnc }">VNC</span>
<span class="feature-tag" :class="{ active: computer.computer?.iswinrm }">WinRM</span>
<span class="feature-tag" :class="{ active: computer.computer?.isshopfloor }">Shopfloor</span>
</span>
</div>
<div class="info-row" v-if="computer.computer?.lastreporteddate">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ formatDate(computer.computer.lastreporteddate) }}</span>
</div>
<div class="info-row" v-if="computer.computer?.lastboottime">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ formatDate(computer.computer.lastboottime) }}</span>
</div>
<h3 class="section-title">Remote Access</h3>
<div class="access-methods">
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
</template>
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
</div>
</div>
</div>
<!-- Right Column -->
<div class="content-column">
<!-- Network -->
<div class="section-card">
<h3 class="section-title">Network</h3>
<div v-if="computer.communications?.length" class="network-list">
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
<div class="network-primary">
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
</div>
<div class="network-secondary" v-if="comm.macaddress">
<span class="mac-address mono">{{ comm.macaddress }}</span>
</div>
</div>
</div>
<p v-else class="muted">No network addresses on record</p>
</div>
<!-- Status / Check-in -->
<div class="section-card">
<h3 class="section-title">Status</h3>
<div class="info-list">
<div class="info-row">
<span class="info-label">Logged In User</span>
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Reported</span>
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
</div>
<div class="info-row">
<span class="info-label">Last Boot</span>
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
</div>
</div>
</div>
<!-- Location -->
<div class="section-card">
<h3 class="section-title">Location</h3>
@@ -189,6 +219,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="computer.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="computer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="computer.notes">
<h3 class="section-title">Notes</h3>
@@ -212,9 +248,12 @@
<script setup>
import { ref, onMounted, computed } from 'vue'
import { colorStyle } from "@/utils/colorStyle"
import { useRoute } from 'vue-router'
import { computersApi, applicationsApi, assetsApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
@@ -301,6 +340,36 @@ function formatDate(dateStr) {
<style scoped>
/* PC-specific styles - shared styles are in global style.css */
/* Remote-access protocol links */
.access-methods {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.access-methods .muted {
color: var(--text-light);
}
.access-link {
display: inline-block;
padding: 3px 12px;
margin: 0 6px 4px 0;
border-radius: 14px;
background: var(--primary);
color: #fff;
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
/* Installed Applications */
.app-list {
display: flex;
@@ -370,4 +439,46 @@ function formatDate(dateStr) {
font-size: 1rem;
color: var(--text-light);
}
/* Network card (IP / MAC list) */
.network-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.network-item {
padding: 0.5rem 0.7rem;
background: var(--bg);
border-radius: 6px;
}
.network-primary {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.ip-address {
font-weight: 600;
color: var(--text);
}
.comm-type {
font-size: 0.8rem;
color: var(--text-light);
}
.primary-badge {
padding: 0.1rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
background: var(--primary);
color: #fff;
border-radius: 10px;
}
.mac-address {
font-size: 0.82rem;
color: var(--text-light);
}
.muted {
color: var(--text-light);
margin: 0;
}
</style>

View File

@@ -223,16 +223,25 @@
</div>
</div>
<div class="form-row">
<div class="form-group" style="display: flex; align-items: flex-end; gap: 1.5rem;">
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<input type="checkbox" v-model="form.isvnc" />
VNC Enabled
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
<input type="checkbox" v-model="form.iswinrm" />
WinRM Enabled
<div class="form-group">
<label>Remote Access Protocols</label>
<div class="protocol-list">
<label v-for="p in protocols" :key="p.protocolid" class="protocol-item">
<input type="checkbox" :checked="isProtocolOn(p.protocolid)" @change="toggleProtocol(p.protocolid, $event.target.checked)" />
<span>{{ p.name }}</span>
<input
v-if="isProtocolOn(p.protocolid)"
type="number"
class="port-override"
:value="protocolPort(p.protocolid)"
:placeholder="p.defaultport || 'port'"
min="1"
max="65535"
title="Port override (blank = default)"
@input="setProtocolPort(p.protocolid, $event.target.value)"
/>
</label>
<span v-if="!protocols.length" class="muted">No protocols defined. Add them under Settings &gt; PC Access Protocols.</span>
</div>
</div>
@@ -276,6 +285,9 @@
</template>
</Modal>
<!-- Site-defined custom fields for computers -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="COMPUTER_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -295,6 +307,7 @@ import { useRoute, useRouter } from 'vue-router'
import { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
@@ -305,6 +318,12 @@ const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
// values are keyed by the underlying asset id, captured on load / create.
const COMPUTER_ASSETTYPEID = 2
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
// PC Number (assetnumber) defaults to the serial number while the user hasn't
// typed their own. Editable; only auto-fills on a new PC.
const manualPcNumber = ref(false)
@@ -332,8 +351,7 @@ const form = ref({
locationid: '',
osid: '',
loggedinuser: '',
isvnc: false,
iswinrm: false,
accessmethods: [],
notes: '',
mapx: null,
mapy: null,
@@ -341,7 +359,33 @@ const form = ref({
})
const pcTypes = ref([])
const protocols = ref([])
const statuses = ref([])
// Access-method editor helpers (form.accessmethods = [{protocolid, portoverride}])
function isProtocolOn(protocolid) {
return form.value.accessmethods.some(a => a.protocolid === protocolid)
}
function protocolPort(protocolid) {
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
return found && found.portoverride != null ? found.portoverride : ''
}
function toggleProtocol(protocolid, on) {
if (on) {
if (!isProtocolOn(protocolid)) {
form.value.accessmethods.push({ protocolid, portoverride: null })
}
} else {
form.value.accessmethods = form.value.accessmethods.filter(a => a.protocolid !== protocolid)
}
}
function setProtocolPort(protocolid, value) {
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
if (found) {
const n = parseInt(value, 10)
found.portoverride = Number.isFinite(n) ? n : null
}
}
const vendors = ref([])
const models = ref([])
const locations = ref([])
@@ -366,13 +410,14 @@ onMounted(async () => {
try {
// Load reference data
// perpage 100 so dropdowns aren't truncated to the default 20-row page
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes] = await Promise.all([
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes] = await Promise.all([
computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
locationsApi.list({ perpage: 100 }),
operatingsystemsApi.list({ perpage: 100 })
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list()
])
pcTypes.value = ptRes.data.data || []
@@ -381,12 +426,14 @@ onMounted(async () => {
models.value = allModels
locations.value = locRes.data.data || []
operatingsystems.value = osRes.data.data || []
protocols.value = protoRes.data.data || []
// Load PC if editing (asset-based shape: extension under pc.computer)
if (isEdit.value) {
const response = await computersApi.get(route.params.id)
const pc = response.data.data
const ext = pc.computer || {}
currentAssetId.value = pc.assetid || null
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
@@ -404,8 +451,10 @@ onMounted(async () => {
locationid: pc.locationid || '',
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
isvnc: ext.isvnc || false,
iswinrm: ext.iswinrm || false,
accessmethods: (pc.accessmethods || []).map(a => ({
protocolid: a.protocolid,
portoverride: a.portoverride ?? null
})),
notes: pc.notes || '',
mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null,
@@ -458,8 +507,7 @@ async function savePC() {
locationid: form.value.locationid || null,
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null,
isvnc: form.value.isvnc,
iswinrm: form.value.iswinrm,
accessmethods: form.value.accessmethods,
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx,
@@ -470,10 +518,22 @@ async function savePC() {
payload.name = form.value.alias
}
let assetId = currentAssetId.value
if (isEdit.value) {
await computersApi.update(route.params.id, payload)
const response = await computersApi.update(route.params.id, payload)
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
} else {
await computersApi.create(payload)
const response = await computersApi.create(payload)
assetId = response.data?.data?.assetid || response.data?.data?.asset?.assetid
}
// Persist any custom-field values now that we have an asset id.
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push('/pcs')
@@ -487,6 +547,28 @@ async function savePC() {
</script>
<style scoped>
.protocol-list {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.protocol-item {
display: flex;
align-items: center;
gap: 6px;
}
.protocol-item .port-override {
width: 78px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.muted {
color: var(--text-light);
}
.map-location-control {
display: flex;
align-items: center;

View File

@@ -28,7 +28,7 @@
<th>Hostname</th>
<th>Serial Number</th>
<th>Type</th>
<th>Features</th>
<th>Remote Access</th>
<th>Status</th>
<th>Location</th>
<th>Actions</th>
@@ -41,12 +41,14 @@
<td class="mono">{{ item.serialnumber || '-' }}</td>
<td>{{ item.computer?.computertypename || '-' }}</td>
<td class="features">
<span v-if="item.computer?.isvnc" class="feature-tag active">VNC</span>
<span v-if="item.computer?.iswinrm" class="feature-tag active">WinRM</span>
<span v-if="!item.computer?.isvnc && !item.computer?.iswinrm">-</span>
<template v-for="a in (item.accessmethods || [])" :key="a.id">
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
</template>
<span v-if="!(item.accessmethods || []).length">-</span>
</td>
<td>
<span class="badge" :class="getStatusClass(item.statusname)">
<span class="badge" :style="colorStyle(item.statuscolor)">
{{ item.statusname || 'Unknown' }}
</span>
</td>
@@ -86,6 +88,7 @@
import { ref, onMounted } from 'vue'
import { computersApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { colorStyle } from '@/utils/colorStyle'
const computers = ref([])
const loading = ref(true)
@@ -138,17 +141,30 @@ function changePerPage(newPerPage) {
loadComputers()
}
function getStatusClass(status) {
if (!status) return 'badge-info'
const s = status.toLowerCase()
if (s === 'in use' || s === 'active') return 'badge-success'
if (s === 'in repair') return 'badge-warning'
if (s === 'retired') return 'badge-danger'
return 'badge-info'
}
</script>
<style scoped>
.access-link {
display: inline-block;
padding: 2px 10px;
margin: 0 4px 3px 0;
border-radius: 12px;
background: var(--primary);
color: #fff;
font-size: 0.78rem;
font-weight: 600;
text-decoration: none;
}
.access-link:hover {
background: var(--primary-dark);
text-decoration: none;
}
.access-link.disabled {
background: var(--secondary);
opacity: 0.5;
cursor: not-allowed;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}

View File

@@ -66,6 +66,7 @@
import { ref, computed, onMounted, watch, nextTick } from 'vue'
import { printersApi } from '../../api'
import { renderQrDataUrl } from './qrLogo'
import { getSiteBaseUrl } from '@/utils/siteSettings'
const printers = ref([])
const selectedPrinters = ref([])
@@ -105,6 +106,7 @@ 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]
@@ -112,7 +114,7 @@ async function generateQRCodes() {
const printer = page[idx]
if (!printer) continue
const pos = idx + 1
const qrUrl = `${window.location.origin}/printers/${printer.printer?.printerid || printer.assetid}`
const qrUrl = `${baseUrl}/printers/${printer.printer?.printerid || printer.assetid}`
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
}
}

View File

@@ -48,6 +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'
const route = useRoute()
const loading = ref(true)
@@ -86,7 +87,8 @@ watch(position, async () => {
async function generateQR() {
if (!printer.value) return
const qrUrl = `${window.location.origin}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
const baseUrl = await getSiteBaseUrl()
const qrUrl = `${baseUrl}/printers/${printer.value.printer?.printerid || printer.value.assetid}`
qrImage.value = await renderQrDataUrl(qrUrl)
}

View File

@@ -117,6 +117,12 @@
</div>
</div>
<!-- Custom Fields -->
<CustomFieldsSection :assetid="printer.assetid" />
<!-- Warranty -->
<WarrantyPanel :assetid="printer.assetid" />
<!-- Notes -->
<div class="section-card" v-if="printer.notes">
<h3 class="section-title">Notes</h3>
@@ -209,32 +215,34 @@
</div>
</div>
<!-- Drivers Card -->
<!-- Drivers Card: pulled from the driver catalog by this printer's model -->
<div class="card">
<div class="card-header">
<h3>Assigned Drivers</h3>
<h3>Drivers</h3>
</div>
<div v-if="drivers.length === 0" class="empty-state">
No drivers assigned
No drivers for this model. Add one under Settings &gt; Printer Drivers and
link it to this printer's model.
</div>
<div v-else class="table-container">
<table>
<thead>
<tr>
<th>Driver Name</th>
<th>OS Type</th>
<th>Version</th>
<th>Universal</th>
<th>Name</th>
<th>Location</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr v-for="driver in drivers" :key="driver.driverid">
<td>{{ driver.drivername }}</td>
<td>{{ driver.ostype }}</td>
<td>{{ driver.version || '-' }}</td>
<td>{{ driver.isuniversal ? 'Yes' : 'No' }}</td>
<td><strong>{{ driver.name }}</strong></td>
<td>
<a v-if="isHttp(driver.location)" :href="driver.location" target="_blank" class="mono">{{ driver.location }}</a>
<span v-else class="mono">{{ driver.location }}</span>
</td>
<td>{{ driver.description || '-' }}</td>
</tr>
</tbody>
</table>
@@ -253,6 +261,8 @@ import { ref, onMounted, computed } from 'vue'
import { useRoute } from 'vue-router'
import { printersApi } from '../../api'
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
import WarrantyPanel from '../../components/WarrantyPanel.vue'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const route = useRoute()
@@ -273,6 +283,10 @@ const displayTitle = computed(() => {
return p.printer?.windowsname || p.printer?.hostname || p.assetnumber
})
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
// Get IP address from communications
const ipAddress = computed(() => {
if (!printer.value?.communications) return null
@@ -282,16 +296,16 @@ const ipAddress = computed(() => {
onMounted(async () => {
try {
const [printerRes, suppliesRes, driversRes] = await Promise.all([
const [printerRes, suppliesRes] = await Promise.all([
printersApi.get(route.params.id),
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } })),
printersApi.getDrivers(route.params.id).catch(() => ({ data: { data: [] } }))
printersApi.getSupplies(route.params.id).catch(() => ({ data: { data: [] } }))
])
printer.value = printerRes.data.data
// supplies endpoint returns {ipaddress, pingstatus, supplies:[...]}
supplies.value = suppliesRes.data.data?.supplies || []
drivers.value = driversRes.data.data || []
// drivers are attached to the printer detail, matched by model
drivers.value = printerRes.data.data?.drivers || []
} catch (error) {
console.error('Error loading printer:', error)
} finally {
@@ -330,6 +344,8 @@ function formatDate(dateStr) {
}
/* Supplies */
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
.supplies-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));

View File

@@ -273,6 +273,9 @@
</template>
</Modal>
<!-- Site-defined custom fields for printers -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="PRINTER_ASSETTYPEID" :assetid="currentAssetId" />
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -292,6 +295,7 @@ import { useRoute, useRouter } from 'vue-router'
import { assetsApi, vendorsApi, locationsApi, printersApi, modelsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
import { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
@@ -301,6 +305,11 @@ const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for printers (see /api/assets/types).
const PRINTER_ASSETTYPEID = 4
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
const manualHostname = ref(false)
const manualWindowsName = ref(false)
@@ -473,6 +482,7 @@ onMounted(async () => {
if (isEdit.value) {
const response = await printersApi.get(route.params.id)
const printer = response.data.data
currentAssetId.value = printer.assetid || null
// asset-based shape: printer extension fields live under printer.printer
const ext = printer.printer || {}
@@ -566,10 +576,21 @@ async function savePrinter() {
payload.name = form.value.alias
}
let assetId = currentAssetId.value
if (isEdit.value) {
await printersApi.update(route.params.id, payload)
const response = await printersApi.update(route.params.id, payload)
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
} else {
await printersApi.create(payload)
const response = await printersApi.create(payload)
assetId = response.data?.data?.assetid || response.data?.data?.asset?.assetid
}
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
router.push('/printers')

View File

@@ -9,6 +9,11 @@
<p>View printers with low or critical toner/supply levels</p>
<span class="badge">Printers</span>
</div>
<div class="report-card card" @click="router.push('/reports/warranty')">
<h3>Warranty Report</h3>
<p>Assets bucketed by coverage: expired, expiring soon, active</p>
<span class="badge">Warranty</span>
</div>
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
<h3>{{ report.name }}</h3>
<p>{{ report.description }}</p>

View File

@@ -0,0 +1,103 @@
<template>
<div>
<div class="page-header">
<h1>Warranty Report</h1>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="summary-row">
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
<span class="summary-label">{{ b.label }}</span>
</div>
</div>
<template v-for="b in bucketOrder" :key="b.key">
<div class="bucket card" v-if="(buckets[b.key] || []).length">
<h3 class="bucket-title">
<span class="dot" :style="{ background: b.color }"></span>
{{ b.label }} ({{ (buckets[b.key] || []).length }})
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Service Level</th>
<th>Ends</th>
<th>Covers</th>
</tr>
</thead>
<tbody>
<tr v-for="w in buckets[b.key]" :key="w.warrantyid">
<td><strong>{{ w.vendor }}</strong></td>
<td>{{ w.servicelevel || '-' }}</td>
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
<span v-if="!w.assets.length" class="muted">-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { warrantyApi } from '../../api'
const loading = ref(true)
const counts = ref({})
const buckets = ref({})
const bucketOrder = [
{ key: 'expired', label: 'Expired', color: '#F44336' },
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },
{ key: 'active', label: 'Active', color: '#4CAF50' },
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
]
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
return (map[a.assettypename] || '/assets/') + a.assetid
}
onMounted(async () => {
try {
const response = await warrantyApi.report()
counts.value = response.data.data.counts || {}
buckets.value = response.data.data.buckets || {}
} catch (err) {
console.error('Error loading warranty report:', err)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.summary-card {
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
}
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
.summary-label { font-size: 0.85rem; color: var(--text-light); }
.bucket { margin-bottom: 1.25rem; }
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.asset-chip {
display: inline-block; padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
}
.muted { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,219 @@
<template>
<div>
<div class="page-header">
<h1>PC Access Protocols</h1>
<div class="actions">
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
<button class="btn btn-primary" @click="openNew">New Protocol</button>
</div>
</div>
<div class="card">
<p class="hint">
Remote-access protocols offered on PCs. Links are built as
<code>{{ '{scheme}://{hostname}.<pc_access_domain>:{port}' }}</code> from
each protocol's template. Set the domain in
<router-link to="/settings/site">Site &amp; Facility</router-link>.
</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Scheme</th>
<th>Default port</th>
<th>Link template</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in protocols" :key="p.protocolid">
<td><strong>{{ p.name }}</strong></td>
<td class="mono">{{ p.scheme }}</td>
<td>{{ p.defaultport ?? '-' }}</td>
<td class="mono">{{ p.linktemplate }}</td>
<td>
<span class="badge" :class="p.isactive ? 'badge-success' : 'badge-secondary'">
{{ p.isactive ? 'yes' : 'no' }}
</span>
</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openEdit(p)">Edit</button>
<button class="btn btn-sm btn-danger" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="!loading && !protocols.length">
<td colspan="6" class="muted" style="text-align:center;">No protocols.</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="editing" class="modal-overlay" @click.self="close">
<div class="modal-panel">
<h2>{{ form.protocolid ? 'Edit' : 'New' }} Protocol</h2>
<div class="form-grid">
<label class="field">
<span>Name</span>
<input v-model="form.name" type="text" maxlength="50" placeholder="e.g. VNC" />
</label>
<label class="field">
<span>Scheme</span>
<input v-model="form.scheme" type="text" maxlength="20" placeholder="vnc / rdp / https / ssh" />
</label>
<label class="field">
<span>Default port</span>
<input v-model.number="form.defaultport" type="number" min="1" max="65535" placeholder="5900" />
</label>
<label class="field">
<span>Link template</span>
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
</label>
<label class="field checkbox">
<input v-model="form.isactive" type="checkbox" />
<span>Active</span>
</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="close">Cancel</button>
<button class="btn btn-primary" :disabled="saving || !formValid" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { computersApi } from '@/api'
const protocols = ref([])
const loading = ref(true)
const editing = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
const formValid = computed(() =>
form.value.name && form.value.scheme && form.value.linktemplate
)
async function load() {
loading.value = true
try {
const response = await computersApi.protocols.list({ active: false })
protocols.value = response.data.data || []
} catch (err) {
console.error('Error loading protocols:', err)
} finally {
loading.value = false
}
}
function openNew() {
error.value = ''
form.value = { name: '', scheme: '', defaultport: null, linktemplate: '', isactive: true }
editing.value = true
}
function openEdit(p) {
error.value = ''
form.value = {
protocolid: p.protocolid,
name: p.name,
scheme: p.scheme,
defaultport: p.defaultport ?? null,
linktemplate: p.linktemplate,
isactive: p.isactive !== false
}
editing.value = true
}
function close() {
editing.value = false
}
async function save() {
saving.value = true
error.value = ''
try {
if (form.value.protocolid) {
await computersApi.protocols.update(form.value.protocolid, form.value)
} else {
await computersApi.protocols.create(form.value)
}
editing.value = false
await load()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Save failed.'
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete protocol "${p.name}"? (kept but deactivated if any PC uses it)`)) return
try {
await computersApi.protocols.remove(p.protocolid)
await load()
} catch (err) {
console.error('Error deleting protocol:', err)
}
}
onMounted(load)
</script>
<style scoped>
.hint {
color: var(--text-light);
font-size: 0.9rem;
margin: 0 0 14px;
}
.mono { font-family: monospace; font-size: 0.85rem; }
.muted { color: var(--text-light); }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
z-index: 1000;
}
.modal-panel {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 100%;
max-width: 520px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.modal-panel h2 { margin: 0 0 18px; }
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field > span { font-size: 0.85rem; color: var(--text-light); }
.field input[type="text"],
.field input[type="number"] {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.field.checkbox { flex-direction: row; align-items: center; gap: 8px; }
.field.checkbox > span { color: var(--text); font-size: 1rem; }
.error { color: var(--danger); margin: 12px 0 0; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<div class="page-header">
<h2>Asset Type Colors</h2>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<p class="hint">
Top-level asset categories are defined by their plugins, so you can set
their color + description here (used for map markers/legend), but not add
or remove them.
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.assettypeid">
<td><strong>{{ t.assettype }}</strong></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>Edit {{ form.assettype }}</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { assetsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const loading = ref(true)
const showModal = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await assetsApi.types.list()
items.value = response.data.data || []
} catch (err) {
console.error('Error loading asset types:', err)
} finally {
loading.value = false
}
}
function openModal(t) {
form.value = { assettypeid: t.assettypeid, assettype: t.assettype, description: t.description || '', color: t.color || '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false }
async function save() {
error.value = ''
saving.value = true
try {
await assetsApi.types.update(form.value.assettypeid, { description: form.value.description, color: form.value.color })
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
</style>

View File

@@ -0,0 +1,221 @@
<template>
<div>
<div class="page-header">
<h2>Custom Fields</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" :disabled="!assettypeid" @click="openModal()">+ Add Field</button>
</div>
<div class="card">
<p class="hint">
Define extra attributes per asset type. They appear on that asset's detail
page and edit form. Use these instead of asking for a schema change.
</p>
<div class="form-group type-picker">
<label>Asset Type</label>
<select v-model="assettypeid" class="form-control" @change="loadFields">
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettypeid">
{{ typeLabel(t) }}
</option>
</select>
</div>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Label</th>
<th>Key</th>
<th>Type</th>
<th>On Detail</th>
<th>On Form</th>
<th>Order</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="f in visibleFields" :key="f.fieldid">
<td><strong>{{ f.label }}</strong></td>
<td class="mono">{{ f.fieldkey }}</td>
<td>
{{ f.datatype }}
<span v-if="f.datatype === 'select' && f.options.length" class="muted">({{ f.options.join(', ') }})</span>
</td>
<td>{{ f.showondetail ? 'yes' : '-' }}</td>
<td>{{ f.showonform ? 'yes' : '-' }}</td>
<td>{{ f.sortorder }}</td>
<td>
<span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(f)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteField(f)">Delete</button>
</td>
</tr>
<tr v-if="visibleFields.length === 0">
<td colspan="8" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Field</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Label *</label>
<input v-model="form.label" type="text" class="form-control" maxlength="150" required />
</div>
<div class="form-group">
<label>Data Type</label>
<select v-model="form.datatype" class="form-control">
<option value="text">Text</option>
<option value="number">Number</option>
<option value="date">Date</option>
<option value="boolean">Yes / No</option>
<option value="select">Dropdown</option>
</select>
</div>
<div class="form-group" v-if="form.datatype === 'select'">
<label>Options <span class="hint">(one per line or comma-separated)</span></label>
<textarea v-model="form.options" class="form-control" rows="3" placeholder="Bronze&#10;Silver&#10;Gold"></textarea>
</div>
<div class="form-row">
<label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label>
<label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label>
</div>
<div class="form-row">
<div class="form-group">
<label>Sort Order</label>
<input v-model.number="form.sortorder" type="number" class="form-control" style="width: 90px;" />
</div>
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { assetsApi, customFieldsApi } from '../../api'
const assetTypes = ref([])
const assettypeid = ref(null)
const items = ref([])
const showInactive = ref(false)
const visibleFields = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(false)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(blankForm())
function blankForm() {
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, sortorder: 0, isactive: true }
}
function typeLabel(t) {
const name = t.assettype || t.typename || t.name || `Type ${t.assettypeid}`
return name.charAt(0).toUpperCase() + name.slice(1).replace('_', ' ')
}
onMounted(async () => {
try {
const response = await assetsApi.types.list()
assetTypes.value = response.data.data || []
if (assetTypes.value.length) {
assettypeid.value = assetTypes.value[0].assettypeid
await loadFields()
}
} catch (err) {
console.error('Error loading asset types:', err)
}
})
async function loadFields() {
if (!assettypeid.value) return
loading.value = true
try {
const response = await customFieldsApi.list({ assettypeid: assettypeid.value, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading fields:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? {
label: item.label || '',
datatype: item.datatype || 'text',
options: (item.options || []).join('\n'),
showondetail: item.showondetail !== false,
showonform: item.showonform !== false,
sortorder: item.sortorder || 0,
isactive: item.isactive !== false,
}
: blankForm()
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const payload = { ...form.value, assettypeid: assettypeid.value }
if (editing.value) {
await customFieldsApi.update(editing.value.fieldid, payload)
} else {
await customFieldsApi.create(payload)
}
closeModal()
loadFields()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteField(f) {
if (!confirm(`Delete field "${f.label}"? Stored values for it will be removed.`)) return
try {
await customFieldsApi.remove(f.fieldid)
loadFields()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
.mono { font-family: monospace; font-size: 0.85rem; }
.type-picker { max-width: 280px; }
.form-row { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
.checkbox-label { display: inline-flex; align-items: center; gap: 0.4rem; }
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Equipment Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Equipment Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.equipmenttypeid">
<td>{{ t.equipmenttype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No equipment types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Equipment Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Equipment Type *</label>
<input v-model="form.equipmenttype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { equipmentApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ equipmenttype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await equipmentApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading equipment types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { equipmenttype: item.equipmenttype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { equipmenttype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await equipmentApi.types.update(editing.value.equipmenttypeid, form.value)
} else {
await equipmentApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete equipment type "${t.equipmenttype}"?`)) return
try {
await equipmentApi.types.remove(t.equipmenttypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Location Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Location Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Location Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.locationtypeid">
<td>{{ t.locationtype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No location types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Location Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Location Type *</label>
<input v-model="form.locationtype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { locationsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ locationtype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await locationsApi.types.list({ active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading location types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { locationtype: item.locationtype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { locationtype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await locationsApi.types.update(editing.value.locationtypeid, form.value)
} else {
await locationsApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete location type "${t.locationtype}"?`)) return
try {
await locationsApi.types.remove(t.locationtypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Network Device Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Network Device Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Network Device Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.networkdevicetypeid">
<td>{{ t.networkdevicetype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No network device types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Network Device Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Network Device Type *</label>
<input v-model="form.networkdevicetype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { networkApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ networkdevicetype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await networkApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading network device types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { networkdevicetype: item.networkdevicetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { networkdevicetype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await networkApi.types.update(editing.value.networkdevicetypeid, form.value)
} else {
await networkApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete network device type "${t.networkdevicetype}"?`)) return
try {
await networkApi.types.remove(t.networkdevicetypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>PC Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div>
@@ -15,19 +16,25 @@
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="pt in pcTypes" :key="pt.computertypeid">
<tr v-for="pt in visiblePcTypes" :key="pt.computertypeid">
<td>{{ pt.computertype }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(pt.color)">{{ pt.color || 'auto' }}</span>
</td>
<td class="actions">
<span v-if="pt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(pt)">Delete</button>
</td>
</tr>
<tr v-if="pcTypes.length === 0">
<td colspan="3" style="text-align: center; color: var(--text-light);">
<tr v-if="visiblePcTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
@@ -53,6 +60,13 @@
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
@@ -68,10 +82,14 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { computersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const pcTypes = ref([])
const showInactive = ref(false)
const visiblePcTypes = computed(() => showInactive.value ? pcTypes.value : pcTypes.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
@@ -79,14 +97,14 @@ const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ computertype: '', description: '' })
const form = ref({ computertype: '', description: '', color: '', isactive: true })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.list({ perpage: 100 })
const response = await computersApi.types.list({ perpage: 200, active: false })
pcTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading PC types:', err)
@@ -98,14 +116,24 @@ async function loadData() {
function openModal(item = null) {
editing.value = item
form.value = item
? { computertype: item.computertype || '', description: item.description || '' }
: { computertype: '', description: '' }
? { computertype: item.computertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { computertype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function deleteType(pt) {
if (!confirm(`Delete PC type "${pt.computertype}"?`)) return
try {
await computersApi.types.remove(pt.computertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
async function save() {
error.value = ''
saving.value = true

View File

@@ -0,0 +1,183 @@
<template>
<div>
<div class="page-header">
<h2>Printer Drivers</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Driver</button>
</div>
<div class="card">
<p class="hint">
Each driver is a name + a link to the driver package - an SMB path
(<code>\\server\share\driver</code>) or an HTTP URL. HTTP links open;
SMB paths are shown for copy-paste (browsers block file:// SMB).
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Printer Model</th>
<th>Location</th>
<th>Description</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="d in visibleItems" :key="d.driverid">
<td><strong>{{ d.name }}</strong></td>
<td>{{ d.modelname || '-' }}</td>
<td>
<a v-if="isHttp(d.location)" :href="d.location" target="_blank" class="mono">{{ d.location }}</a>
<span v-else class="mono">{{ d.location }}</span>
</td>
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
<td>
<span class="badge" :class="d.isactive ? 'badge-success' : 'badge-secondary'">{{ d.isactive ? 'yes' : 'no' }}</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(d)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteDriver(d)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No drivers</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Driver</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
</div>
<div class="form-group">
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
<select v-model="form.modelnumberid" class="form-control">
<option :value="null">-- none --</option>
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
{{ m.modelnumber }}<template v-if="m.vendorname"> ({{ m.vendorname }})</template>
</option>
</select>
</div>
<div class="form-group">
<label>Location * <span class="hint">(SMB path or HTTP URL)</span></label>
<input v-model="form.location" type="text" class="form-control" maxlength="500"
placeholder="\\server\share\driver or https://..." required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
const items = ref([])
const models = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', location: '', description: '', modelnumberid: null, isactive: true })
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
onMounted(() => { loadData(); loadModels() })
async function loadData() {
loading.value = true
try {
const response = await printersApi.drivers.list({ active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading drivers:', err)
} finally {
loading.value = false
}
}
async function loadModels() {
try {
const response = await printersApi.modelSupplies.listModels({ perpage: 100 })
models.value = response.data.data || []
} catch (err) {
console.error('Error loading models:', err)
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
: { name: '', location: '', description: '', modelnumberid: null, isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await printersApi.drivers.update(editing.value.driverid, form.value)
} else {
await printersApi.drivers.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteDriver(d) {
if (!confirm(`Delete driver "${d.name}"?`)) return
try {
await printersApi.drivers.delete(d.driverid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
.mono { font-family: monospace; font-size: 0.85rem; word-break: break-all; }
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Printer Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Printer Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Printer Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.printertypeid">
<td>{{ t.printertype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No printer types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Printer Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Printer Type *</label>
<input v-model="form.printertype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ printertype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await printersApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading printer types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { printertype: item.printertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { printertype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await printersApi.types.update(editing.value.printertypeid, form.value)
} else {
await printersApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete printer type "${t.printertype}"?`)) return
try {
await printersApi.types.remove(t.printertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div>
<div class="page-header">
<h2>Relationship Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Relationship Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Relationship Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.relationshiptypeid">
<td><span class="badge" :style="colorStyle(t.color)">{{ t.relationshiptype }}</span></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="mono">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No relationship types found</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Relationship Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Relationship Type *</label>
<input v-model="form.relationshiptype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(relationship badges; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { relationshipTypesApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ relationshiptype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await relationshipTypesApi.list()
items.value = response.data.data || []
} catch (err) {
console.error('Error loading relationship types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { relationshiptype: item.relationshiptype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { relationshiptype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await relationshipTypesApi.update(editing.value.relationshiptypeid, form.value)
} else {
await relationshipTypesApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete relationship type "${t.relationshiptype}"?`)) return
try {
await relationshipTypesApi.remove(t.relationshiptypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>
<style scoped>
.mono { font-family: monospace; font-size: 0.85rem; }
</style>

View File

@@ -1,212 +1,92 @@
<template>
<div class="settings-page">
<h1>Settings</h1>
<div class="settings-search">
<input v-model="search" type="text" class="search-input"
placeholder="Search settings (vendors, vlans, plugins...)" />
</div>
<!-- Search results: flat grid of matches across all groups -->
<div v-if="search.trim()" class="settings-grid">
<router-link
v-for="card in searchResults"
:key="card.to"
:to="card.to"
class="settings-card"
>
<div class="card-icon"><component :is="card.icon" :size="28" /></div>
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
</router-link>
<p v-if="!searchResults.length" class="no-results">No matching settings</p>
</div>
<!-- Normal: group tabs on the left, that group's cards on the right -->
<div v-else class="settings-layout">
<nav class="settings-tabs">
<button
v-for="group in groups"
:key="group.title"
class="settings-tab"
:class="{ active: activeGroup === group.title }"
@click="activeGroup = group.title"
>{{ group.title }}</button>
</nav>
<div class="settings-grid">
<router-link
v-for="card in activeCards"
:key="card.to"
:to="card.to"
class="settings-card"
>
<div class="card-icon"><component :is="card.icon" :size="28" /></div>
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle } from 'lucide-vue-next'
// Settings grouped by purpose so the index stays scannable as it grows.
const groups = [
{
title: 'Asset Reference Data',
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors' },
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
],
},
{
title: 'Locations & Organization',
cards: [
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
],
},
{
title: 'Network',
cards: [
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
],
},
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},
{
title: 'Access & Audit',
cards: [
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
],
},
]
const search = ref('')
const activeGroup = ref(groups[0].title)
const activeCards = computed(() =>
groups.find(g => g.title === activeGroup.value)?.cards || [])
// Flat search across every card's title + description.
const searchResults = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return []
return groups.flatMap(g => g.cards).filter(c =>
c.title.toLowerCase().includes(term) ||
c.description.toLowerCase().includes(term))
})
</script>
<style scoped>
.settings-page h1 {
margin-bottom: 1.5rem;
}
.settings-search {
margin-bottom: 1rem;
}
.search-input {
width: 100%;
max-width: 420px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
}
.settings-layout {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.settings-tabs {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 200px;
position: sticky;
top: 1rem;
}
.settings-tab {
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 0.95rem;
}
.settings-tab:hover { background: var(--bg); }
.settings-tab.active { background: var(--primary); color: #fff; }
.no-results {
color: var(--text-light);
font-size: 0.9rem;
}
.settings-grid {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.settings-card {
display: block;
padding: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s, border-color 0.2s;
}
.settings-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.card-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.settings-card h3 {
margin: 0 0 0.5rem 0;
color: var(--text);
}
.settings-card p {
margin: 0;
color: var(--text-light);
font-size: 0.9rem;
}
</style>
<template>
<div class="settings-landing">
<p class="landing-intro">
Pick a section from the left, or choose one below.
</p>
<section v-for="group in groups" :key="group.title" class="landing-section">
<h2 class="section-heading">{{ group.title }}</h2>
<div class="landing-grid">
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="landing-card"
>
<div class="card-head">
<span class="card-icon"><component :is="card.icon" :size="18" /></span>
<h3>{{ card.title }}</h3>
</div>
<p>{{ card.description }}</p>
</router-link>
</div>
</section>
</div>
</template>
<script setup>
import { settingsGroups as groups } from './settingsNav'
</script>
<style scoped>
.landing-intro {
margin: 0 0 1.5rem 0;
color: var(--text-light);
}
.landing-section {
margin-bottom: 1.75rem;
}
.section-heading {
margin: 0 0 0.6rem 0;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--border);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
}
.landing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.7rem;
}
.landing-card {
display: block;
padding: 0.8rem 0.9rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s, border-color 0.15s;
}
.landing-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.card-head {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.card-icon {
display: inline-flex;
color: var(--primary);
}
.landing-card h3 {
margin: 0;
font-size: 0.92rem;
color: var(--text);
}
.landing-card p {
margin: 0;
color: var(--text-light);
font-size: 0.82rem;
line-height: 1.35;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div class="settings-shell">
<!-- Left rail: grouped, searchable nav that stays put while the right pane swaps -->
<aside class="settings-rail">
<h1 class="rail-title">Settings</h1>
<div class="rail-search">
<input v-model="search" type="text" class="search-input"
placeholder="Search settings..." />
</div>
<nav class="rail-nav">
<template v-for="group in visibleGroups" :key="group.title">
<div class="rail-group-heading">{{ group.title }}</div>
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="rail-link"
:class="{ active: isActive(card.to) }"
>
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
<span class="rail-label">{{ card.title }}</span>
</router-link>
</template>
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
</nav>
</aside>
<!-- Right pane: the selected settings page renders here -->
<section class="settings-content">
<router-view />
</section>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { settingsGroups as groups } from './settingsNav'
const route = useRoute()
const search = ref('')
// Filter the rail by title/description; drop groups that end up empty.
const visibleGroups = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return groups
return groups
.map(g => ({
title: g.title,
cards: g.cards.filter(c =>
c.title.toLowerCase().includes(term) ||
c.description.toLowerCase().includes(term)),
}))
.filter(g => g.cards.length)
})
// A rail link is active when the current path matches its base path
// (ignoring query, so /settings/system?tab=map and /settings/system stay distinct
// only by their own comparison below).
function isActive(to) {
const base = to.split('?')[0]
const query = to.includes('?') ? to.split('?')[1] : ''
if (route.path !== base) return false
// Floor Map shares /settings/system with System Settings; disambiguate by tab.
if (base === '/settings/system') {
const wantMap = query.includes('tab=map')
const onMap = route.query.tab === 'map'
return wantMap === onMap
}
return true
}
</script>
<style scoped>
.settings-shell {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.settings-rail {
flex: 0 0 250px;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.rail-title {
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
}
.rail-search {
margin-bottom: 0.75rem;
}
.search-input {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.85rem;
}
.rail-group-heading {
margin: 1rem 0 0.3rem 0;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
}
.rail-group-heading:first-child {
margin-top: 0;
}
.rail-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-radius: 6px;
text-decoration: none;
color: var(--text);
font-size: 0.88rem;
border-left: 2px solid transparent;
}
.rail-link:hover {
background: var(--bg);
}
.rail-link.active {
background: var(--bg);
border-left-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
.rail-icon {
display: inline-flex;
color: var(--text-light);
}
.rail-link.active .rail-icon {
color: var(--primary);
}
.rail-label {
line-height: 1.2;
}
.rail-empty {
color: var(--text-light);
font-size: 0.85rem;
}
.settings-content {
flex: 1 1 auto;
min-width: 0;
}
@media (max-width: 820px) {
.settings-shell {
flex-direction: column;
}
.settings-rail {
position: static;
flex-basis: auto;
width: 100%;
max-height: none;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div>
<div class="page-header">
<h1>Site &amp; Facility</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<div v-if="loading" class="muted">Loading...</div>
<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>
</label>
<div v-if="!items.length" class="muted">No site settings found.</div>
<div class="actions">
<button class="btn btn-primary" :disabled="saving || !items.length" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<span v-if="saved" class="muted">Saved.</span>
<span v-if="error" class="error">{{ error }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
const items = ref([])
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
const LABELS = {
site_base_url: 'Site URL / FQDN',
facility_name: 'Facility Name',
pc_access_domain: 'PC Access Domain'
}
function prettyLabel(key) {
return LABELS[key] || key
}
async function load() {
loading.value = true
try {
const response = await settingsApi.list()
const all = response.data?.data || response.data || []
items.value = all.filter(s => s.category === 'site')
} catch (err) {
console.error('Error loading site settings:', err)
error.value = 'Could not load settings.'
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
for (const s of items.value) {
await settingsApi.update(s.key, s.value)
}
saved.value = true
} catch (err) {
error.value = err.response?.data?.error?.message || 'Save failed.'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.form-grid {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 640px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-weight: 600;
}
.field input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.muted {
color: var(--text-light);
font-size: 0.85rem;
}
.error {
color: var(--danger);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,209 @@
<template>
<div>
<div class="page-header">
<h1>Slides</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<div class="tabs">
<button
v-for="s in surfaces"
:key="s.key"
class="tab"
:class="{ active: surface === s.key }"
@click="switchSurface(s.key)"
>{{ s.label }}</button>
</div>
<div class="toolbar">
<label class="btn btn-primary upload-btn">
{{ uploading ? 'Uploading...' : 'Upload Images' }}
<input type="file" accept="image/*" multiple hidden :disabled="uploading" @change="onUpload" />
</label>
<button
class="btn btn-danger"
:disabled="!selected.length"
@click="deleteSelected"
>Delete Selected ({{ selected.length }})</button>
<span class="hint">Order top-to-bottom is play order. Images show on the {{ surfaceLabel }}.</span>
</div>
<div v-if="loading" class="muted">Loading...</div>
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
<div v-else class="slide-grid">
<div v-for="(slide, idx) in slides" :key="slide.slideid" class="slide-tile">
<label class="pick">
<input type="checkbox" :value="slide.filename" v-model="selected" />
</label>
<img :src="slide.url" :alt="slide.filename" class="thumb" />
<div class="slide-meta">
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
<div class="move">
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">&uarr;</button>
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">&darr;</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { slidesApi } from '@/api'
const surfaces = [
{ key: 'lobby', label: 'Lobby Display' },
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
]
const surface = ref('lobby')
const slides = ref([])
const selected = ref([])
const loading = ref(true)
const uploading = ref(false)
const surfaceLabel = computed(() => surfaces.find(s => s.key === surface.value)?.label || surface.value)
async function load() {
loading.value = true
selected.value = []
try {
const response = await slidesApi.list(surface.value)
slides.value = response.data.data || []
} catch (err) {
console.error('Error loading slides:', err)
} finally {
loading.value = false
}
}
function switchSurface(key) {
if (key === surface.value) return
surface.value = key
load()
}
async function onUpload(event) {
const files = Array.from(event.target.files || [])
if (!files.length) return
uploading.value = true
try {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
await slidesApi.upload(surface.value, formData)
await load()
} catch (err) {
console.error('Upload failed:', err)
} finally {
uploading.value = false
event.target.value = ''
}
}
async function move(idx, delta) {
const target = idx + delta
if (target < 0 || target >= slides.value.length) return
const arr = slides.value.slice()
const [item] = arr.splice(idx, 1)
arr.splice(target, 0, item)
slides.value = arr
try {
await slidesApi.reorder(surface.value, arr.map(s => s.filename))
} catch (err) {
console.error('Reorder failed:', err)
await load()
}
}
async function deleteSelected() {
if (!selected.value.length) return
if (!confirm(`Delete ${selected.value.length} slide(s)?`)) return
try {
await slidesApi.remove(surface.value, selected.value)
await load()
} catch (err) {
console.error('Delete failed:', err)
}
}
onMounted(load)
</script>
<style scoped>
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab {
padding: 10px 18px;
border: none;
background: none;
color: var(--text-light);
font-weight: 600;
cursor: pointer;
border-bottom: 3px solid transparent;
}
.tab.active {
color: var(--text);
border-bottom-color: var(--primary);
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.upload-btn { position: relative; cursor: pointer; }
.hint { color: var(--text-light); font-size: 0.85rem; }
.muted { color: var(--text-light); }
.empty { padding: 30px 0; text-align: center; }
.slide-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.slide-tile {
position: relative;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
.slide-tile .pick {
position: absolute;
top: 6px;
left: 6px;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
padding: 2px 4px;
}
.thumb {
width: 100%;
height: 130px;
object-fit: cover;
display: block;
background: #000;
}
.slide-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 8px;
}
.fname {
font-size: 0.78rem;
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.move { display: flex; gap: 4px; flex-shrink: 0; }
</style>

View File

@@ -86,21 +86,8 @@
</div>
<div class="form-group">
<label for="color">Color</label>
<div class="color-input-row">
<input
id="color"
v-model="form.color"
type="color"
class="color-picker"
/>
<input
v-model="form.color"
type="text"
class="form-control"
placeholder="#000000"
/>
</div>
<label>Color</label>
<ColorSwatchPicker v-model="form.color" />
<small class="form-hint">Used in UI to visually distinguish statuses</small>
</div>
@@ -151,6 +138,7 @@
import { ref, onMounted } from 'vue'
import { assetsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
const statuses = ref([])
const loading = ref(true)

View File

@@ -263,6 +263,79 @@
</div>
</div>
<!-- Floor Map Section -->
<div class="section-card" v-show="isVisible('map')">
<h2 class="section-title">Floor Map</h2>
<div class="setting-group">
<h3>Facility Blueprint</h3>
<p class="setting-description">
The floor-plan image and its pixel dimensions for this facility. Map
markers are positioned against these dimensions, so the width and
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<div class="setting-row">
<label>
<span>Blueprint image URL (light theme)</span>
<input
type="text"
v-model="settings.map_blueprint_light"
placeholder="/static/images/sitemap2025-light.png"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
:disabled="saving"
>
<small class="input-hint">Path or URL to the light-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint image URL (dark theme)</span>
<input
type="text"
v-model="settings.map_blueprint_dark"
placeholder="/static/images/sitemap2025-dark.png"
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
:disabled="saving"
>
<small class="input-hint">Path or URL to the dark-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint width (pixels)</span>
<input
type="number"
v-model="settings.map_width"
min="1"
placeholder="3300"
@blur="saveSetting('map_width', settings.map_width)"
:disabled="saving"
>
<small class="input-hint">Native pixel width of the blueprint image</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint height (pixels)</span>
<input
type="number"
v-model="settings.map_height"
min="1"
placeholder="2550"
@blur="saveSetting('map_height', settings.map_height)"
:disabled="saving"
>
<small class="input-hint">Native pixel height of the blueprint image</small>
</label>
</div>
</div>
</div>
<!-- Authentication Section -->
<div class="section-card" v-show="isVisible('auth')">
<h2 class="section-title">Authentication</h2>
@@ -501,6 +574,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { settingsApi, computersApi } from '../../api'
import { setIdentifierFlag } from '../../composables/identifierSettings'
@@ -513,10 +587,17 @@ const SETTINGS_TABS = [
{ key: 'auth', label: 'Authentication', keywords: 'auth saml sso login users idp' },
{ key: 'identifiers', label: 'Asset Identifiers', keywords: 'identifier gauge lab maintenance fqdn hostname asset' },
{ key: 'search', label: 'Global Search', keywords: 'search results domains' },
{ key: 'map', label: 'Floor Map', keywords: 'map floor plan blueprint image facility site dimensions width height' },
{ key: 'pctype', label: 'PC Type Mapping', keywords: 'pc type mapping collector enrollment shopfloor computer type' },
]
const settingsSearch = ref('')
const activeTab = ref('integrations')
const route = useRoute()
// Allow deep-linking to a tab, e.g. /settings/system?tab=map (Floor Map)
const activeTab = ref(
route.query.tab && SETTINGS_TABS.some(t => t.key === route.query.tab)
? String(route.query.tab)
: 'integrations'
)
const visibleTabs = computed(() => {
const term = settingsSearch.value.trim().toLowerCase()
@@ -556,6 +637,11 @@ const settings = reactive({
alert_recipients: '',
// Audit
audit_retention_days: 90,
// Floor map blueprint (per-facility)
map_blueprint_light: '',
map_blueprint_dark: '',
map_width: 3300,
map_height: 2550,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',

View File

@@ -0,0 +1,85 @@
// Shared settings navigation catalog.
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
// grouping lives in one place.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal } from 'lucide-vue-next'
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/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' },
],
},
{
title: 'General Reference',
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
],
},
{
title: 'PCs',
cards: [
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
],
},
{
title: 'Printers',
cards: [
{ to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
],
},
{
title: 'Equipment',
cards: [
{ to: '/settings/equipmenttypes', icon: Wrench, title: 'Equipment Types', description: 'Manage equipment subtypes + map colors' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
],
},
{
title: 'Locations & Organization',
cards: [
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
{ to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
],
},
{
title: 'Network',
cards: [
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
],
},
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},
{
title: 'Access & Audit',
cards: [
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
],
},
]

View File

@@ -0,0 +1,292 @@
<template>
<div>
<div class="page-header">
<h2>Warranties</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div>
<div class="filters">
<label>Status
<select v-model="statusFilter" class="form-control" @change="loadData">
<option value="">All</option>
<option value="active">Active</option>
<option value="expiring">Expiring Soon</option>
<option value="expired">Expired</option>
<option value="unknown">Unknown</option>
</select>
</label>
</div>
<div class="card">
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Status</th>
<th>Service Level</th>
<th>Ends</th>
<th>Covers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="w in items" :key="w.warrantyid">
<td><strong>{{ w.vendor }}</strong><span v-if="w.provider !== 'manual'" class="muted"> ({{ w.provider }})</span></td>
<td><span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span></td>
<td>{{ w.servicelevel || '-' }}</td>
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<span v-if="!w.assets.length" class="muted">-</span>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
</td>
<td class="actions">
<button v-if="w.provider !== 'manual'" class="btn btn-secondary btn-sm" @click="refresh(w)">Refresh</button>
<button class="btn btn-secondary btn-sm" @click="openModal(w)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteWarranty(w)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No warranties</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Warranty</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label>Vendor *</label>
<input v-model="form.vendor" type="text" class="form-control" maxlength="100" required />
</div>
<div class="form-group">
<label>Provider</label>
<select v-model="form.provider" class="form-control">
<option value="manual">Manual</option>
<option value="dell">Dell</option>
<option value="lenovo">Lenovo</option>
<option value="hp">HP</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Service Tag / Serial</label>
<input v-model="form.servicetag" type="text" class="form-control" maxlength="100" />
</div>
<div class="form-group">
<label>Service Level</label>
<input v-model="form.servicelevel" type="text" class="form-control" maxlength="150" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Start Date</label>
<input v-model="form.startdate" type="date" class="form-control" />
</div>
<div class="form-group">
<label>End Date</label>
<input v-model="form.enddate" type="date" class="form-control" />
</div>
</div>
<div class="form-group">
<label>Covered Assets</label>
<div class="asset-search">
<input v-model="assetQuery" type="text" class="form-control" placeholder="Search asset number or name..."
@input="searchAssets" />
<ul v-if="assetResults.length" class="asset-results">
<li v-for="a in assetResults" :key="a.assetid" @click="addAsset(a)">
{{ a.assetnumber }}<span v-if="a.name" class="muted"> - {{ a.name }}</span>
</li>
</ul>
</div>
<div class="asset-chips">
<span v-for="a in selectedAssets" :key="a.assetid" class="asset-chip removable">
{{ a.assetnumber }}
<button type="button" @click="removeAsset(a)">x</button>
</span>
<span v-if="!selectedAssets.length" class="muted">None linked</span>
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
const items = ref([])
const loading = ref(true)
const statusFilter = ref('')
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(blankForm())
const selectedAssets = ref([])
const assetQuery = ref('')
const assetResults = ref([])
function blankForm() {
return { vendor: '', provider: 'manual', servicetag: '', servicelevel: '', startdate: '', enddate: '', notes: '' }
}
function statusLabel(status) {
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
}
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
// Route to the right detail page by asset type.
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
const base = map[a.assettypename] || '/assets/'
return base + a.assetid
}
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const params = statusFilter.value ? { status: statusFilter.value } : {}
const response = await warrantyApi.list(params)
items.value = response.data.data || []
} catch (err) {
console.error('Error loading warranties:', err)
} finally {
loading.value = false
}
}
let searchTimer = null
function searchAssets() {
clearTimeout(searchTimer)
const q = assetQuery.value.trim()
if (!q) { assetResults.value = []; return }
searchTimer = setTimeout(async () => {
try {
const response = await assetsApi.search(q, { perpage: 8 })
assetResults.value = response.data.data || []
} catch (err) {
assetResults.value = []
}
}, 250)
}
function addAsset(a) {
if (!selectedAssets.value.some(x => x.assetid === a.assetid)) {
selectedAssets.value.push({ assetid: a.assetid, assetnumber: a.assetnumber })
}
assetQuery.value = ''
assetResults.value = []
}
function removeAsset(a) {
selectedAssets.value = selectedAssets.value.filter(x => x.assetid !== a.assetid)
}
function openModal(item = null) {
editing.value = item
if (item) {
form.value = {
vendor: item.vendor || '', provider: item.provider || 'manual',
servicetag: item.servicetag || '', servicelevel: item.servicelevel || '',
startdate: item.startdate || '', enddate: item.enddate || '', notes: item.notes || '',
}
selectedAssets.value = (item.assets || []).map(a => ({ assetid: a.assetid, assetnumber: a.assetnumber }))
} else {
form.value = blankForm()
selectedAssets.value = []
}
assetQuery.value = ''
assetResults.value = []
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
if (editing.value) {
await warrantyApi.update(editing.value.warrantyid, payload)
} else {
await warrantyApi.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteWarranty(w) {
if (!confirm(`Delete the ${w.vendor} warranty?`)) return
try {
await warrantyApi.remove(w.warrantyid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || 'Failed to delete')
}
}
async function refresh(w) {
try {
await warrantyApi.refresh(w.warrantyid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Refresh failed')
}
}
</script>
<style scoped>
.muted { color: var(--text-light); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.asset-search { position: relative; }
.asset-results {
position: absolute; z-index: 10; left: 0; right: 0; margin: 2px 0 0;
padding: 0; list-style: none; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 6px; max-height: 200px; overflow-y: auto;
}
.asset-results li { padding: 0.45rem 0.6rem; cursor: pointer; }
.asset-results li:hover { background: var(--bg); }
.asset-chips { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.asset-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
}
.asset-chip.removable button {
border: none; background: none; color: var(--text-light); cursor: pointer; font-size: 0.85rem; padding: 0;
}
</style>