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:
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
101
frontend/src/components/ColorSwatchPicker.vue
Normal file
101
frontend/src/components/ColorSwatchPicker.vue
Normal 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>
|
||||
82
frontend/src/components/CustomFieldsInputs.vue
Normal file
82
frontend/src/components/CustomFieldsInputs.vue
Normal 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>
|
||||
48
frontend/src/components/CustomFieldsSection.vue
Normal file
48
frontend/src/components/CustomFieldsSection.vue
Normal 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>
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
75
frontend/src/components/WarrantyPanel.vue
Normal file
75
frontend/src/components/WarrantyPanel.vue
Normal 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>
|
||||
68
frontend/src/composables/mapConfig.js
Normal file
68
frontend/src/composables/mapConfig.js
Normal 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 }
|
||||
}
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
]
|
||||
|
||||
16
frontend/src/router/routes/warranty.js
Normal file
16
frontend/src/router/routes/warranty.js
Normal 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')
|
||||
}
|
||||
]
|
||||
60
frontend/src/utils/assetTypes.js
Normal file
60
frontend/src/utils/assetTypes.js
Normal 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}`
|
||||
}
|
||||
39
frontend/src/utils/colorStyle.js
Normal file
39
frontend/src/utils/colorStyle.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Data-driven badge colors. A record stores a hex color; the UI renders it with
|
||||
// an auto-picked readable text color, so any color stays legible. Pickers should
|
||||
// offer PALETTE (curated, distinct, accessible) with custom hex as a fallback.
|
||||
|
||||
// Curated categorical palette - distinct + accessible. ~12 is the practical
|
||||
// ceiling for at-a-glance distinguishability, which is plenty per category.
|
||||
export const PALETTE = [
|
||||
'#f5365c', // red
|
||||
'#fb6340', // orange
|
||||
'#ff8800', // amber
|
||||
'#ffc107', // gold
|
||||
'#2dce89', // green
|
||||
'#04b962', // emerald
|
||||
'#11cdef', // cyan
|
||||
'#14abef', // blue
|
||||
'#0d6efd', // royal blue
|
||||
'#7934f3', // purple
|
||||
'#e83e8c', // pink
|
||||
'#6c757d', // gray
|
||||
]
|
||||
|
||||
// Black or white text for a given background, by perceived brightness.
|
||||
export function readableText(bg) {
|
||||
if (!bg || typeof bg !== 'string') return '#ffffff'
|
||||
let hex = bg.trim().replace('#', '')
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('')
|
||||
if (hex.length !== 6) return '#ffffff'
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return brightness > 0.6 ? '#1a1a1a' : '#ffffff'
|
||||
}
|
||||
|
||||
// Style object for a badge/pill from a stored color (with a neutral fallback).
|
||||
export function colorStyle(color, fallback = '#6c757d') {
|
||||
const backgroundColor = color || fallback
|
||||
return { backgroundColor, color: readableText(backgroundColor) }
|
||||
}
|
||||
51
frontend/src/utils/mapColors.js
Normal file
51
frontend/src/utils/mapColors.js
Normal 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)
|
||||
}
|
||||
135
frontend/src/utils/mapPdf.js
Normal file
135
frontend/src/utils/mapPdf.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// Export the shop-floor map (current filtered assets on the facility blueprint)
|
||||
// to a PDF, entirely client-side. The blueprint image is drawn full-size and
|
||||
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
|
||||
// vector-over-raster page rather than a screen capture.
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors'
|
||||
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('Failed to load blueprint image: ' + url))
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = (hex || '#BDBDBD').replace('#', '')
|
||||
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
||||
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
// Build the legend entries (color + label) for the assets present, matching the
|
||||
// active coloring mode.
|
||||
function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) {
|
||||
const seen = new Map()
|
||||
for (const a of assets) {
|
||||
let key, label, color
|
||||
if (selectedType) {
|
||||
const id = getSubtypeId(a)
|
||||
key = id != null ? String(id) : 'none'
|
||||
label = (id != null && subtypeNames[id]) || 'Unspecified'
|
||||
color = (id != null && subtypeColors[id]) || '#BDBDBD'
|
||||
} else {
|
||||
key = (a.assettype || 'unknown').toLowerCase()
|
||||
label = a.assettype || 'Unknown'
|
||||
color = getAssetTypeColor(a.assettype)
|
||||
}
|
||||
if (!seen.has(key)) seen.set(key, { label, color, count: 0 })
|
||||
seen.get(key).count += 1
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
export async function exportMapPdf(opts) {
|
||||
const {
|
||||
assets = [],
|
||||
blueprintUrl,
|
||||
mapWidth = 3300,
|
||||
mapHeight = 2550,
|
||||
selectedType = '',
|
||||
subtypeColors = {},
|
||||
subtypeNames = {},
|
||||
filters = [],
|
||||
facility = '',
|
||||
title = 'Shop Floor Map'
|
||||
} = opts
|
||||
|
||||
const img = await loadImage(blueprintUrl)
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true })
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const pageH = doc.internal.pageSize.getHeight()
|
||||
const margin = 28
|
||||
|
||||
// ---- Header ----
|
||||
doc.setTextColor('#111111')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(16)
|
||||
doc.text(title, margin, margin + 6)
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor('#555555')
|
||||
const stamp = new Date().toLocaleString()
|
||||
const subParts = [facility, stamp, `${assets.length} asset${assets.length === 1 ? '' : 's'}`].filter(Boolean)
|
||||
doc.text(subParts.join(' | '), margin, margin + 22)
|
||||
|
||||
const filterText = filters.length ? 'Filters: ' + filters.join(' ') : 'Filters: none (all assets)'
|
||||
doc.text(filterText, margin, margin + 35)
|
||||
|
||||
// ---- Legend (wraps under the header, pushes the map down) ----
|
||||
const legend = buildLegend(assets, { selectedType, subtypeColors, subtypeNames })
|
||||
const swatch = 8
|
||||
const legendY0 = margin + 50
|
||||
const lineH = 15
|
||||
let lx = margin
|
||||
let ly = legendY0
|
||||
doc.setFontSize(8.5)
|
||||
for (const entry of legend) {
|
||||
const label = `${entry.label} (${entry.count})`
|
||||
const w = swatch + 4 + doc.getTextWidth(label) + 16
|
||||
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
|
||||
const [r, g, b] = hexToRgb(entry.color)
|
||||
doc.setFillColor(r, g, b)
|
||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4)
|
||||
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F')
|
||||
doc.setTextColor('#333333')
|
||||
doc.text(label, lx + swatch + 4, ly)
|
||||
lx += w
|
||||
}
|
||||
const legendBottom = legend.length ? ly + 6 : legendY0
|
||||
|
||||
// ---- Blueprint image, fit into the area below the legend ----
|
||||
const aspect = mapWidth / mapHeight
|
||||
const contentTop = legendBottom + 8
|
||||
const availW = pageW - margin * 2
|
||||
const availH = pageH - contentTop - margin
|
||||
let imgW = availW
|
||||
let imgH = availW / aspect
|
||||
if (imgH > availH) { imgH = availH; imgW = availH * aspect }
|
||||
const imgX = margin + (availW - imgW) / 2
|
||||
const imgY = contentTop + (availH - imgH) / 2
|
||||
|
||||
doc.addImage(img, 'PNG', imgX, imgY, imgW, imgH, undefined, 'FAST')
|
||||
doc.setDrawColor('#cccccc'); doc.setLineWidth(0.5)
|
||||
doc.rect(imgX, imgY, imgW, imgH)
|
||||
|
||||
// ---- Markers (database Y is top-down, same origin as the drawn image) ----
|
||||
const radius = 3.2
|
||||
for (const a of assets) {
|
||||
if (a.mapx == null || a.mapy == null) continue
|
||||
const x = imgX + (a.mapx / mapWidth) * imgW
|
||||
const y = imgY + (a.mapy / mapHeight) * imgH
|
||||
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
|
||||
const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors }))
|
||||
doc.setFillColor(r, g, b)
|
||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6)
|
||||
doc.circle(x, y, radius, 'FD')
|
||||
}
|
||||
|
||||
const dateSlug = new Date().toISOString().slice(0, 10)
|
||||
doc.save(`shopfloor-map-${dateSlug}.pdf`)
|
||||
}
|
||||
37
frontend/src/utils/siteSettings.js
Normal file
37
frontend/src/utils/siteSettings.js
Normal 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')
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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">★</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); }
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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(', ')
|
||||
}
|
||||
|
||||
|
||||
349
frontend/src/views/notifications/NotificationTypesList.vue
Normal file
349
frontend/src/views/notifications/NotificationTypesList.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 > 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 > 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));
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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>
|
||||
|
||||
103
frontend/src/views/reports/WarrantyReport.vue
Normal file
103
frontend/src/views/reports/WarrantyReport.vue
Normal 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>
|
||||
219
frontend/src/views/settings/AccessProtocolsList.vue
Normal file
219
frontend/src/views/settings/AccessProtocolsList.vue
Normal 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 & 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>
|
||||
119
frontend/src/views/settings/AssetTypesList.vue
Normal file
119
frontend/src/views/settings/AssetTypesList.vue
Normal 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>
|
||||
221
frontend/src/views/settings/CustomFieldsList.vue
Normal file
221
frontend/src/views/settings/CustomFieldsList.vue
Normal 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 Silver 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>
|
||||
142
frontend/src/views/settings/EquipmentTypesList.vue
Normal file
142
frontend/src/views/settings/EquipmentTypesList.vue
Normal 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>
|
||||
142
frontend/src/views/settings/LocationTypesList.vue
Normal file
142
frontend/src/views/settings/LocationTypesList.vue
Normal 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>
|
||||
142
frontend/src/views/settings/NetworkTypesList.vue
Normal file
142
frontend/src/views/settings/NetworkTypesList.vue
Normal 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>
|
||||
@@ -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
|
||||
|
||||
183
frontend/src/views/settings/PrinterDriversList.vue
Normal file
183
frontend/src/views/settings/PrinterDriversList.vue
Normal 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>
|
||||
142
frontend/src/views/settings/PrinterTypesList.vue
Normal file
142
frontend/src/views/settings/PrinterTypesList.vue
Normal 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>
|
||||
146
frontend/src/views/settings/RelationshipTypesList.vue
Normal file
146
frontend/src/views/settings/RelationshipTypesList.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
170
frontend/src/views/settings/SettingsLayout.vue
Normal file
170
frontend/src/views/settings/SettingsLayout.vue
Normal 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>
|
||||
118
frontend/src/views/settings/SiteSettings.vue
Normal file
118
frontend/src/views/settings/SiteSettings.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Site & 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>
|
||||
209
frontend/src/views/settings/SlideManager.vue
Normal file
209
frontend/src/views/settings/SlideManager.vue
Normal 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">↑</button>
|
||||
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">↓</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>
|
||||
@@ -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)
|
||||
|
||||
@@ -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: '',
|
||||
|
||||
85
frontend/src/views/settings/settingsNav.js
Normal file
85
frontend/src/views/settings/settingsNav.js
Normal 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' },
|
||||
],
|
||||
},
|
||||
]
|
||||
292
frontend/src/views/warranty/WarrantiesList.vue
Normal file
292
frontend/src/views/warranty/WarrantiesList.vue
Normal 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>
|
||||
Reference in New Issue
Block a user