Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
6
frontend/src/App.vue
Normal file
6
frontend/src/App.vue
Normal file
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
1126
frontend/src/api/index.js
Normal file
1126
frontend/src/api/index.js
Normal file
File diff suppressed because it is too large
Load Diff
1759
frontend/src/assets/style.css
Normal file
1759
frontend/src/assets/style.css
Normal file
File diff suppressed because it is too large
Load Diff
793
frontend/src/components/AssetRelationships.vue
Normal file
793
frontend/src/components/AssetRelationships.vue
Normal file
@@ -0,0 +1,793 @@
|
||||
<template>
|
||||
<div class="relationships-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-title">Relationships</h3>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn btn-sm btn-primary"
|
||||
@click="showAddModal = true"
|
||||
>
|
||||
Add Relationship
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading relationships...</div>
|
||||
|
||||
<div v-else-if="!hasRelationships" class="empty-state">
|
||||
No relationships defined.
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Symmetric connections: one direction-blind entry per peer -->
|
||||
<div v-if="connectedItems.length > 0" class="relationship-group">
|
||||
<h4 class="group-title">Connected</h4>
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="item in connectedItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<div class="rel-line">
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
</div>
|
||||
<div class="rel-meta">
|
||||
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteItem(item)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Directional relationships: natural per-row phrasing, no jargon -->
|
||||
<div v-if="directionalItems.length > 0" class="relationship-group">
|
||||
<div class="relationship-list">
|
||||
<div
|
||||
v-for="item in directionalItems"
|
||||
:key="item.key"
|
||||
class="relationship-item"
|
||||
>
|
||||
<div class="rel-icon"><component :is="getAssetIcon(item.peer?.assettypename || item.peer?.assettype)" :size="16" /></div>
|
||||
<div class="rel-content">
|
||||
<div class="rel-line">
|
||||
<template v-if="item.direction === 'outgoing'">
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
<span class="rel-arrow">-></span>
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="rel-arrow"><-</span>
|
||||
<span class="badge" :style="colorStyle(colorForType(item.relationshiptypename))">{{ item.relationshiptypename }}</span>
|
||||
<span class="rel-from">from</span>
|
||||
<router-link :to="getAssetRoute(item.peer)" class="rel-name">
|
||||
{{ item.peer?.name || item.peer?.assetnumber || 'Unknown' }}
|
||||
</router-link>
|
||||
</template>
|
||||
</div>
|
||||
<div class="rel-meta">
|
||||
<span class="rel-type-badge">{{ item.peer?.assettypename || item.peer?.assettype }}</span>
|
||||
</div>
|
||||
<div v-if="item.notes" class="rel-notes">{{ item.notes }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="authStore.isAuthenticated"
|
||||
class="btn-icon delete"
|
||||
@click="deleteItem(item)"
|
||||
title="Remove relationship"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add Relationship Modal -->
|
||||
<div v-if="showAddModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h3>Add Relationship</h3>
|
||||
<button class="btn-icon" @click="closeModal">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Direction</label>
|
||||
<select v-model="newRel.direction" class="form-control">
|
||||
<option value="outgoing">This asset -> Target asset</option>
|
||||
<option value="incoming">Source asset -> This asset</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Relationship Type</label>
|
||||
<select v-model="newRel.relationshiptypeid" class="form-control" required>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="t in relationshipTypes"
|
||||
:key="t.relationshiptypeid"
|
||||
:value="t.relationshiptypeid"
|
||||
>
|
||||
{{ t.relationshiptype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ newRel.direction === 'outgoing' ? 'Target Asset' : 'Source Asset' }}</label>
|
||||
<div class="asset-search">
|
||||
<input
|
||||
type="text"
|
||||
v-model="assetSearchQuery"
|
||||
class="form-control"
|
||||
placeholder="Search assets..."
|
||||
@input="searchAssets"
|
||||
/>
|
||||
<div v-if="assetSearchResults.length > 0" class="search-results">
|
||||
<div
|
||||
v-for="asset in assetSearchResults"
|
||||
:key="asset.assetid"
|
||||
class="search-result-item"
|
||||
:class="{ selected: newRel.targetAssetId === asset.assetid }"
|
||||
@click="selectAsset(asset)"
|
||||
>
|
||||
<span class="result-icon">{{ getAssetIcon(asset.assettype) }}</span>
|
||||
<span class="result-name">{{ asset.name || asset.assetnumber }}</span>
|
||||
<span class="result-type">{{ asset.assettype }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="selectedAsset" class="selected-asset">
|
||||
<span class="result-icon">{{ getAssetIcon(selectedAsset.assettype) }}</span>
|
||||
<span>{{ selectedAsset.name || selectedAsset.assetnumber }}</span>
|
||||
<button class="btn-icon" @click="clearSelectedAsset">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Notes (optional)</label>
|
||||
<textarea
|
||||
v-model="newRel.notes"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
placeholder="Additional notes about this relationship..."
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!canSave"
|
||||
@click="saveRelationship"
|
||||
>
|
||||
Add Relationship
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Package, Ruler } from 'lucide-vue-next'
|
||||
import { assetsApi, relationshipTypesApi } from '../api'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { apiError } from '../utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const props = defineProps({
|
||||
assetid: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
// Alternative: lookup by machine/asset number
|
||||
machinenumber: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['updated'])
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const loading = ref(true)
|
||||
const resolvedAssetId = ref(null)
|
||||
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
|
||||
const newRel = ref({
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: '',
|
||||
targetAssetId: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const assetSearchQuery = ref('')
|
||||
const assetSearchResults = ref([])
|
||||
const selectedAsset = ref(null)
|
||||
let searchTimeout = null
|
||||
|
||||
const hasRelationships = computed(() => outgoing.value.length > 0 || incoming.value.length > 0)
|
||||
|
||||
// Symmetric types collapse to one entry per {peer, type} unordered pair. All
|
||||
// stored direction rows (both directions, plus any legacy duplicates) fold
|
||||
// into one displayed entry; its rowIds carries every collapsed relationshipid
|
||||
// so a delete removes them all.
|
||||
const connectedItems = computed(() => {
|
||||
const byPair = new Map()
|
||||
const rows = [
|
||||
...outgoing.value.map(rel => ({ rel, peer: rel.targetasset })),
|
||||
...incoming.value.map(rel => ({ rel, peer: rel.sourceasset })),
|
||||
]
|
||||
for (const { rel, peer } of rows) {
|
||||
if (rel.isdirectional !== false) continue
|
||||
const selfid = resolvedAssetId.value
|
||||
const peerid = peer?.assetid
|
||||
const lo = Math.min(selfid, peerid)
|
||||
const hi = Math.max(selfid, peerid)
|
||||
const key = `${rel.relationshiptypeid}:${lo}:${hi}`
|
||||
const existing = byPair.get(key)
|
||||
if (existing) {
|
||||
existing.rowIds.push(rel.relationshipid)
|
||||
if (!existing.notes && rel.notes) existing.notes = rel.notes
|
||||
} else {
|
||||
byPair.set(key, {
|
||||
key,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer,
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
}
|
||||
return Array.from(byPair.values())
|
||||
})
|
||||
|
||||
// Directional types keep one entry per stored row with arrow phrasing.
|
||||
const directionalItems = computed(() => {
|
||||
const items = []
|
||||
for (const rel of outgoing.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `out-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.targetasset,
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
for (const rel of incoming.value) {
|
||||
if (rel.isdirectional === false) continue
|
||||
items.push({
|
||||
key: `in-${rel.relationshipid}`,
|
||||
rowIds: [rel.relationshipid],
|
||||
peer: rel.sourceasset,
|
||||
direction: 'incoming',
|
||||
relationshiptypeid: rel.relationshiptypeid,
|
||||
relationshiptypename: rel.relationshiptypename,
|
||||
notes: rel.notes || null,
|
||||
})
|
||||
}
|
||||
return items
|
||||
})
|
||||
|
||||
const canSave = computed(() => {
|
||||
return newRel.value.relationshiptypeid && newRel.value.targetAssetId && resolvedAssetId.value
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await Promise.all([loadRelationships(), loadRelationshipTypes()])
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.assetid, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => props.machinenumber, async () => {
|
||||
await resolveAssetId()
|
||||
if (resolvedAssetId.value) {
|
||||
await loadRelationships()
|
||||
}
|
||||
})
|
||||
|
||||
async function resolveAssetId() {
|
||||
// If assetid is provided directly, use it
|
||||
if (props.assetid) {
|
||||
resolvedAssetId.value = props.assetid
|
||||
lookupFailed.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Otherwise, try to look up by machine number
|
||||
if (props.machinenumber) {
|
||||
try {
|
||||
const response = await assetsApi.lookup(props.machinenumber)
|
||||
resolvedAssetId.value = response.data.data?.assetid
|
||||
lookupFailed.value = !resolvedAssetId.value
|
||||
} catch (error) {
|
||||
resolvedAssetId.value = null
|
||||
lookupFailed.value = true
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationships() {
|
||||
if (!resolvedAssetId.value) return
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await assetsApi.getRelationships(resolvedAssetId.value)
|
||||
outgoing.value = response.data.data?.outgoing || []
|
||||
incoming.value = response.data.data?.incoming || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load relationships:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRelationshipTypes() {
|
||||
try {
|
||||
const response = await relationshipTypesApi.list()
|
||||
relationshipTypes.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load relationship types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function searchAssets() {
|
||||
clearTimeout(searchTimeout)
|
||||
if (!assetSearchQuery.value || assetSearchQuery.value.length < 2) {
|
||||
assetSearchResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await assetsApi.search(assetSearchQuery.value, { perpage: 10 })
|
||||
// Filter out the current asset
|
||||
assetSearchResults.value = (response.data.data || []).filter(
|
||||
a => a.assetid !== resolvedAssetId.value
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('Failed to search assets:', error)
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectAsset(asset) {
|
||||
selectedAsset.value = asset
|
||||
newRel.value.targetAssetId = asset.assetid
|
||||
assetSearchQuery.value = ''
|
||||
assetSearchResults.value = []
|
||||
}
|
||||
|
||||
function clearSelectedAsset() {
|
||||
selectedAsset.value = null
|
||||
newRel.value.targetAssetId = null
|
||||
}
|
||||
|
||||
async function saveRelationship() {
|
||||
if (!canSave.value) return
|
||||
|
||||
try {
|
||||
const data = {
|
||||
relationshiptypeid: parseInt(newRel.value.relationshiptypeid),
|
||||
notes: newRel.value.notes || null
|
||||
}
|
||||
|
||||
if (newRel.value.direction === 'outgoing') {
|
||||
data.sourceassetid = resolvedAssetId.value
|
||||
data.targetassetid = newRel.value.targetAssetId
|
||||
} else {
|
||||
data.sourceassetid = newRel.value.targetAssetId
|
||||
data.targetassetid = resolvedAssetId.value
|
||||
}
|
||||
|
||||
await assetsApi.createRelationship(data)
|
||||
await loadRelationships()
|
||||
closeModal()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
console.error('Failed to create relationship:', error)
|
||||
toast.error(apiError(error, 'Failed to create relationship'))
|
||||
}
|
||||
}
|
||||
|
||||
// Delete every collapsed direction row behind a displayed entry.
|
||||
async function deleteItem(item) {
|
||||
if (!confirm('Remove this relationship?')) return
|
||||
|
||||
try {
|
||||
await Promise.all(item.rowIds.map(id => assetsApi.deleteRelationship(id)))
|
||||
await loadRelationships()
|
||||
emit('updated')
|
||||
} catch (error) {
|
||||
console.error('Failed to delete relationship:', error)
|
||||
toast.error('Failed to delete relationship')
|
||||
}
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showAddModal.value = false
|
||||
newRel.value = {
|
||||
direction: 'outgoing',
|
||||
relationshiptypeid: '',
|
||||
targetAssetId: null,
|
||||
notes: ''
|
||||
}
|
||||
selectedAsset.value = null
|
||||
assetSearchQuery.value = ''
|
||||
assetSearchResults.value = []
|
||||
}
|
||||
|
||||
function getAssetIcon(assettype) {
|
||||
const icons = {
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
function getAssetRoute(asset) {
|
||||
if (!asset) return '#'
|
||||
|
||||
const routeMap = {
|
||||
'machine': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'measuring_tool': '/measuringtools'
|
||||
}
|
||||
|
||||
// serializer sends assettypename; old shape used assettype
|
||||
const typename = asset.assettypename || asset.assettype
|
||||
const basePath = routeMap[typename] || '/assets'
|
||||
|
||||
// pluginid is the extension-table id the detail routes key on
|
||||
const id = asset.pluginid
|
||||
|| asset.typedata?.networkdeviceid
|
||||
|| asset.typedata?.machineid
|
||||
|| asset.assetid
|
||||
return `${basePath}/${id}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.relationships-section {
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
padding: 1.25rem;
|
||||
/* Match .section-card spacing so relationships is its own card with a gap
|
||||
below (not visually merged with the Notes card) and does not split across
|
||||
a multicol break. */
|
||||
margin-bottom: 25px;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
|
||||
.relationship-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.relationship-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-light);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.relationship-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.relationship-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rel-icon {
|
||||
font-size: 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.rel-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.rel-name {
|
||||
font-weight: 500;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.rel-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rel-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.rel-arrow {
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-from {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-outline {
|
||||
background: transparent;
|
||||
border: 1px solid var(--primary);
|
||||
color: var(--primary);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.rel-type-badge {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.rel-notes {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.btn-icon.delete {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--danger);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-icon.delete:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.modal-header .btn-icon {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1.25rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Asset Search */
|
||||
.asset-search {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-results {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 10;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.search-result-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.6rem 0.75rem;
|
||||
cursor: pointer;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.search-result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.search-result-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.search-result-item.selected {
|
||||
background: rgba(65, 129, 255, 0.15);
|
||||
}
|
||||
|
||||
.result-icon {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.result-name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.selected-asset {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(65, 129, 255, 0.1);
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.selected-asset .btn-icon {
|
||||
margin-left: auto;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
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>
|
||||
53
frontend/src/components/EmailReportButton.vue
Normal file
53
frontend/src/components/EmailReportButton.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<template>
|
||||
<button class="btn btn-secondary" :disabled="sending" @click="emailReport">
|
||||
{{ sending ? 'Sending...' : 'Email report' }}
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { reportsApi } from '../api'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { apiError } from '../utils/apiError'
|
||||
|
||||
// On-demand report delivery. Emails the given rows as an HTML table to the
|
||||
// site's Alert Recipients (or an explicit `to`). Automatic/scheduled sending is
|
||||
// out of scope for this app; point an external cron at POST /api/reports/email
|
||||
// with an API token to automate.
|
||||
const props = defineProps({
|
||||
subject: { type: String, required: true },
|
||||
columns: { type: Array, required: true },
|
||||
rows: { type: Array, required: true },
|
||||
intro: { type: String, default: '' },
|
||||
to: { type: String, default: '' },
|
||||
})
|
||||
|
||||
const toast = useToast()
|
||||
const sending = ref(false)
|
||||
|
||||
async function emailReport() {
|
||||
sending.value = true
|
||||
try {
|
||||
const payload = {
|
||||
subject: props.subject,
|
||||
columns: props.columns,
|
||||
rows: props.rows,
|
||||
intro: props.intro,
|
||||
}
|
||||
if (props.to) payload.to = props.to
|
||||
const response = await reportsApi.email(payload)
|
||||
const result = response.data?.data || {}
|
||||
if (result.sent) {
|
||||
toast.success('Report emailed.')
|
||||
} else if (result.error) {
|
||||
toast.error(`Report email failed: ${result.error}`)
|
||||
} else {
|
||||
toast.info(response.data?.message || 'Email is not configured.')
|
||||
}
|
||||
} catch (event) {
|
||||
toast.error(apiError(event, 'Failed to email report'))
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
141
frontend/src/components/EmbeddedLocationMap.vue
Normal file
141
frontend/src/components/EmbeddedLocationMap.vue
Normal file
@@ -0,0 +1,141 @@
|
||||
<template>
|
||||
<div class="embedded-map" ref="mapContainer"></div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 },
|
||||
top: { type: Number, default: null },
|
||||
markerColor: { type: String, default: '#ff0000' },
|
||||
markerLabel: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let marker = null
|
||||
|
||||
// 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,
|
||||
maxZoom: 2,
|
||||
attributionControl: false,
|
||||
zoomControl: true
|
||||
})
|
||||
|
||||
L.imageOverlay(blueprintUrlFor(currentTheme.value), bounds).addTo(map)
|
||||
|
||||
// Convert database coordinates to Leaflet (y is inverted)
|
||||
const leafletY = MAP_HEIGHT - props.top
|
||||
const leafletX = props.left
|
||||
|
||||
// Create marker
|
||||
const icon = L.divIcon({
|
||||
html: `<div class="location-marker-dot" style="background: ${props.markerColor};"></div>`,
|
||||
iconSize: [20, 20],
|
||||
iconAnchor: [10, 10],
|
||||
className: 'location-marker'
|
||||
})
|
||||
|
||||
marker = L.marker([leafletY, leafletX], { icon })
|
||||
|
||||
if (props.markerLabel) {
|
||||
marker.bindTooltip(props.markerLabel, {
|
||||
permanent: true,
|
||||
direction: 'top',
|
||||
offset: [0, -10],
|
||||
className: 'location-label'
|
||||
})
|
||||
}
|
||||
|
||||
marker.addTo(map)
|
||||
|
||||
// Center on marker with appropriate zoom
|
||||
map.setView([leafletY, leafletX], -1)
|
||||
map.setMaxBounds(bounds)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadMapConfig()
|
||||
initMap()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
})
|
||||
|
||||
watch([() => props.left, () => props.top], () => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
initMap()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.embedded-map {
|
||||
width: 100%;
|
||||
height: 300px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
:deep(.location-marker) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:deep(.location-marker-dot) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
border: 3px solid #fff;
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 2px rgba(0,0,0,0.3), 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 6px rgba(255,0,0,0.2), 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.location-label) {
|
||||
background: rgba(0, 0, 0, 0.85);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 4px 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
}
|
||||
|
||||
:deep(.location-label::before) {
|
||||
border-top-color: rgba(0, 0, 0, 0.85);
|
||||
}
|
||||
</style>
|
||||
251
frontend/src/components/EmployeeSearch.vue
Normal file
251
frontend/src/components/EmployeeSearch.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<template>
|
||||
<div class="employee-search">
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
@input="onSearch"
|
||||
@keydown.enter.prevent="addCustom"
|
||||
/>
|
||||
<div v-if="results.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in results"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<small v-if="allowCustom" class="form-hint">Search for employees or press Enter to add a custom name</small>
|
||||
|
||||
<!-- Selected employee(s) display -->
|
||||
<div v-if="multiple && selectedList.length" class="selected-employees">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedList"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<span v-if="emp.sso && !emp.sso.startsWith('NAME:')" class="emp-sso-tag">{{ emp.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="!multiple && selected" class="selected-employee-display">
|
||||
<span>{{ selected.name }}</span>
|
||||
<span v-if="selected.sso" class="emp-sso-tag">{{ selected.sso }}</span>
|
||||
<button v-if="!disabled" type="button" class="btn-remove" @click="clearSelection">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { employeesApi } from '../api'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: [Object, Array], default: null },
|
||||
multiple: { type: Boolean, default: false },
|
||||
allowCustom: { type: Boolean, default: false },
|
||||
placeholder: { type: String, default: 'Search by name...' },
|
||||
disabled: { type: Boolean, default: false }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const results = ref([])
|
||||
const selected = ref(null)
|
||||
const selectedList = ref([])
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Initialize from modelValue
|
||||
watch(() => props.modelValue, (val) => {
|
||||
if (props.multiple) {
|
||||
selectedList.value = val || []
|
||||
} else {
|
||||
selected.value = val
|
||||
}
|
||||
}, { immediate: true })
|
||||
|
||||
async function onSearch() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = searchQuery.value.trim()
|
||||
if (query.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
results.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
results.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
const employee = {
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
// Check if already selected
|
||||
if (selectedList.value.some(e => e.sso === employee.sso)) {
|
||||
return
|
||||
}
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function addCustom() {
|
||||
if (!props.allowCustom) return
|
||||
|
||||
const name = searchQuery.value.trim()
|
||||
if (!name) return
|
||||
|
||||
const employee = {
|
||||
sso: `NAME:${name}`,
|
||||
name: name
|
||||
}
|
||||
|
||||
if (props.multiple) {
|
||||
selectedList.value.push(employee)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
} else {
|
||||
selected.value = employee
|
||||
emit('update:modelValue', employee)
|
||||
}
|
||||
|
||||
searchQuery.value = ''
|
||||
results.value = []
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedList.value.splice(idx, 1)
|
||||
emit('update:modelValue', selectedList.value)
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selected.value = null
|
||||
emit('update:modelValue', null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Multiple selection */
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Single selection */
|
||||
.selected-employee-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 0.25rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.emp-sso-tag {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
</style>
|
||||
268
frontend/src/components/LocationMapTooltip.vue
Normal file
268
frontend/src/components/LocationMapTooltip.vue
Normal file
@@ -0,0 +1,268 @@
|
||||
<template>
|
||||
<div class="location-tooltip-wrapper" @mouseenter="showTooltip" @mouseleave="onWrapperLeave">
|
||||
<slot></slot>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible && hasPosition"
|
||||
class="map-tooltip"
|
||||
:style="tooltipStyle"
|
||||
ref="tooltipRef"
|
||||
@mouseenter="onTooltipEnter"
|
||||
@mouseleave="onTooltipLeave"
|
||||
@wheel.prevent="onWheel"
|
||||
>
|
||||
<div class="map-tooltip-content">
|
||||
<div class="map-preview" ref="mapPreview">
|
||||
<div
|
||||
class="map-transform"
|
||||
:style="transformStyle"
|
||||
>
|
||||
<img
|
||||
:src="blueprintUrl"
|
||||
alt="Shop Floor Map"
|
||||
class="map-image"
|
||||
@load="onImageLoad"
|
||||
/>
|
||||
<!-- Marker dot -->
|
||||
<div
|
||||
class="marker-dot"
|
||||
:style="markerStyle"
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="map-tooltip-footer">
|
||||
<span class="coordinates">{{ left }}, {{ top }}</span>
|
||||
<span class="zoom-hint">Scroll to zoom</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 },
|
||||
top: { type: Number, default: null },
|
||||
machineName: { type: String, default: '' }
|
||||
})
|
||||
|
||||
const visible = ref(false)
|
||||
const tooltipRef = ref(null)
|
||||
const mapPreview = ref(null)
|
||||
const tooltipPosition = ref({ x: 0, y: 0 })
|
||||
const isOverTooltip = ref(false)
|
||||
const zoom = ref(1)
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
const hasPosition = computed(() => {
|
||||
return props.left !== null && props.top !== null
|
||||
})
|
||||
|
||||
const blueprintUrl = computed(() => {
|
||||
// Reading currentTheme keeps this reactive to theme changes.
|
||||
return blueprintUrlFor(currentTheme.value)
|
||||
})
|
||||
|
||||
// Calculate marker position as percentage of the facility blueprint size
|
||||
const markerX = computed(() => {
|
||||
return (props.left / mapConfig.width) * 100
|
||||
})
|
||||
|
||||
const markerY = computed(() => {
|
||||
return (props.top / mapConfig.height) * 100
|
||||
})
|
||||
|
||||
// Marker style with counter-scale to maintain constant size
|
||||
const markerStyle = computed(() => ({
|
||||
left: markerX.value + '%',
|
||||
top: markerY.value + '%',
|
||||
transform: `translate(-50%, -50%) scale(${1 / zoom.value})`
|
||||
}))
|
||||
|
||||
// Transform style that centers on the marker and zooms toward it
|
||||
const transformStyle = computed(() => {
|
||||
// Calculate translation to center the marker in the preview
|
||||
const translateX = 50 - markerX.value
|
||||
const translateY = 50 - markerY.value
|
||||
|
||||
return {
|
||||
transform: `translate(${translateX}%, ${translateY}%) scale(${zoom.value})`,
|
||||
transformOrigin: `${markerX.value}% ${markerY.value}%`
|
||||
}
|
||||
})
|
||||
|
||||
const tooltipStyle = computed(() => ({
|
||||
left: `${tooltipPosition.value.x}px`,
|
||||
top: `${tooltipPosition.value.y}px`
|
||||
}))
|
||||
|
||||
function onImageLoad() {
|
||||
imageLoaded.value = true
|
||||
}
|
||||
|
||||
function showTooltip(event) {
|
||||
if (!hasPosition.value) return
|
||||
|
||||
visible.value = true
|
||||
zoom.value = 1
|
||||
|
||||
const rect = event.target.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.bottom + 10
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
adjustPosition()
|
||||
})
|
||||
}
|
||||
|
||||
function onWrapperLeave() {
|
||||
// Small delay to allow moving to tooltip
|
||||
setTimeout(() => {
|
||||
if (!isOverTooltip.value) {
|
||||
hideTooltip()
|
||||
}
|
||||
}, 100)
|
||||
}
|
||||
|
||||
function onTooltipEnter() {
|
||||
isOverTooltip.value = true
|
||||
}
|
||||
|
||||
function onTooltipLeave() {
|
||||
isOverTooltip.value = false
|
||||
hideTooltip()
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
visible.value = false
|
||||
zoom.value = 1
|
||||
}
|
||||
|
||||
function onWheel(event) {
|
||||
const delta = event.deltaY > 0 ? -0.3 : 0.3
|
||||
const newZoom = Math.max(1, Math.min(8, zoom.value + delta))
|
||||
zoom.value = newZoom
|
||||
}
|
||||
|
||||
function adjustPosition() {
|
||||
if (!tooltipRef.value) return
|
||||
|
||||
const tooltip = tooltipRef.value
|
||||
const rect = tooltip.getBoundingClientRect()
|
||||
const viewportWidth = window.innerWidth
|
||||
const viewportHeight = window.innerHeight
|
||||
|
||||
if (rect.right > viewportWidth - 20) {
|
||||
tooltipPosition.value.x -= (rect.right - viewportWidth + 20)
|
||||
}
|
||||
if (rect.left < 20) {
|
||||
tooltipPosition.value.x += (20 - rect.left)
|
||||
}
|
||||
|
||||
if (rect.bottom > viewportHeight - 20) {
|
||||
tooltipPosition.value.y = rect.top - tooltip.offsetHeight - 20
|
||||
}
|
||||
}
|
||||
|
||||
// Reset zoom when tooltip becomes visible
|
||||
watch(visible, (newVal) => {
|
||||
if (newVal) {
|
||||
zoom.value = 1
|
||||
}
|
||||
})
|
||||
|
||||
// Reset imageLoaded when theme changes to force reload
|
||||
watch(currentTheme, () => {
|
||||
imageLoaded.value = false
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.location-tooltip-wrapper {
|
||||
display: inline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.location-tooltip-wrapper:hover {
|
||||
color: var(--primary, #1976d2);
|
||||
}
|
||||
</style>
|
||||
|
||||
<style>
|
||||
.map-tooltip {
|
||||
position: fixed;
|
||||
z-index: 10000;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.map-tooltip-content {
|
||||
background: var(--bg-card, #ffffff);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border, #e0e0e0);
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.25);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
position: relative;
|
||||
width: 500px;
|
||||
height: 385px;
|
||||
overflow: hidden;
|
||||
background: var(--bg, #f5f5f5);
|
||||
}
|
||||
|
||||
.map-transform {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
transition: transform 0.15s ease-out;
|
||||
}
|
||||
|
||||
.map-image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.marker-dot {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
background: #ff0000;
|
||||
border: 2px solid #ffffff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 3px rgba(255,0,0,0.3), 0 0 10px #ff0000;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.map-tooltip-footer {
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg, #f5f5f5);
|
||||
border-top: 1px solid var(--border, #e0e0e0);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.coordinates {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
|
||||
.zoom-hint {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-light, #666666);
|
||||
}
|
||||
</style>
|
||||
145
frontend/src/components/Modal.vue
Normal file
145
frontend/src/components/Modal.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div v-if="modelValue" class="modal-overlay" @click.self="closeOnOverlay && close()">
|
||||
<div class="modal-container" :class="sizeClass">
|
||||
<div class="modal-header" v-if="title || $slots.header">
|
||||
<slot name="header">
|
||||
<h3>{{ title }}</h3>
|
||||
</slot>
|
||||
<button class="modal-close" @click="close" aria-label="Close">×</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<slot></slot>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer" v-if="$slots.footer">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: { type: Boolean, default: false },
|
||||
title: { type: String, default: '' },
|
||||
size: { type: String, default: 'medium' }, // small, medium, large, fullscreen
|
||||
closeOnOverlay: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'close'])
|
||||
|
||||
const sizeClass = computed(() => `modal-${props.size}`)
|
||||
|
||||
function close() {
|
||||
emit('update:modelValue', false)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Handle escape key
|
||||
watch(() => props.modelValue, (isOpen) => {
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = 'hidden'
|
||||
} else {
|
||||
document.removeEventListener('keydown', handleEscape)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
})
|
||||
|
||||
function handleEscape(e) {
|
||||
if (e.key === 'Escape') close()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-container {
|
||||
background: var(--bg-card-solid);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 90vh;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-small {
|
||||
width: 400px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-medium {
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
.modal-large {
|
||||
width: 900px;
|
||||
max-width: 95vw;
|
||||
}
|
||||
|
||||
.modal-fullscreen {
|
||||
width: 95vw;
|
||||
height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-close:hover {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 1rem 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
83
frontend/src/components/PaginationBar.vue
Normal file
83
frontend/src/components/PaginationBar.vue
Normal file
@@ -0,0 +1,83 @@
|
||||
<template>
|
||||
<div class="pagination-bar" v-if="totalPages > 1 || showPerPage">
|
||||
<div class="pagination-info">
|
||||
<select
|
||||
v-if="showPerPage"
|
||||
:value="perPage"
|
||||
class="perpage-select"
|
||||
@change="$emit('update:perPage', Number($event.target.value))"
|
||||
>
|
||||
<option v-for="opt in perPageOptions" :key="opt" :value="opt">
|
||||
{{ opt }} per page
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="pagination" v-if="totalPages > 1">
|
||||
<button :disabled="page === 1" @click="$emit('update:page', page - 1)">
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-for="p in visiblePages"
|
||||
:key="p"
|
||||
:class="{ active: p === page, ellipsis: p === '...' }"
|
||||
:disabled="p === '...'"
|
||||
@click="p !== '...' && $emit('update:page', p)"
|
||||
>
|
||||
{{ p }}
|
||||
</button>
|
||||
|
||||
<button :disabled="page === totalPages" @click="$emit('update:page', page + 1)">
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
page: { type: Number, required: true },
|
||||
totalPages: { type: Number, required: true },
|
||||
perPage: { type: Number, default: 20 },
|
||||
showPerPage: { type: Boolean, default: true }
|
||||
})
|
||||
|
||||
defineEmits(['update:page', 'update:perPage'])
|
||||
|
||||
const perPageOptions = [10, 20, 50, 100]
|
||||
|
||||
const visiblePages = computed(() => {
|
||||
const total = props.totalPages
|
||||
const current = props.page
|
||||
const pages = []
|
||||
|
||||
if (total <= 7) {
|
||||
for (let i = 1; i <= total; i++) pages.push(i)
|
||||
return pages
|
||||
}
|
||||
|
||||
pages.push(1)
|
||||
|
||||
if (current > 3) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
const start = Math.max(2, current - 1)
|
||||
const end = Math.min(total - 1, current + 1)
|
||||
|
||||
for (let i = start; i <= end; i++) {
|
||||
pages.push(i)
|
||||
}
|
||||
|
||||
if (current < total - 2) {
|
||||
pages.push('...')
|
||||
}
|
||||
|
||||
pages.push(total)
|
||||
|
||||
return pages
|
||||
})
|
||||
</script>
|
||||
790
frontend/src/components/ShopFloorMap.vue
Normal file
790
frontend/src/components/ShopFloorMap.vue
Normal file
@@ -0,0 +1,790 @@
|
||||
<template>
|
||||
<div class="shopfloor-map">
|
||||
<!-- Controls for legacy machine mode only (MapView handles filters for asset mode) -->
|
||||
<div class="map-controls" v-if="!pickerMode && !assetTypeMode">
|
||||
<div class="filters">
|
||||
<select v-model="filters.machinetype" @change="applyFilters">
|
||||
<option value="">All Types</option>
|
||||
<option v-for="t in machinetypes" :key="t.machinetypeid" :value="t.machinetypeid">
|
||||
{{ t.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filters.businessunit" @change="applyFilters">
|
||||
<option value="">All Business Units</option>
|
||||
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filters.status" @change="applyFilters">
|
||||
<option value="">All Statuses</option>
|
||||
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
v-model="filters.search"
|
||||
placeholder="Search..."
|
||||
@input="debounceSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="legend">
|
||||
<span
|
||||
v-for="t in visibleTypes"
|
||||
:key="t.machinetypeid"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: getTypeColor(t.machinetype) }"></span>
|
||||
{{ t.machinetype }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Legend for asset type mode - shows subtypes when a type is selected -->
|
||||
<div class="map-legend" v-if="!pickerMode && assetTypeMode">
|
||||
<!-- Show subtype legend when a specific type is selected -->
|
||||
<template v-if="selectedAssetType && Object.keys(visibleSubtypes).length">
|
||||
<span
|
||||
v-for="(color, subtypeId) in visibleSubtypes"
|
||||
:key="subtypeId"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: color }"></span>
|
||||
{{ subtypeNames[subtypeId] || `Type ${subtypeId}` }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- Show asset type legend when no type is selected -->
|
||||
<template v-else>
|
||||
<span
|
||||
v-for="(color, assetType) in visibleAssetTypes"
|
||||
:key="assetType"
|
||||
class="legend-item"
|
||||
>
|
||||
<span class="legend-dot" :style="{ background: color }"></span>
|
||||
{{ assetTypeLabels[assetType] || assetType }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="picker-controls" v-if="pickerMode">
|
||||
<span class="picker-message">Click on the map to set location</span>
|
||||
<span v-if="pickedPosition" class="picker-coords">
|
||||
Position: {{ pickedPosition.left }}, {{ pickedPosition.top }}
|
||||
</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="clearPosition">Clear</button>
|
||||
</div>
|
||||
|
||||
<div ref="mapContainer" class="map-container" :class="{ 'picker-active': pickerMode }"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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: () => [] },
|
||||
machinetypes: { type: Array, default: () => [] },
|
||||
businessunits: { type: Array, default: () => [] },
|
||||
statuses: { type: Array, default: () => [] },
|
||||
theme: { type: String, default: 'dark' },
|
||||
pickerMode: { type: Boolean, default: false },
|
||||
initialPosition: { type: Object, default: null }, // { left, top }
|
||||
assetTypeMode: { type: Boolean, default: false }, // When true, use unified asset format
|
||||
selectedAssetType: { type: String, default: '' }, // Currently selected asset type filter
|
||||
subtypeColors: { type: Object, default: () => ({}) }, // Map of subtype ID to color
|
||||
subtypeNames: { type: Object, default: () => ({}) } // Map of subtype ID to name
|
||||
})
|
||||
|
||||
const emit = defineEmits(['markerClick', 'positionPicked'])
|
||||
|
||||
const mapContainer = ref(null)
|
||||
let map = null
|
||||
let imageOverlay = null
|
||||
const markers = ref([])
|
||||
const pickedPosition = ref(null)
|
||||
let pickerMarker = null
|
||||
let markerLayer = null
|
||||
let canvasRenderer = null
|
||||
|
||||
const filters = ref({
|
||||
machinetype: '',
|
||||
businessunit: '',
|
||||
status: '',
|
||||
search: ''
|
||||
})
|
||||
|
||||
// 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 = {
|
||||
'machine': '#F44336', // Red
|
||||
'computer': '#2196F3', // Blue
|
||||
'printer': '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
'network_device': '#FF9800', // Orange (alternate key)
|
||||
'measuring_tool': '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
}
|
||||
|
||||
// Get asset type color with case-insensitive lookup
|
||||
function getAssetTypeColor(assettype) {
|
||||
if (!assettype) return '#BDBDBD'
|
||||
const normalized = assettype.toLowerCase()
|
||||
return assetTypeColorsMap[normalized] || '#BDBDBD'
|
||||
}
|
||||
|
||||
// Asset-type display labels come from the shared util (single source of truth).
|
||||
const assetTypeLabels = new Proxy({}, {
|
||||
get(target, prop) {
|
||||
return typeof prop === 'string' ? assetTypeLabel(prop) : prop
|
||||
}
|
||||
})
|
||||
|
||||
// Type colors - distinct colors for each machine type
|
||||
const typeColors = {
|
||||
// Machining
|
||||
'Mill': '#F44336', // Red
|
||||
'Lathe': '#E91E63', // Pink
|
||||
'Grinder': '#2196F3', // Blue
|
||||
'Broach': '#00BCD4', // Cyan
|
||||
'Hobbing': '#009688', // Teal
|
||||
'Turn': '#FF5722', // Deep Orange (Mill Turn, Vertical Turn)
|
||||
|
||||
// Inspection & Measurement
|
||||
'CMM': '#9C27B0', // Purple
|
||||
'Measuring': '#7B1FA2', // Dark Purple
|
||||
'Eddy': '#673AB7', // Deep Purple
|
||||
'Inspection': '#8BC34A', // Light Green
|
||||
|
||||
// Heat Treatment & Processing
|
||||
'Furnace': '#FF9800', // Orange
|
||||
'Wash': '#4CAF50', // Green
|
||||
'Wax': '#FFEB3B', // Yellow
|
||||
|
||||
// Automation
|
||||
'Robot': '#3F51B5', // Indigo
|
||||
'Deburr': '#5C6BC0', // Indigo Light
|
||||
|
||||
// Welding
|
||||
'Welder': '#795548', // Brown
|
||||
|
||||
// IT/Network
|
||||
'PC': '#607D8B', // Blue Grey
|
||||
'Printer': '#78909C', // Blue Grey Light
|
||||
'Switch': '#546E7A', // Blue Grey Dark
|
||||
'Access Point': '#455A64', // Blue Grey Darker
|
||||
'IDF': '#37474F', // Blue Grey Very Dark
|
||||
|
||||
// Other
|
||||
'Saw': '#8D6E63', // Brown Light
|
||||
'Press': '#A1887F', // Brown Lighter
|
||||
'EDM': '#00ACC1', // Cyan Dark
|
||||
'Drill': '#26A69A', // Teal Light
|
||||
'CNC': '#66BB6A', // Green Light
|
||||
'Assembly': '#CDDC39', // Lime
|
||||
'Other': '#BDBDBD' // Grey
|
||||
}
|
||||
|
||||
function getTypeColor(typeName) {
|
||||
if (!typeName) return '#BDBDBD'
|
||||
for (const [key, color] of Object.entries(typeColors)) {
|
||||
if (typeName.toLowerCase().includes(key.toLowerCase())) {
|
||||
return color
|
||||
}
|
||||
}
|
||||
return '#BDBDBD' // Grey for unknown types
|
||||
}
|
||||
|
||||
// Get unique visible types from machines
|
||||
const visibleTypes = computed(() => {
|
||||
const typeIds = new Set(props.machines.map(m => m.machinetypeid))
|
||||
return props.machinetypes.filter(t => typeIds.has(t.machinetypeid))
|
||||
})
|
||||
|
||||
// Get unique visible asset types (for asset mode)
|
||||
const visibleAssetTypes = computed(() => {
|
||||
const types = new Set(props.machines.map(m => m.assettype).filter(Boolean))
|
||||
const result = {}
|
||||
for (const t of types) {
|
||||
result[t] = getAssetTypeColor(t)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
// Get subtype ID from asset based on asset type
|
||||
function getSubtypeId(asset) {
|
||||
if (!asset.typedata) return null
|
||||
// Normalize network_device -> network device so the subtype id resolves.
|
||||
const typeLower = (asset.assettype || '').toLowerCase().replace(/_/g, ' ')
|
||||
if (typeLower === 'machine') return asset.typedata.machinetypeid
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
// Get visible subtypes when a type is selected
|
||||
const visibleSubtypes = computed(() => {
|
||||
if (!props.selectedAssetType) return {}
|
||||
const subtypeIds = new Set(
|
||||
props.machines
|
||||
.map(m => getSubtypeId(m))
|
||||
.filter(id => id != null)
|
||||
)
|
||||
const result = {}
|
||||
for (const id of subtypeIds) {
|
||||
if (props.subtypeColors[id]) {
|
||||
result[id] = props.subtypeColors[id]
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
function initMap() {
|
||||
if (!mapContainer.value) return
|
||||
|
||||
// Use canvas renderer for performance - renders all markers on a single <canvas>
|
||||
canvasRenderer = L.canvas({ padding: 0.5 })
|
||||
|
||||
map = L.map(mapContainer.value, {
|
||||
crs: L.CRS.Simple,
|
||||
minZoom: -4,
|
||||
maxZoom: 2,
|
||||
attributionControl: false,
|
||||
renderer: canvasRenderer
|
||||
})
|
||||
|
||||
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
|
||||
const initialZoom = -2
|
||||
map.setView([MAP_HEIGHT / 2, MAP_WIDTH / 2], initialZoom)
|
||||
map.setMaxBounds(bounds)
|
||||
|
||||
// Picker mode: click to set position
|
||||
if (props.pickerMode) {
|
||||
map.on('click', handleMapClick)
|
||||
|
||||
// Show initial position if provided
|
||||
if (props.initialPosition) {
|
||||
setPickerPosition(props.initialPosition.left, props.initialPosition.top)
|
||||
}
|
||||
} else {
|
||||
renderMarkers()
|
||||
}
|
||||
}
|
||||
|
||||
function handleMapClick(e) {
|
||||
if (!props.pickerMode) return
|
||||
|
||||
const leafletY = e.latlng.lat
|
||||
const leafletX = e.latlng.lng
|
||||
|
||||
// Convert back to database coordinates
|
||||
const dbLeft = Math.round(leafletX)
|
||||
const dbTop = Math.round(MAP_HEIGHT - leafletY)
|
||||
|
||||
setPickerPosition(dbLeft, dbTop)
|
||||
}
|
||||
|
||||
function setPickerPosition(left, top) {
|
||||
// Remove old picker marker
|
||||
if (pickerMarker) {
|
||||
pickerMarker.remove()
|
||||
}
|
||||
|
||||
pickedPosition.value = { left, top }
|
||||
|
||||
// Convert to Leaflet coordinates
|
||||
const leafletY = MAP_HEIGHT - top
|
||||
const leafletX = left
|
||||
|
||||
const icon = L.divIcon({
|
||||
html: `<div class="picker-marker-dot"></div>`,
|
||||
iconSize: [16, 16],
|
||||
iconAnchor: [8, 8],
|
||||
className: 'picker-marker'
|
||||
})
|
||||
|
||||
pickerMarker = L.marker([leafletY, leafletX], { icon, draggable: true })
|
||||
pickerMarker.addTo(map)
|
||||
|
||||
// Allow dragging to fine-tune position
|
||||
pickerMarker.on('dragend', () => {
|
||||
const pos = pickerMarker.getLatLng()
|
||||
const newLeft = Math.round(pos.lng)
|
||||
const newTop = Math.round(MAP_HEIGHT - pos.lat)
|
||||
pickedPosition.value = { left: newLeft, top: newTop }
|
||||
emit('positionPicked', pickedPosition.value)
|
||||
})
|
||||
|
||||
emit('positionPicked', pickedPosition.value)
|
||||
}
|
||||
|
||||
function clearPosition() {
|
||||
if (pickerMarker) {
|
||||
pickerMarker.remove()
|
||||
pickerMarker = null
|
||||
}
|
||||
pickedPosition.value = null
|
||||
emit('positionPicked', null)
|
||||
}
|
||||
|
||||
// Get the detail page route based on machine category
|
||||
// Extensible for future addon types (network, cameras, etc.)
|
||||
function getDetailRoute(machine) {
|
||||
const category = machine.category?.toLowerCase() || ''
|
||||
const routeMap = {
|
||||
'machine': '/machines',
|
||||
'pc': '/pcs',
|
||||
'printer': '/printers',
|
||||
// Future addon routes can be added here:
|
||||
// 'network': '/network',
|
||||
// 'camera': '/cameras',
|
||||
}
|
||||
const basePath = routeMap[category] || '/machines'
|
||||
return `${basePath}/${machine.machineid}`
|
||||
}
|
||||
|
||||
function renderMarkers() {
|
||||
// Clear existing markers
|
||||
markers.value.forEach(m => m.marker.remove())
|
||||
markers.value = []
|
||||
|
||||
props.machines.forEach(item => {
|
||||
if (item.mapx == null || item.mapy == null) return
|
||||
|
||||
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
|
||||
const leafletY = MAP_HEIGHT - item.mapy
|
||||
const leafletX = item.mapx
|
||||
|
||||
// Determine color based on mode
|
||||
let color, typeName, displayName, detailRoute
|
||||
|
||||
if (props.assetTypeMode) {
|
||||
// Unified asset mode - use subtype colors when a type is selected
|
||||
if (props.selectedAssetType) {
|
||||
const subtypeId = getSubtypeId(item)
|
||||
color = (subtypeId && props.subtypeColors[subtypeId]) || '#BDBDBD'
|
||||
typeName = (subtypeId && props.subtypeNames[subtypeId]) || item.assettype || ''
|
||||
} else {
|
||||
// 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'
|
||||
// Collapsed dual-bay pair: show the combined '2007 / 2008' label.
|
||||
if (item.dualpathpartner) {
|
||||
displayName = `${item.assetnumber} / ${item.dualpathpartner}`
|
||||
}
|
||||
detailRoute = getAssetDetailRoute(item)
|
||||
} else {
|
||||
// Legacy machine mode
|
||||
typeName = item.machinetype || ''
|
||||
color = getTypeColor(typeName)
|
||||
displayName = item.alias || item.machinenumber || 'Unknown'
|
||||
detailRoute = getDetailRoute(item)
|
||||
}
|
||||
|
||||
// Use circleMarker instead of divIcon marker - renders on canvas
|
||||
// for much better performance (no DOM element per marker)
|
||||
const marker = L.circleMarker([leafletY, leafletX], {
|
||||
radius: 6,
|
||||
fillColor: color,
|
||||
color: 'rgba(255,255,255,0.8)',
|
||||
weight: 2,
|
||||
fillOpacity: 1,
|
||||
renderer: canvasRenderer
|
||||
})
|
||||
|
||||
// Build tooltip content
|
||||
let tooltipLines = [`<strong>${displayName}</strong>`]
|
||||
|
||||
if (props.assetTypeMode) {
|
||||
tooltipLines.push(`<span style="color: #888;">${item.assettype || 'Unknown'}</span>`)
|
||||
|
||||
if (item.primaryip) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.primaryip}</span>`)
|
||||
}
|
||||
if (item.typedata?.hostname) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">${item.typedata.hostname}</span>`)
|
||||
}
|
||||
} else {
|
||||
const category = item.category?.toLowerCase() || ''
|
||||
|
||||
if (typeName && typeName.toLowerCase() !== 'locationonly') {
|
||||
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
|
||||
}
|
||||
|
||||
if (item.vendor) {
|
||||
tooltipLines.push(`<span style="color: #aaa;">${item.vendor}${item.model ? ' ' + item.model : ''}</span>`)
|
||||
} else if (item.model) {
|
||||
tooltipLines.push(`<span style="color: #aaa;">${item.model}</span>`)
|
||||
}
|
||||
|
||||
if (category === 'printer') {
|
||||
if (item.ipaddress) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">IP: ${item.ipaddress}</span>`)
|
||||
}
|
||||
if (item.hostname) {
|
||||
tooltipLines.push(`<span style="color: #8cf;">${item.hostname}</span>`)
|
||||
}
|
||||
} else {
|
||||
if (item.connected_pc) {
|
||||
tooltipLines.push(`<span style="color: #fc8;">PC: ${item.connected_pc}</span>`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (item.businessunit) {
|
||||
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
|
||||
}
|
||||
|
||||
const tooltipContent = tooltipLines.join('<br>')
|
||||
marker.bindTooltip(tooltipContent, {
|
||||
direction: 'top',
|
||||
offset: [0, -12],
|
||||
className: 'marker-tooltip'
|
||||
})
|
||||
|
||||
// Click popup (detailed info)
|
||||
let popupContent
|
||||
if (props.assetTypeMode) {
|
||||
popupContent = `
|
||||
<div class="marker-popup">
|
||||
<strong>${displayName}</strong>
|
||||
<div class="popup-details">
|
||||
<div><span class="label">Asset #:</span> ${item.assetnumber || '-'}</div>
|
||||
<div><span class="label">Type:</span> ${item.assettype || '-'}</div>
|
||||
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
||||
<div><span class="label">Location:</span> ${item.location || '-'}</div>
|
||||
${item.primaryip ? `<div><span class="label">IP:</span> ${item.primaryip}</div>` : ''}
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
`
|
||||
} else {
|
||||
popupContent = `
|
||||
<div class="marker-popup">
|
||||
<strong>${displayName}</strong>
|
||||
<div class="popup-details">
|
||||
<div><span class="label">Number:</span> ${item.machinenumber || '-'}</div>
|
||||
<div><span class="label">Type:</span> ${typeName || '-'}</div>
|
||||
<div><span class="label">Category:</span> ${item.category || '-'}</div>
|
||||
<div><span class="label">Status:</span> ${item.status || '-'}</div>
|
||||
<div><span class="label">Vendor:</span> ${item.vendor || '-'}</div>
|
||||
<div><span class="label">Model:</span> ${item.model || '-'}</div>
|
||||
</div>
|
||||
<a href="${detailRoute}" class="popup-link">View Details</a>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
marker.bindPopup(popupContent)
|
||||
marker.on('click', () => emit('markerClick', item))
|
||||
|
||||
marker.addTo(map)
|
||||
|
||||
// Build search data
|
||||
const searchData = props.assetTypeMode
|
||||
? `${item.assetnumber} ${item.name} ${item.displayname} ${item.assettype} ${item.status} ${item.businessunit} ${item.typedata?.hostname || ''} ${item.primaryip || ''}`.toLowerCase()
|
||||
: `${item.machinenumber} ${item.alias} ${typeName} ${item.vendor} ${item.model} ${item.serialnumber} ${item.businessunit}`.toLowerCase()
|
||||
|
||||
markers.value.push({
|
||||
marker,
|
||||
machine: item,
|
||||
searchData
|
||||
})
|
||||
})
|
||||
|
||||
applyFilters()
|
||||
}
|
||||
|
||||
// Get detail route for unified asset format (shared util = single source).
|
||||
function getAssetDetailRoute(asset) {
|
||||
return assetDetailRoute(asset)
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const searchTerm = filters.value.search.toLowerCase()
|
||||
|
||||
markers.value.forEach(({ marker, machine, searchData }) => {
|
||||
let visible = true
|
||||
|
||||
if (filters.value.machinetype && machine.machinetypeid !== filters.value.machinetype) {
|
||||
visible = false
|
||||
}
|
||||
if (filters.value.businessunit && machine.businessunitid !== filters.value.businessunit) {
|
||||
visible = false
|
||||
}
|
||||
if (filters.value.status && machine.statusid !== filters.value.status) {
|
||||
visible = false
|
||||
}
|
||||
if (searchTerm && !searchData.includes(searchTerm)) {
|
||||
visible = false
|
||||
}
|
||||
|
||||
marker.setStyle({ fillOpacity: visible ? 1 : 0.15, opacity: visible ? 1 : 0.15 })
|
||||
})
|
||||
}
|
||||
|
||||
let searchTimeout = null
|
||||
function debounceSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(applyFilters, 300)
|
||||
}
|
||||
|
||||
watch(() => props.machines, (newVal, oldVal) => {
|
||||
if (map && newVal !== oldVal) renderMarkers()
|
||||
})
|
||||
|
||||
watch(() => props.theme, (newTheme) => {
|
||||
if (imageOverlay && map) {
|
||||
imageOverlay.setUrl(blueprintUrlFor(newTheme))
|
||||
}
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (map) {
|
||||
map.remove()
|
||||
map = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shopfloor-map {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-controls {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-card);
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters select,
|
||||
.filters input {
|
||||
padding: 0.625rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filters select {
|
||||
cursor: pointer;
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23999' d='M6 8L1 3h10z'/%3E%3C/svg%3E");
|
||||
background-repeat: no-repeat;
|
||||
background-position: right 0.75rem center;
|
||||
padding-right: 2.25rem;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.filters input {
|
||||
width: 280px;
|
||||
}
|
||||
|
||||
.filters input::placeholder {
|
||||
color: var(--text-light);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.filters input::placeholder {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.legend {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.map-legend-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
font-size: 1rem;
|
||||
color: var(--text);
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--bg-card);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex: 1;
|
||||
min-height: 600px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.map-container.picker-active {
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.picker-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.25rem;
|
||||
padding: 1rem;
|
||||
background: var(--warning);
|
||||
border-bottom: 1px solid var(--warning);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.picker-message {
|
||||
font-weight: 500;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.picker-coords {
|
||||
font-family: monospace;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
:deep(.marker-popup) {
|
||||
min-width: 320px;
|
||||
}
|
||||
|
||||
:deep(.marker-popup strong) {
|
||||
display: block;
|
||||
margin-bottom: 0.625rem;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
:deep(.popup-details) {
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.9;
|
||||
}
|
||||
|
||||
:deep(.popup-details .label) {
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.popup-link) {
|
||||
display: inline-block;
|
||||
margin-top: 1rem;
|
||||
color: #1976d2;
|
||||
text-decoration: none;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.popup-link:hover) {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
:deep(.machine-marker) {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
:deep(.machine-marker-dot) {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255,255,255,0.8);
|
||||
box-shadow: 0 1px 4px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
:deep(.picker-marker-dot) {
|
||||
background: #ff0000;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--border);
|
||||
box-shadow: 0 2px 6px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
:deep(.marker-tooltip) {
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
color: #fff;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 14px 18px;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.7;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5);
|
||||
max-width: 400px;
|
||||
}
|
||||
|
||||
:deep(.marker-tooltip::before) {
|
||||
border-top-color: rgba(0, 0, 0, 0.92);
|
||||
}
|
||||
|
||||
/* Legend bar for asset type mode (no filters - parent handles them) */
|
||||
.map-legend {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text);
|
||||
}
|
||||
</style>
|
||||
75
frontend/src/components/ToastHost.vue
Normal file
75
frontend/src/components/ToastHost.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div class="toast-host">
|
||||
<transition-group name="toast">
|
||||
<div
|
||||
v-for="t in toast.items"
|
||||
:key="t.id"
|
||||
class="toast"
|
||||
:class="`toast-${t.type}`"
|
||||
@click="toast.dismiss(t.id)"
|
||||
>
|
||||
<span class="toast-message">{{ t.message }}</span>
|
||||
<button class="toast-close" @click.stop="toast.dismiss(t.id)" aria-label="Dismiss">×</button>
|
||||
</div>
|
||||
</transition-group>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useToast } from '../composables/toast'
|
||||
|
||||
const toast = useToast()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.toast-host {
|
||||
position: fixed;
|
||||
bottom: 1.25rem;
|
||||
right: 1.25rem;
|
||||
z-index: 3000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
max-width: min(420px, calc(100vw - 2.5rem));
|
||||
}
|
||||
|
||||
.toast {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 0.9rem;
|
||||
border-radius: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-left: 4px solid var(--secondary);
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.25);
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
.toast-success { border-left-color: var(--success); }
|
||||
.toast-error { border-left-color: var(--danger); }
|
||||
.toast-info { border-left-color: var(--primary); }
|
||||
|
||||
.toast-message { flex: 1; }
|
||||
|
||||
.toast-close {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--text-light);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.toast-enter-active,
|
||||
.toast-leave-active {
|
||||
transition: opacity 0.2s ease, transform 0.2s ease;
|
||||
}
|
||||
.toast-enter-from,
|
||||
.toast-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(20px);
|
||||
}
|
||||
</style>
|
||||
81
frontend/src/components/WarrantyPanel.vue
Normal file
81
frontend/src/components/WarrantyPanel.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<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?addfor=${assetid}`" class="warranty-manage">Add / manage</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="warranty-empty">
|
||||
<span class="muted">No warranty on record.</span>
|
||||
<router-link :to="`/warranties?addfor=${assetid}`" 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 },
|
||||
// Optional pre-fetched warranties. When provided, the panel uses them instead
|
||||
// of fetching (lets a parent share one fetch with e.g. a hero badge).
|
||||
items: { type: Array, default: null },
|
||||
})
|
||||
|
||||
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() {
|
||||
// Parent-supplied data wins - skip the fetch.
|
||||
if (props.items !== null) { warranties.value = props.items; return }
|
||||
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)
|
||||
watch(() => props.items, 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>
|
||||
42
frontend/src/composables/enabledPlugins.js
Normal file
42
frontend/src/composables/enabledPlugins.js
Normal file
@@ -0,0 +1,42 @@
|
||||
// Enabled-plugin gating for the router. Fetches the enabled plugin-name list
|
||||
// ONCE (cached promise) so a disabled backend plugin's frontend routes become
|
||||
// unreachable by direct URL. Fail-open: any fetch error treats every plugin as
|
||||
// enabled, so an API blip can never brick navigation.
|
||||
import { pluginsApi } from '../api'
|
||||
|
||||
let enabledPromise = null
|
||||
let enabledSet = null // resolved Set<string>, or null while pending / on failure
|
||||
|
||||
// Kick off (or reuse) the single fetch. The endpoint is jwt-optional so this
|
||||
// works on unauthenticated kiosk routes (e.g. /tv) too.
|
||||
export function loadEnabledPlugins() {
|
||||
if (!enabledPromise) {
|
||||
enabledPromise = pluginsApi.enabled()
|
||||
.then(response => {
|
||||
const names = response?.data?.data
|
||||
// Only trust a real array; anything else -> fail open (null).
|
||||
enabledSet = Array.isArray(names) ? new Set(names) : null
|
||||
return enabledSet
|
||||
})
|
||||
.catch(() => {
|
||||
// Fail open: leave enabledSet null so isPluginEnabled returns true.
|
||||
enabledSet = null
|
||||
return null
|
||||
})
|
||||
}
|
||||
return enabledPromise
|
||||
}
|
||||
|
||||
// True if the plugin is enabled OR the list is unknown (fail-open). A null
|
||||
// enabledSet means we never got a trustworthy answer, so allow everything.
|
||||
export function isPluginEnabled(name) {
|
||||
if (!name) return true
|
||||
if (enabledSet === null) return true
|
||||
return enabledSet.has(name)
|
||||
}
|
||||
|
||||
// Test/hot-reload hook: forget the cached result so the next call refetches.
|
||||
export function resetEnabledPlugins() {
|
||||
enabledPromise = null
|
||||
enabledSet = null
|
||||
}
|
||||
71
frontend/src/composables/identifierSettings.js
Normal file
71
frontend/src/composables/identifierSettings.js
Normal file
@@ -0,0 +1,71 @@
|
||||
// Per-type enable/disable flags for optional asset identifiers, read from the
|
||||
// settings table. Keys follow identifier_<name>_<assettype>_enabled. A legacy
|
||||
// global key identifier_<name>_enabled is honored as a fallback for older
|
||||
// installs. Missing = enabled, so a fresh install shows every identifier.
|
||||
import { reactive } from 'vue'
|
||||
import { settingsApi } from '../api'
|
||||
|
||||
// scope[name][assettype] = boolean. legacy[name] = boolean (old global flag).
|
||||
const state = reactive({
|
||||
scope: {},
|
||||
legacy: {},
|
||||
loaded: false
|
||||
})
|
||||
|
||||
let inflight = null
|
||||
|
||||
const KEY_RE = /^identifier_(.+?)(?:_(machine|computer|printer|network_device|measuring_tool))?_enabled$/
|
||||
|
||||
function applySetting(key, value) {
|
||||
const match = KEY_RE.exec(key)
|
||||
if (!match) return
|
||||
const name = match[1]
|
||||
const assettype = match[2]
|
||||
if (assettype) {
|
||||
if (!state.scope[name]) state.scope[name] = {}
|
||||
state.scope[name][assettype] = value !== false
|
||||
} else {
|
||||
state.legacy[name] = value !== false
|
||||
}
|
||||
}
|
||||
|
||||
function fetchFlags() {
|
||||
inflight = settingsApi.list()
|
||||
.then(({ data }) => {
|
||||
;(data.data || []).forEach(s => applySetting(s.key, s.value))
|
||||
state.loaded = true
|
||||
})
|
||||
.catch(() => { state.loaded = true })
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
|
||||
function loadFlags() {
|
||||
if (!state.loaded && !inflight) fetchFlags()
|
||||
}
|
||||
|
||||
// Re-read flags from the server. Call after an identifier setting changes so
|
||||
// other open views pick it up without a full page reload.
|
||||
export function reloadIdentifierFlags() {
|
||||
return fetchFlags()
|
||||
}
|
||||
|
||||
// Optimistically update one flag in the shared state (e.g. right after a
|
||||
// Settings toggle) so dependent views react immediately.
|
||||
export function setIdentifierFlag(name, assettype, enabled) {
|
||||
applySetting(`identifier_${name}_${assettype}_enabled`, enabled)
|
||||
}
|
||||
|
||||
// True when identifier `name` should show on `assettype`. Per-type flag wins,
|
||||
// then the legacy global flag, then default-on.
|
||||
function isEnabled(name, assettype) {
|
||||
const perType = state.scope[name]
|
||||
if (perType && assettype in perType) return perType[assettype]
|
||||
if (name in state.legacy) return state.legacy[name]
|
||||
return true
|
||||
}
|
||||
|
||||
export function useIdentifierFlags() {
|
||||
loadFlags()
|
||||
return { state, isEnabled }
|
||||
}
|
||||
102
frontend/src/composables/listQuery.js
Normal file
102
frontend/src/composables/listQuery.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
// Keep a list page's current page and search term in the URL query so the
|
||||
// browser Back button restores them. Without this a list keeps page in local
|
||||
// state and remounts at page 1 after visiting a detail page and going Back.
|
||||
//
|
||||
// Usage in a list view:
|
||||
// const { page, search, setPage, setSearch } = useListQuery({ onChange: loadRows })
|
||||
// goToPage(p) -> setPage(p); loadRows()
|
||||
// debouncedSearch -> setSearch(search.value); loadRows()
|
||||
// filter reset -> setPage(1); loadRows()
|
||||
//
|
||||
// Options:
|
||||
// onChange callback run when the query changes from outside (Back/Forward,
|
||||
// deep link) so the list reloads at the restored page.
|
||||
// extraKeys optional extra query keys to persist (e.g. ['typeid']); each
|
||||
// gets a ref exposed under the returned `extras` object.
|
||||
export function useListQuery(options = {}) {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const onChange = options.onChange || (() => {})
|
||||
const extraKeys = options.extraKeys || []
|
||||
|
||||
const page = ref(parseInt(route.query.page, 10) || 1)
|
||||
const search = ref(route.query.q || '')
|
||||
|
||||
const extras = {}
|
||||
for (const key of extraKeys) {
|
||||
extras[key] = ref(route.query[key] || '')
|
||||
}
|
||||
|
||||
// Build the next query. Keep unrelated keys intact. Write page only when > 1
|
||||
// and search only when non-empty so the clean state is a bare path.
|
||||
function buildQuery() {
|
||||
const query = { ...route.query }
|
||||
if (page.value > 1) query.page = String(page.value)
|
||||
else delete query.page
|
||||
if (search.value) query.q = search.value
|
||||
else delete query.q
|
||||
for (const key of extraKeys) {
|
||||
if (extras[key].value) query[key] = String(extras[key].value)
|
||||
else delete query[key]
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// replace (not push) so paging does not spam history; Back leaves the list.
|
||||
function syncUrl() {
|
||||
const query = buildQuery()
|
||||
const current = route.query
|
||||
const keys = new Set([...Object.keys(query), ...Object.keys(current)])
|
||||
let same = true
|
||||
for (const key of keys) {
|
||||
if (String(query[key] ?? '') !== String(current[key] ?? '')) {
|
||||
same = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!same) router.replace({ query })
|
||||
}
|
||||
|
||||
function setPage(newPage) {
|
||||
page.value = newPage
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Changing the search resets to page 1.
|
||||
function setSearch(term) {
|
||||
search.value = term
|
||||
page.value = 1
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Changing an extra filter resets to page 1.
|
||||
function setExtra(key, value) {
|
||||
extras[key].value = value
|
||||
page.value = 1
|
||||
syncUrl()
|
||||
}
|
||||
|
||||
// Re-sync refs when the query changes from outside (Back/Forward, deep link).
|
||||
// Fire onChange only when page/search/extras actually changed so a self
|
||||
// syncUrl() call does not trigger a redundant reload.
|
||||
watch(() => route.query, (newQuery) => {
|
||||
const newPage = parseInt(newQuery.page, 10) || 1
|
||||
const newSearch = newQuery.q || ''
|
||||
let changed = false
|
||||
if (newPage !== page.value) { page.value = newPage; changed = true }
|
||||
if (newSearch !== search.value) { search.value = newSearch; changed = true }
|
||||
for (const key of extraKeys) {
|
||||
const newValue = newQuery[key] || ''
|
||||
if (newValue !== String(extras[key].value || '')) {
|
||||
extras[key].value = newValue
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) onChange()
|
||||
})
|
||||
|
||||
return { page, search, extras, setPage, setSearch, setExtra }
|
||||
}
|
||||
69
frontend/src/composables/mapConfig.js
Normal file
69
frontend/src/composables/mapConfig.js
Normal file
@@ -0,0 +1,69 @@
|
||||
// Facility floor-map blueprint config, read from the settings table so each
|
||||
// site instance (ADR-004) renders its own floor plan instead of a hardcoded
|
||||
// one. Keys: map_blueprint_light, map_blueprint_dark, map_width, map_height.
|
||||
// Missing keys fall back to the generic placeholder so a fresh or offline
|
||||
// install still renders; each site uploads its own blueprint in Settings.
|
||||
import { reactive } from 'vue'
|
||||
import { settingsApi } from '../api'
|
||||
import { withBase } from '../utils/basePath'
|
||||
|
||||
// Fallback defaults - match the seeded map_blueprint_* setting defaults.
|
||||
const DEFAULTS = {
|
||||
blueprintLight: '/static/images/floorplan-placeholder.svg',
|
||||
blueprintDark: '/static/images/floorplan-placeholder.svg',
|
||||
width: 3300,
|
||||
height: 2550
|
||||
}
|
||||
|
||||
// Shared reactive config. Import as `state` to read width/height/blueprint.
|
||||
export const state = reactive({ ...DEFAULTS, loaded: false })
|
||||
|
||||
let inflight = null
|
||||
|
||||
function applySetting(key, value) {
|
||||
if (value === null || value === undefined || value === '') return
|
||||
if (key === 'map_blueprint_light') state.blueprintLight = value
|
||||
else if (key === 'map_blueprint_dark') state.blueprintDark = value
|
||||
else if (key === 'map_width') {
|
||||
const n = parseInt(value, 10)
|
||||
if (!isNaN(n) && n > 0) state.width = n
|
||||
} else if (key === 'map_height') {
|
||||
const n = parseInt(value, 10)
|
||||
if (!isNaN(n) && n > 0) state.height = n
|
||||
}
|
||||
}
|
||||
|
||||
function fetchConfig() {
|
||||
inflight = settingsApi.list({ category: 'map' })
|
||||
.then(({ data }) => {
|
||||
;(data.data || []).forEach(s => applySetting(s.key, s.value))
|
||||
state.loaded = true
|
||||
})
|
||||
.catch(() => { state.loaded = true })
|
||||
.finally(() => { inflight = null })
|
||||
return inflight
|
||||
}
|
||||
|
||||
// Fetch the map config once (shared across all map components). Returns a
|
||||
// promise that resolves when state is populated, so a caller can await it
|
||||
// before initializing a Leaflet map that needs the dimensions.
|
||||
export function loadMapConfig() {
|
||||
if (state.loaded) return Promise.resolve()
|
||||
if (inflight) return inflight
|
||||
return fetchConfig()
|
||||
}
|
||||
|
||||
// Re-read config from the server after a map setting changes.
|
||||
export function reloadMapConfig() {
|
||||
return fetchConfig()
|
||||
}
|
||||
|
||||
// Blueprint image URL for the given theme ('light' | 'dark').
|
||||
export function blueprintUrlFor(theme) {
|
||||
return withBase(theme === 'light' ? state.blueprintLight : state.blueprintDark)
|
||||
}
|
||||
|
||||
export function useMapConfig() {
|
||||
loadMapConfig()
|
||||
return { state, blueprintUrlFor }
|
||||
}
|
||||
86
frontend/src/composables/settingsCatalog.js
Normal file
86
frontend/src/composables/settingsCatalog.js
Normal file
@@ -0,0 +1,86 @@
|
||||
// Settings catalog: the static core groups (settingsNav.js) merged with the
|
||||
// plugin-contributed cards from the ADR-010 get_settings_cards hook, served by
|
||||
// GET /api/pluginui/settings-cards. Both SettingsLayout (rail) and SettingsIndex
|
||||
// (landing) read the merged catalog so plugins add settings cards with no core
|
||||
// edit. Fetched once into a module-level ref and shared across callers.
|
||||
import { ref } from 'vue'
|
||||
import { Ruler, Wrench, Cog, Package, Settings, Puzzle, SlidersHorizontal, Palette, Bell, Network, Printer, Droplets } from 'lucide-vue-next'
|
||||
import api from '../api'
|
||||
import { settingsGroups } from '../views/settings/settingsNav'
|
||||
|
||||
// Icon string keys (as returned by the hook) mapped to Lucide components,
|
||||
// same idea as the sidebar iconMap. Unknown keys fall back to a puzzle piece.
|
||||
const iconMap = {
|
||||
ruler: Ruler,
|
||||
wrench: Wrench,
|
||||
cog: Cog,
|
||||
package: Package,
|
||||
settings: Settings,
|
||||
puzzle: Puzzle,
|
||||
sliders: SlidersHorizontal,
|
||||
palette: Palette,
|
||||
bell: Bell,
|
||||
network: Network,
|
||||
printer: Printer,
|
||||
droplets: Droplets,
|
||||
}
|
||||
|
||||
function resolveIcon(key) {
|
||||
return iconMap[key] || Puzzle
|
||||
}
|
||||
|
||||
// Merge plugin cards into a fresh copy of the core groups. A card joins the
|
||||
// group whose title matches its `group`; a new group is appended at the end.
|
||||
function mergeCards(baseGroups, cards) {
|
||||
const merged = baseGroups.map(group => ({
|
||||
title: group.title,
|
||||
cards: [...group.cards],
|
||||
}))
|
||||
const byTitle = new Map(merged.map(group => [group.title, group]))
|
||||
|
||||
for (const card of cards) {
|
||||
const entry = {
|
||||
to: card.to,
|
||||
icon: resolveIcon(card.icon),
|
||||
title: card.title,
|
||||
description: card.description || '',
|
||||
position: card.position ?? 99,
|
||||
}
|
||||
let group = byTitle.get(card.group)
|
||||
if (!group) {
|
||||
group = { title: card.group, cards: [] }
|
||||
byTitle.set(card.group, group)
|
||||
merged.push(group)
|
||||
}
|
||||
group.cards.push(entry)
|
||||
}
|
||||
|
||||
// Order plugin cards within a group by position; core cards keep their order.
|
||||
for (const group of merged) {
|
||||
group.cards.sort((a, b) => (a.position ?? 0) - (b.position ?? 0))
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// Shared across component instances: seeded with the core groups, replaced with
|
||||
// the merged catalog once the hook endpoint answers.
|
||||
const groups = ref(settingsGroups)
|
||||
let loaded = false
|
||||
|
||||
async function loadCatalog() {
|
||||
try {
|
||||
const response = await api.get('/pluginui/settings-cards')
|
||||
groups.value = mergeCards(settingsGroups, response.data.data || [])
|
||||
} catch (error) {
|
||||
// Degrade to the core-only catalog; the rail still works without plugins.
|
||||
groups.value = settingsGroups
|
||||
}
|
||||
}
|
||||
|
||||
export function useSettingsCatalog() {
|
||||
if (!loaded) {
|
||||
loaded = true
|
||||
loadCatalog()
|
||||
}
|
||||
return { groups }
|
||||
}
|
||||
28
frontend/src/composables/setupState.js
Normal file
28
frontend/src/composables/setupState.js
Normal file
@@ -0,0 +1,28 @@
|
||||
// Tracks whether first-run setup is complete, so the app can steer a fresh
|
||||
// admin into the /setup wizard. Defaults to "complete" so the wizard never
|
||||
// flashes before the real value loads.
|
||||
import { ref } from 'vue'
|
||||
import { settingsApi } from '../api'
|
||||
|
||||
export const setupComplete = ref(true)
|
||||
// Session-only: a fresh admin who clicks "Skip for now" is not nagged again
|
||||
// until the next login/reload.
|
||||
export const setupSkipped = ref(false)
|
||||
const loaded = ref(false)
|
||||
|
||||
export function isSetupLoaded() {
|
||||
return loaded.value
|
||||
}
|
||||
|
||||
export async function refreshSetupState() {
|
||||
try {
|
||||
const response = await settingsApi.get('setup_complete')
|
||||
const value = response.data?.data?.value
|
||||
setupComplete.value = value === true || value === 'true'
|
||||
} catch (err) {
|
||||
// If we cannot read it, assume complete so we do not trap the user.
|
||||
setupComplete.value = true
|
||||
}
|
||||
loaded.value = true
|
||||
return setupComplete.value
|
||||
}
|
||||
359
frontend/src/composables/systemSettings.js
Normal file
359
frontend/src/composables/systemSettings.js
Normal file
@@ -0,0 +1,359 @@
|
||||
// Shared plumbing for the individual system-settings pages.
|
||||
// Each settings page calls useSystemSettings() in its own setup, so state is
|
||||
// per-page (no cross-page leak). The composable owns the load-all/save/toggle/
|
||||
// upload machinery plus the static display catalogs, keeping the pages thin.
|
||||
// Per-page connection-status computeds (zabbix/dell/smtp/saml) stay in the pages.
|
||||
import { ref, reactive } from 'vue'
|
||||
import { settingsApi, computersApi } from '../api'
|
||||
import { setIdentifierFlag } from './identifierSettings'
|
||||
import { apiError } from '../utils/apiError'
|
||||
|
||||
// Branding logo widgets. kind maps to the backend endpoint; key is the setting
|
||||
// the resulting URL is stored under.
|
||||
export const brandingLogos = [
|
||||
{ kind: 'site', key: 'site_logo', label: 'Site logo', accept: 'image/*',
|
||||
placeholder: '/ge-aerospace-logo.svg',
|
||||
hint: 'Shown in the app header and login. Upload an image or type a path/URL.' },
|
||||
{ kind: 'qr', key: 'qr_logo', label: 'QR overlay logo', accept: 'image/*',
|
||||
placeholder: '/ge-monogram.svg',
|
||||
hint: 'Logo overlaid on printed QR codes. Leave blank for no overlay.' },
|
||||
{ kind: 'badge', key: 'badge_logo', label: 'Machine badge logo', accept: 'image/*',
|
||||
placeholder: '/ge-aerospace-logo.svg',
|
||||
hint: 'Logo printed on machine badges. Upload an image or type a path/URL.' },
|
||||
{ kind: 'favicon', key: 'site_favicon', label: 'Favicon', accept: 'image/*,.ico',
|
||||
placeholder: '(blank = shipped /favicon.svg)',
|
||||
hint: 'Browser-tab icon. Leave blank to use the shipped favicon.' },
|
||||
]
|
||||
|
||||
// Asset identifier matrix: identifier x asset type. Keys follow
|
||||
// identifier_<name>_<assettype>_enabled. Missing = enabled (default on).
|
||||
export const identifierRows = [
|
||||
{ name: 'gaugelabreference', label: 'Gauge Lab Reference' },
|
||||
{ name: 'maintenancereference', label: 'Maintenance Reference' },
|
||||
{ name: 'fqdn', label: 'FQDN / Hostname' }
|
||||
]
|
||||
export const assetTypeCols = [
|
||||
{ key: 'machine', label: 'Machine' },
|
||||
{ key: 'computer', label: 'PC' },
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'network_device', label: 'Network' },
|
||||
{ key: 'measuring_tool', label: 'Measuring Tool' }
|
||||
]
|
||||
|
||||
// Global-search domain toggles: keys follow search_<type>_enabled.
|
||||
// Missing = enabled (default on).
|
||||
export const searchDomains = [
|
||||
{ key: 'application', label: 'Applications' },
|
||||
{ key: 'knowledgebase', label: 'Knowledge Base' },
|
||||
{ key: 'employee', label: 'Employees' },
|
||||
{ key: 'machine', label: 'Machines' },
|
||||
{ key: 'computer', label: 'PCs' },
|
||||
{ key: 'printer', label: 'Printers' },
|
||||
{ key: 'network_device', label: 'Network Devices' },
|
||||
{ key: 'measuring_tool', label: 'Measuring Tools' },
|
||||
{ key: 'notification', label: 'Notifications' },
|
||||
{ key: 'subnet', label: 'Subnets' }
|
||||
]
|
||||
|
||||
export function useSystemSettings() {
|
||||
// Every settings key across all pages. A page binds only its own subset.
|
||||
const settings = reactive({
|
||||
// Zabbix
|
||||
zabbix_enabled: false,
|
||||
zabbix_url: '',
|
||||
zabbix_token: '',
|
||||
// Dell warranty
|
||||
warranty_dell_enabled: false,
|
||||
warranty_dell_clientid: '',
|
||||
warranty_dell_clientsecret: '',
|
||||
warranty_dell_tokenurl: '',
|
||||
warranty_dell_apiurl: '',
|
||||
// ServiceNow
|
||||
servicenow_enabled: true,
|
||||
servicenow_search_url: '',
|
||||
servicenow_ticket_prefixes: '',
|
||||
servicenow_incident_url: '',
|
||||
servicenow_change_url: '',
|
||||
// Branding
|
||||
site_logo: '',
|
||||
qr_logo: '',
|
||||
badge_logo: '',
|
||||
site_favicon: '',
|
||||
brand_primary_color: '',
|
||||
brand_primary_dark_color: '',
|
||||
brand_accent_color: '',
|
||||
brand_sidebar_color: '',
|
||||
// Printing and labels
|
||||
qr_target_printer: '',
|
||||
qr_target_usb: '',
|
||||
usb_label_style: 'barcode',
|
||||
qr_target_machine: '',
|
||||
qr_target_computer: '',
|
||||
qr_target_network_device: '',
|
||||
qr_target_measuring_tool: '',
|
||||
label_default_style: 'card',
|
||||
label_default_codetype: 'qr',
|
||||
label_default_encodes_machine: 'assetnumber',
|
||||
label_default_encodes_computer: 'assetpage',
|
||||
label_default_encodes_printer: 'assetpage',
|
||||
label_default_encodes_network_device: 'assetpage',
|
||||
label_default_encodes_measuring_tool: 'location',
|
||||
// Email
|
||||
smtp_enabled: false,
|
||||
smtp_host: '',
|
||||
smtp_port: 587,
|
||||
smtp_username: '',
|
||||
smtp_password: '',
|
||||
smtp_use_tls: true,
|
||||
smtp_from_address: '',
|
||||
smtp_from_name: 'ShopDB',
|
||||
alert_recipients: '',
|
||||
// Audit
|
||||
audit_retention_days: 90,
|
||||
// Floor map blueprint (per-facility)
|
||||
map_blueprint_light: '',
|
||||
map_blueprint_dark: '',
|
||||
map_width: 3300,
|
||||
map_height: 2550,
|
||||
// SAML
|
||||
saml_enabled: false,
|
||||
saml_idp_metadata_url: '',
|
||||
saml_entity_id: '',
|
||||
saml_acs_url: '',
|
||||
saml_allow_local_login: true,
|
||||
saml_auto_create_users: true,
|
||||
saml_admin_group: ''
|
||||
})
|
||||
|
||||
const identifierMatrix = reactive({})
|
||||
const searchMatrix = reactive({})
|
||||
const pcTypeMappings = ref([]) // [{ pxetype, computertype }]
|
||||
const computerTypes = ref([]) // ComputerType names for the dropdown
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const mapUploading = ref(false)
|
||||
const brandingUploading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
function flashSaved() {
|
||||
success.value = 'Setting saved'
|
||||
setTimeout(() => { success.value = '' }, 2000)
|
||||
}
|
||||
|
||||
function identifierKey(name, assettype) {
|
||||
return `identifier_${name}_${assettype}_enabled`
|
||||
}
|
||||
|
||||
function matrixValue(name, assettype) {
|
||||
const key = identifierKey(name, assettype)
|
||||
return key in identifierMatrix ? identifierMatrix[key] : true
|
||||
}
|
||||
|
||||
function searchValue(domainKey) {
|
||||
const key = `search_${domainKey}_enabled`
|
||||
return key in searchMatrix ? searchMatrix[key] : true
|
||||
}
|
||||
|
||||
// Load all settings and fan them into settings / matrices / pctype rows.
|
||||
async function loadSettings() {
|
||||
try {
|
||||
loading.value = true
|
||||
const { data } = await settingsApi.list()
|
||||
|
||||
const pctypeRows = []
|
||||
for (const setting of data.data) {
|
||||
if (setting.key in settings) {
|
||||
settings[setting.key] = setting.value
|
||||
} else if (/^identifier_.+_(machine|computer|printer|network_device|measuring_tool)_enabled$/.test(setting.key)) {
|
||||
identifierMatrix[setting.key] = setting.value !== false
|
||||
} else if (/^search_.+_enabled$/.test(setting.key)) {
|
||||
searchMatrix[setting.key] = setting.value !== false
|
||||
} else if (setting.key.startsWith('pctypemap_')) {
|
||||
pctypeRows.push({
|
||||
pxetype: setting.key.slice('pctypemap_'.length),
|
||||
computertype: setting.value
|
||||
})
|
||||
}
|
||||
}
|
||||
pcTypeMappings.value = pctypeRows.sort((a, b) => a.pxetype.localeCompare(b.pxetype))
|
||||
} catch (e) {
|
||||
error.value = 'Failed to load settings'
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Computer type options for the pctype mapping dropdown (own request).
|
||||
async function loadComputerTypes() {
|
||||
try {
|
||||
const typesResponse = await computersApi.types.list({ perpage: 100 })
|
||||
computerTypes.value = (typesResponse.data.data || []).map(t => t.computertype)
|
||||
} catch (typesError) {
|
||||
console.error('Failed to load computer types', typesError)
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSetting(key, value) {
|
||||
try {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
await settingsApi.update(key, value)
|
||||
settings[key] = value
|
||||
flashSaved()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to save setting')
|
||||
console.error(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSetting(key) {
|
||||
await saveSetting(key, !settings[key])
|
||||
}
|
||||
|
||||
// Toggle a per-type identifier flag. The key may not be seeded on older
|
||||
// installs, so create it when the update returns 404.
|
||||
async function toggleIdentifier(name, assettype) {
|
||||
const key = identifierKey(name, assettype)
|
||||
const newValue = !matrixValue(name, assettype)
|
||||
try {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
await settingsApi.update(key, newValue)
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) {
|
||||
await settingsApi.create({
|
||||
key,
|
||||
value: newValue,
|
||||
valuetype: 'boolean',
|
||||
category: 'identifiers',
|
||||
description: `Show the ${name} identifier on ${assettype} assets`
|
||||
})
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
identifierMatrix[key] = newValue
|
||||
// Push into the shared composable so open asset views react without a
|
||||
// full page reload (the composable otherwise fetches only once).
|
||||
setIdentifierFlag(name, assettype, newValue)
|
||||
flashSaved()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to save setting')
|
||||
console.error(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleSearchDomain(domainKey) {
|
||||
const key = `search_${domainKey}_enabled`
|
||||
const newValue = !searchValue(domainKey)
|
||||
const label = searchDomains.find(d => d.key === domainKey)?.label || domainKey
|
||||
try {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
await settingsApi.update(key, newValue)
|
||||
} catch (e) {
|
||||
if (e.response?.status === 404) {
|
||||
await settingsApi.create({
|
||||
key,
|
||||
value: newValue,
|
||||
valuetype: 'boolean',
|
||||
category: 'search',
|
||||
description: `Include ${label} in global search results`
|
||||
})
|
||||
} else {
|
||||
throw e
|
||||
}
|
||||
}
|
||||
searchMatrix[key] = newValue
|
||||
flashSaved()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to save setting')
|
||||
console.error(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function changePcTypeMapping(pxetype, computertype) {
|
||||
const key = `pctypemap_${pxetype}`
|
||||
try {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
await settingsApi.update(key, computertype)
|
||||
const row = pcTypeMappings.value.find(r => r.pxetype === pxetype)
|
||||
if (row) row.computertype = computertype
|
||||
flashSaved()
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Failed to save setting')
|
||||
console.error(e)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBlueprint(theme, event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
mapUploading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
||||
const url = data?.data?.value
|
||||
if (theme === 'light') settings.map_blueprint_light = url
|
||||
else settings.map_blueprint_dark = url
|
||||
success.value = 'Blueprint uploaded'
|
||||
setTimeout(() => { success.value = '' }, 2000)
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Upload failed')
|
||||
} finally {
|
||||
mapUploading.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadLogo(kind, key, event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
brandingUploading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
try {
|
||||
const { data } = await settingsApi.uploadBrandingLogo(kind, file)
|
||||
const url = data?.data?.value
|
||||
if (url) settings[key] = url
|
||||
success.value = 'Logo uploaded'
|
||||
setTimeout(() => { success.value = '' }, 2000)
|
||||
} catch (e) {
|
||||
error.value = apiError(e, 'Upload failed')
|
||||
} finally {
|
||||
brandingUploading.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
settings,
|
||||
identifierMatrix, searchMatrix, pcTypeMappings, computerTypes,
|
||||
loading, saving, mapUploading, brandingUploading, error, success,
|
||||
loadSettings, loadComputerTypes,
|
||||
saveSetting, toggleSetting,
|
||||
identifierKey, matrixValue, toggleIdentifier,
|
||||
searchValue, toggleSearchDomain,
|
||||
changePcTypeMapping,
|
||||
uploadBlueprint, uploadLogo,
|
||||
}
|
||||
}
|
||||
35
frontend/src/composables/toast.js
Normal file
35
frontend/src/composables/toast.js
Normal file
@@ -0,0 +1,35 @@
|
||||
// Global toast notifications. Import useToast() anywhere and call
|
||||
// toast.success('...') / toast.error('...') / toast.info('...'). A single
|
||||
// <ToastHost /> mounted in AppLayout renders the stack.
|
||||
import { reactive } from 'vue'
|
||||
|
||||
const state = reactive({
|
||||
items: [],
|
||||
})
|
||||
|
||||
let nextId = 1
|
||||
|
||||
function dismiss(id) {
|
||||
const index = state.items.findIndex(t => t.id === id)
|
||||
if (index !== -1) state.items.splice(index, 1)
|
||||
}
|
||||
|
||||
function push(message, type = 'info', duration = 5000) {
|
||||
if (!message) return
|
||||
const id = nextId++
|
||||
state.items.push({ id, message, type })
|
||||
if (duration > 0) {
|
||||
setTimeout(() => dismiss(id), duration)
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return {
|
||||
items: state.items,
|
||||
dismiss,
|
||||
success: (message, duration) => push(message, 'success', duration),
|
||||
error: (message, duration) => push(message, 'error', duration ?? 8000),
|
||||
info: (message, duration) => push(message, 'info', duration),
|
||||
}
|
||||
}
|
||||
44
frontend/src/composables/warrantyBadge.js
Normal file
44
frontend/src/composables/warrantyBadge.js
Normal file
@@ -0,0 +1,44 @@
|
||||
// Shared warranty fetch + hero-badge derivation for asset detail pages.
|
||||
// Pass a getter for the asset id; get back the warranties list (to feed a
|
||||
// WarrantyPanel) and a heroWarranty for a compact status badge.
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { warrantyApi } from '../api'
|
||||
|
||||
const STATUS_RANK = { expired: 0, expiring: 1, active: 2, unknown: 3 }
|
||||
const STATUS_LABELS = {
|
||||
active: 'Under Warranty',
|
||||
expiring: 'Warranty Expiring',
|
||||
expired: 'Warranty Expired',
|
||||
unknown: 'Warranty',
|
||||
}
|
||||
|
||||
export function useWarrantyBadge(getAssetId) {
|
||||
const warranties = ref([])
|
||||
|
||||
// Worst-case warranty drives the badge: expired > expiring > active > unknown.
|
||||
const heroWarranty = computed(() => {
|
||||
if (!warranties.value.length) return null
|
||||
const worst = [...warranties.value]
|
||||
.sort((a, b) => (STATUS_RANK[a.status] ?? 9) - (STATUS_RANK[b.status] ?? 9))[0]
|
||||
return { ...worst, label: STATUS_LABELS[worst.status] || 'Warranty' }
|
||||
})
|
||||
|
||||
function warrantyDate(d) {
|
||||
return d ? new Date(d + 'T00:00:00').toLocaleDateString() : ''
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const assetid = getAssetId()
|
||||
if (!assetid) { warranties.value = []; return }
|
||||
try {
|
||||
const response = await warrantyApi.forAsset(assetid)
|
||||
warranties.value = response.data.data || []
|
||||
} catch (err) {
|
||||
warranties.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(getAssetId, load, { immediate: true })
|
||||
|
||||
return { warranties, heroWarranty, warrantyDate, reloadWarranties: load }
|
||||
}
|
||||
21
frontend/src/main.js
Normal file
21
frontend/src/main.js
Normal file
@@ -0,0 +1,21 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import router from './router'
|
||||
import App from './App.vue'
|
||||
|
||||
// Locally-bundled Inter (air-gap safe, no Google Fonts fetch)
|
||||
import '@fontsource-variable/inter'
|
||||
|
||||
// Initialize theme on app load
|
||||
import './stores/theme'
|
||||
import { applyBranding } from './utils/siteSettings'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
// Apply per-site favicon + brand color once settings load (no-op on defaults).
|
||||
applyBranding()
|
||||
170
frontend/src/router/index.js
Normal file
170
frontend/src/router/index.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import AppLayout from '../views/AppLayout.vue'
|
||||
import SettingsLayout from '../views/settings/SettingsLayout.vue'
|
||||
import { setupComplete, setupSkipped, isSetupLoaded, refreshSetupState } from '../composables/setupState'
|
||||
import { loadEnabledPlugins, isPluginEnabled } from '../composables/enabledPlugins'
|
||||
import { useToast } from '../composables/toast'
|
||||
|
||||
// Auto-discover all route modules from routes/ directory
|
||||
const routeModules = import.meta.glob('./routes/*.js', { eager: true })
|
||||
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 = [
|
||||
{
|
||||
path: '/login',
|
||||
name: 'login',
|
||||
component: () => import('../views/Login.vue'),
|
||||
meta: { guest: true }
|
||||
},
|
||||
// Forced/self-service password change (standalone, authenticated)
|
||||
{
|
||||
path: '/change-password',
|
||||
name: 'change-password',
|
||||
component: () => import('../views/ChangePassword.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
// First-run setup wizard (standalone, admin-only)
|
||||
{
|
||||
path: '/setup',
|
||||
name: 'setup',
|
||||
component: () => import('../views/SetupWizard.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
// Standalone full-screen dashboards (no sidebar, no auth required)
|
||||
{
|
||||
path: '/shopfloor',
|
||||
name: 'shopfloor',
|
||||
component: () => import('../views/ShopfloorDashboard.vue')
|
||||
},
|
||||
{
|
||||
path: '/tv',
|
||||
name: 'tv',
|
||||
component: () => import('../views/TVDashboard.vue'),
|
||||
meta: { plugin: 'slides' }
|
||||
},
|
||||
// Print pages (standalone, no sidebar/header)
|
||||
{
|
||||
path: '/print/machine-badge/:id',
|
||||
name: 'print-machine-badge',
|
||||
component: () => import('../views/print/MachineBadge.vue')
|
||||
},
|
||||
// Shared asset label/code generator for any asset type (public, like the
|
||||
// other /print/* routes). assettype = machine|computer|printer|
|
||||
// network_device|measuring_tool; id = the asset's plugin id.
|
||||
{
|
||||
path: '/print/asset-label/:assettype/:id',
|
||||
name: 'print-asset-label',
|
||||
component: () => import('../views/print/AssetLabel.vue')
|
||||
},
|
||||
// Batch label sheets (ULINE 6-up / mini 72-up) for one asset type.
|
||||
{
|
||||
path: '/print/asset-label-batch/:assettype',
|
||||
name: 'print-asset-label-batch',
|
||||
component: () => import('../views/print/AssetLabelBatch.vue')
|
||||
},
|
||||
{
|
||||
path: '/print/printer-qr',
|
||||
name: 'print-printer-qr-batch',
|
||||
component: () => import('../views/print/PrinterQRBatch.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: '/print/printer-qr/:id',
|
||||
name: 'print-printer-qr-single',
|
||||
component: () => import('../views/print/PrinterQRSingle.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: '/print/usb-labels',
|
||||
name: 'print-usb-labels',
|
||||
component: () => import('../views/print/USBLabelBatch.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: AppLayout,
|
||||
children: appChildren
|
||||
}
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes
|
||||
})
|
||||
|
||||
// Navigation guard
|
||||
router.beforeEach(async (to, from, next) => {
|
||||
const authStore = useAuthStore()
|
||||
|
||||
if (to.meta.requiresAuth && !authStore.isAuthenticated) {
|
||||
// Remember where they were headed so login can send them back there.
|
||||
return next({ path: '/login', query: { redirect: to.fullPath } })
|
||||
}
|
||||
if (to.meta.requiresAdmin && !authStore.isAdmin) {
|
||||
return next('/')
|
||||
}
|
||||
if (to.meta.guest && authStore.isAuthenticated) {
|
||||
return next('/')
|
||||
}
|
||||
|
||||
// Forced password change: an admin-set temporary password must be replaced
|
||||
// before the user reaches the rest of the app. Let them log out.
|
||||
if (authStore.isAuthenticated && authStore.mustChangePassword
|
||||
&& to.path !== '/change-password' && to.path !== '/login') {
|
||||
return next('/change-password')
|
||||
}
|
||||
|
||||
// Plugin gating: a disabled backend plugin's frontend routes are dead ends.
|
||||
// The enabled list is fetched once and cached; fail-open on error so a blip
|
||||
// cannot brick navigation. Works unauthenticated (endpoint is jwt-optional).
|
||||
if (to.meta.plugin) {
|
||||
await loadEnabledPlugins()
|
||||
if (!isPluginEnabled(to.meta.plugin)) {
|
||||
useToast().info(`The ${to.meta.plugin} feature is not enabled.`)
|
||||
return next('/')
|
||||
}
|
||||
}
|
||||
|
||||
// First-run: steer a fresh admin into the setup wizard (once, until finished).
|
||||
if (authStore.isAuthenticated && authStore.isAdmin && to.path !== '/setup') {
|
||||
if (!isSetupLoaded()) {
|
||||
await refreshSetupState()
|
||||
}
|
||||
if (!setupComplete.value && !setupSkipped.value) {
|
||||
return next('/setup')
|
||||
}
|
||||
}
|
||||
|
||||
next()
|
||||
})
|
||||
|
||||
export default router
|
||||
27
frontend/src/router/routes/applications.js
Normal file
27
frontend/src/router/routes/applications.js
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Applications routes (core feature)
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'applications',
|
||||
name: 'applications',
|
||||
component: () => import('../../views/applications/ApplicationsList.vue')
|
||||
},
|
||||
{
|
||||
path: 'applications/new',
|
||||
name: 'application-new',
|
||||
component: () => import('../../views/applications/ApplicationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'applications/:id',
|
||||
name: 'application-detail',
|
||||
component: () => import('../../views/applications/ApplicationDetail.vue')
|
||||
},
|
||||
{
|
||||
path: 'applications/:id/edit',
|
||||
name: 'application-edit',
|
||||
component: () => import('../../views/applications/ApplicationForm.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
}
|
||||
]
|
||||
48
frontend/src/router/routes/computers.js
Normal file
48
frontend/src/router/routes/computers.js
Normal file
@@ -0,0 +1,48 @@
|
||||
/**
|
||||
* Computers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'pcs',
|
||||
name: 'pcs',
|
||||
component: () => import('../../views/pcs/PCsList.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/new',
|
||||
name: 'pc-new',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id',
|
||||
name: 'pc-detail',
|
||||
component: () => import('../../views/pcs/PCDetail.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'pcs/:id/edit',
|
||||
name: 'pc-edit',
|
||||
component: () => import('../../views/pcs/PCForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'computers' }
|
||||
},
|
||||
// Computer-specific settings
|
||||
{
|
||||
path: 'settings/pctypes',
|
||||
name: 'pctypes',
|
||||
component: () => import('../../views/settings/PCTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/operatingsystems',
|
||||
name: 'operatingsystems',
|
||||
component: () => import('../../views/settings/OperatingSystemsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/accessprotocols',
|
||||
name: 'access-protocols',
|
||||
component: () => import('../../views/settings/AccessProtocolsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'computers' }
|
||||
}
|
||||
]
|
||||
285
frontend/src/router/routes/core.js
Normal file
285
frontend/src/router/routes/core.js
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Core routes - Dashboard, Search, Map, Reports, Settings, Employees
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: '',
|
||||
name: 'dashboard',
|
||||
component: () => import('../../views/Dashboard.vue')
|
||||
},
|
||||
{
|
||||
path: 'search',
|
||||
name: 'search',
|
||||
component: () => import('../../views/SearchResults.vue')
|
||||
},
|
||||
{
|
||||
path: 'map',
|
||||
name: 'map',
|
||||
component: () => import('../../views/MapView.vue')
|
||||
},
|
||||
{
|
||||
path: 'map/editor',
|
||||
name: 'map-editor',
|
||||
component: () => import('../../views/MapEditor.vue'),
|
||||
meta: { requiresAuth: true }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'reports',
|
||||
component: () => import('../../views/reports/ReportsIndex.vue')
|
||||
},
|
||||
{
|
||||
path: 'reports/pc-relationships',
|
||||
name: 'report-pc-relationships',
|
||||
component: () => import('../../views/reports/PCRelationshipsReport.vue'),
|
||||
meta: { plugin: 'computers' }
|
||||
},
|
||||
{
|
||||
path: 'reports/toner',
|
||||
name: 'toner-report',
|
||||
component: () => import('../../views/reports/TonerReport.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'employees/:sso',
|
||||
name: 'employee-detail',
|
||||
component: () => import('../../views/employees/EmployeeDetail.vue'),
|
||||
meta: { plugin: 'employees' }
|
||||
},
|
||||
// Settings
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('../../views/settings/SettingsIndex.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/vendors',
|
||||
name: 'vendors',
|
||||
component: () => import('../../views/vendors/VendorsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/modeltypes',
|
||||
name: 'modeltypes',
|
||||
component: () => import('../../views/settings/ModelTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/locations',
|
||||
name: 'locations',
|
||||
component: () => import('../../views/settings/LocationsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/statuses',
|
||||
name: 'statuses',
|
||||
component: () => import('../../views/settings/StatusesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/models',
|
||||
name: 'models',
|
||||
component: () => import('../../views/settings/ModelsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/businessunits',
|
||||
name: 'businessunits',
|
||||
component: () => import('../../views/settings/BusinessUnitsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/supportteams',
|
||||
name: 'supportteams',
|
||||
component: () => import('../../views/settings/SupportTeamsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dashboarddefaults',
|
||||
name: 'dashboarddefaults',
|
||||
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, plugin: 'slides' }
|
||||
},
|
||||
{
|
||||
path: 'settings/machinetypes',
|
||||
name: 'machinetypes',
|
||||
component: () => import('../../views/settings/MachineTypesList.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/employeedirectory',
|
||||
name: 'employee-directory',
|
||||
component: () => import('../../views/settings/EmployeeDirectory.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
// The old monolithic System Settings tab page is split into one routed page
|
||||
// per section (below). This redirect keeps old bookmarks working: bare
|
||||
// /settings/system and every /settings/system?tab=<key> land on the right page.
|
||||
{
|
||||
path: 'settings/system',
|
||||
name: 'system-settings',
|
||||
redirect: to => {
|
||||
const tabRedirects = {
|
||||
integrations: '/settings/servicenow',
|
||||
branding: '/settings/branding',
|
||||
printing: '/settings/printing',
|
||||
email: '/settings/email',
|
||||
audit: '/settings/audit',
|
||||
auth: '/settings/authentication',
|
||||
identifiers: '/settings/assetidentifiers',
|
||||
search: '/settings/globalsearch',
|
||||
map: '/settings/floormap',
|
||||
pctype: '/settings/pctypemapping'
|
||||
}
|
||||
// vue-router carries the original ?tab= query onto the target; harmless
|
||||
// since the new pages ignore it, and the path is what routes the view.
|
||||
return tabRedirects[to.query.tab] || '/settings/servicenow'
|
||||
},
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/servicenow',
|
||||
name: 'servicenow-settings',
|
||||
component: () => import('../../views/settings/ServiceNowSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/zabbix',
|
||||
name: 'zabbix-settings',
|
||||
component: () => import('../../views/settings/ZabbixSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/dellwarranty',
|
||||
name: 'dell-warranty-settings',
|
||||
component: () => import('../../views/settings/DellWarrantySettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/pctypemapping',
|
||||
name: 'pctype-mapping-settings',
|
||||
component: () => import('../../views/settings/PCTypeMappingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/branding',
|
||||
name: 'branding-settings',
|
||||
component: () => import('../../views/settings/BrandingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/printing',
|
||||
name: 'printing-settings',
|
||||
component: () => import('../../views/settings/PrintingSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/email',
|
||||
name: 'email-settings',
|
||||
component: () => import('../../views/settings/EmailSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/audit',
|
||||
name: 'audit-settings',
|
||||
component: () => import('../../views/settings/AuditSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/authentication',
|
||||
name: 'authentication-settings',
|
||||
component: () => import('../../views/settings/AuthenticationSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/apitokens',
|
||||
name: 'apitokens-settings',
|
||||
component: () => import('../../views/settings/ApiTokensList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/assetidentifiers',
|
||||
name: 'asset-identifiers-settings',
|
||||
component: () => import('../../views/settings/AssetIdentifiersSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/globalsearch',
|
||||
name: 'global-search-settings',
|
||||
component: () => import('../../views/settings/GlobalSearchSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/floormap',
|
||||
name: 'floor-map-settings',
|
||||
component: () => import('../../views/settings/FloorMapSettings.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/plugins',
|
||||
name: 'plugins',
|
||||
component: () => import('../../views/settings/PluginsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/auditlogs',
|
||||
name: 'audit-logs',
|
||||
component: () => import('../../views/settings/AuditLogs.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
},
|
||||
{
|
||||
path: 'settings/users',
|
||||
name: 'users',
|
||||
component: () => import('../../views/settings/UsersList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true }
|
||||
}
|
||||
]
|
||||
30
frontend/src/router/routes/geenforce.js
Normal file
30
frontend/src/router/routes/geenforce.js
Normal file
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* GE-Enforce plugin routes.
|
||||
*
|
||||
* A top-level section (not under /settings) - the manifest editor + fleet
|
||||
* reports are a large operational surface, so they get their own full-width
|
||||
* shell with tabs. meta.plugin = 'geenforce' so the ADR-009 guard hides the
|
||||
* section when the plugin is disabled. Admin-only.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'geenforce',
|
||||
component: () => import('../../views/geenforce/GeEnforceLayout.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' },
|
||||
children: [
|
||||
{ path: '', redirect: '/geenforce/manifests' },
|
||||
{
|
||||
path: 'manifests',
|
||||
name: 'geenforce-manifests',
|
||||
component: () => import('../../views/geenforce/ManifestEditor.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||
},
|
||||
{
|
||||
path: 'reports',
|
||||
name: 'geenforce-reports',
|
||||
component: () => import('../../views/geenforce/EnforcementReports.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'geenforce' }
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
29
frontend/src/router/routes/knowledgebase.js
Normal file
29
frontend/src/router/routes/knowledgebase.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Knowledge Base plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'knowledgebase',
|
||||
name: 'knowledgebase',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseList.vue'),
|
||||
meta: { plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/new',
|
||||
name: 'knowledgebase-new',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id',
|
||||
name: 'knowledgebase-detail',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseDetail.vue'),
|
||||
meta: { plugin: 'knowledgebase' }
|
||||
},
|
||||
{
|
||||
path: 'knowledgebase/:id/edit',
|
||||
name: 'knowledgebase-edit',
|
||||
component: () => import('../../views/knowledgebase/KnowledgeBaseForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'knowledgebase' }
|
||||
}
|
||||
]
|
||||
29
frontend/src/router/routes/machines.js
Normal file
29
frontend/src/router/routes/machines.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Machines plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'machines',
|
||||
name: 'machines',
|
||||
component: () => import('../../views/machines/MachinesList.vue'),
|
||||
meta: { plugin: 'machines' }
|
||||
},
|
||||
{
|
||||
path: 'machines/new',
|
||||
name: 'machine-new',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'machines' }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id',
|
||||
name: 'machine-detail',
|
||||
component: () => import('../../views/machines/MachineDetail.vue'),
|
||||
meta: { plugin: 'machines' }
|
||||
},
|
||||
{
|
||||
path: 'machines/:id/edit',
|
||||
name: 'machine-edit',
|
||||
component: () => import('../../views/machines/MachineForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'machines' }
|
||||
}
|
||||
]
|
||||
54
frontend/src/router/routes/measuringtools.js
Normal file
54
frontend/src/router/routes/measuringtools.js
Normal file
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* Measuring-tools plugin routes.
|
||||
*
|
||||
* Every route carries meta.plugin = 'measuringtools' so the ADR-009 router
|
||||
* guard redirects to the dashboard when the backend plugin is disabled. List
|
||||
* and detail are public (no requiresAuth, matching the other asset types); the
|
||||
* form requires auth; the settings subtype page requires admin.
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'measuringtools',
|
||||
name: 'measuringtools',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolsList.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'measuringtools/new',
|
||||
name: 'measuringtool-new',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
// Resolve a tool from its core asset id (search rows / cross-links carry
|
||||
// assetid, not the extension id). Shares the detail component.
|
||||
path: 'measuringtools/by-asset/:assetid',
|
||||
name: 'measuringtool-by-asset',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'measuringtools/:id',
|
||||
name: 'measuringtool-detail',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolDetail.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'measuringtools/:id/edit',
|
||||
name: 'measuringtool-edit',
|
||||
component: () => import('../../views/measuringtools/MeasuringToolForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'reports/calibration',
|
||||
name: 'calibration-report',
|
||||
component: () => import('../../views/reports/CalibrationReport.vue'),
|
||||
meta: { plugin: 'measuringtools' }
|
||||
},
|
||||
{
|
||||
path: 'settings/measuringtooltypes',
|
||||
name: 'measuringtooltypes',
|
||||
component: () => import('../../views/settings/MeasuringToolTypesList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'measuringtools' }
|
||||
}
|
||||
]
|
||||
53
frontend/src/router/routes/network.js
Normal file
53
frontend/src/router/routes/network.js
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Network plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'network',
|
||||
name: 'network',
|
||||
component: () => import('../../views/network/NetworkHub.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/new',
|
||||
name: 'network-new',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/:id',
|
||||
name: 'network-detail',
|
||||
component: () => import('../../views/network/NetworkDeviceDetail.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'network/:id/edit',
|
||||
name: 'network-edit',
|
||||
component: () => import('../../views/network/NetworkDeviceForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
// Legacy path -> the Networks tab of the hub.
|
||||
path: 'networks',
|
||||
redirect: { path: '/network', query: { tab: 'networks' } }
|
||||
},
|
||||
{
|
||||
path: 'networks/:id',
|
||||
name: 'network-subnet-detail',
|
||||
component: () => import('../../views/network/SubnetDetail.vue'),
|
||||
meta: { plugin: 'network' }
|
||||
},
|
||||
// Network-specific settings
|
||||
{
|
||||
path: 'settings/vlans',
|
||||
name: 'vlans',
|
||||
component: () => import('../../views/settings/VLANsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
},
|
||||
{
|
||||
path: 'settings/subnets',
|
||||
name: 'subnets',
|
||||
component: () => import('../../views/settings/SubnetsList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'network' }
|
||||
}
|
||||
]
|
||||
41
frontend/src/router/routes/notifications.js
Normal file
41
frontend/src/router/routes/notifications.js
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Notifications plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'notifications',
|
||||
name: 'notifications',
|
||||
component: () => import('../../views/notifications/NotificationsList.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/new',
|
||||
name: 'notification-new',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'settings/notificationtypes',
|
||||
name: 'notification-types',
|
||||
component: () => import('../../views/notifications/NotificationTypesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id',
|
||||
name: 'notification-detail',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'notifications/:id/edit',
|
||||
name: 'notification-edit',
|
||||
component: () => import('../../views/notifications/NotificationForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'notifications' }
|
||||
},
|
||||
{
|
||||
path: 'calendar',
|
||||
name: 'calendar',
|
||||
component: () => import('../../views/CalendarView.vue'),
|
||||
meta: { plugin: 'notifications' }
|
||||
}
|
||||
]
|
||||
42
frontend/src/router/routes/printers.js
Normal file
42
frontend/src/router/routes/printers.js
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Printers plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'printers',
|
||||
name: 'printers',
|
||||
component: () => import('../../views/printers/PrintersList.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/new',
|
||||
name: 'printer-new',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id',
|
||||
name: 'printer-detail',
|
||||
component: () => import('../../views/printers/PrinterDetail.vue'),
|
||||
meta: { plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'printers/:id/edit',
|
||||
name: 'printer-edit',
|
||||
component: () => import('../../views/printers/PrinterForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
// printer-specific settings
|
||||
{
|
||||
path: 'settings/modelsupplies',
|
||||
name: 'model-supplies',
|
||||
component: () => import('../../views/settings/ModelSuppliesList.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'printers' }
|
||||
},
|
||||
{
|
||||
path: 'settings/printerdrivers',
|
||||
name: 'printer-drivers',
|
||||
component: () => import('../../views/settings/PrinterDriversList.vue'),
|
||||
meta: { requiresAuth: true, requiresAdmin: true, plugin: 'printers' }
|
||||
}
|
||||
]
|
||||
29
frontend/src/router/routes/usb.js
Normal file
29
frontend/src/router/routes/usb.js
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* USB plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'usb',
|
||||
name: 'usb',
|
||||
component: () => import('../../views/usb/USBList.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/new',
|
||||
name: 'usb-new',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id',
|
||||
name: 'usb-detail',
|
||||
component: () => import('../../views/usb/USBDetail.vue'),
|
||||
meta: { plugin: 'usb' }
|
||||
},
|
||||
{
|
||||
path: 'usb/:id/edit',
|
||||
name: 'usb-edit',
|
||||
component: () => import('../../views/usb/USBForm.vue'),
|
||||
meta: { requiresAuth: true, plugin: 'usb' }
|
||||
}
|
||||
]
|
||||
17
frontend/src/router/routes/warranty.js
Normal file
17
frontend/src/router/routes/warranty.js
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Warranty plugin routes
|
||||
*/
|
||||
export default [
|
||||
{
|
||||
path: 'warranties',
|
||||
name: 'warranties',
|
||||
component: () => import('../../views/warranty/WarrantiesList.vue'),
|
||||
meta: { plugin: 'warranty' }
|
||||
},
|
||||
{
|
||||
path: 'reports/warranty',
|
||||
name: 'warranty-report',
|
||||
component: () => import('../../views/reports/WarrantyReport.vue'),
|
||||
meta: { plugin: 'warranty' }
|
||||
}
|
||||
]
|
||||
99
frontend/src/stores/auth.js
Normal file
99
frontend/src/stores/auth.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { authApi, employeesApi } from '../api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
user: JSON.parse(localStorage.getItem('user') || 'null'),
|
||||
token: localStorage.getItem('token') || null
|
||||
}),
|
||||
|
||||
getters: {
|
||||
isAuthenticated: (state) => !!state.token,
|
||||
username: (state) => state.user?.username || '',
|
||||
roles: (state) => state.user?.roles || [],
|
||||
hasRole: (state) => (role) => state.user?.roles?.includes(role) || false,
|
||||
isAdmin: (state) => state.user?.roles?.includes('admin') || false,
|
||||
// True when an admin-set temporary password must be changed before use.
|
||||
mustChangePassword: (state) => !!state.user?.mustchangepassword,
|
||||
// Full name from the employee directory (falls back to username/SSO).
|
||||
displayName: (state) => state.user?.directoryname || state.user?.username || '',
|
||||
// Employee photo URL if the directory has one for this SSO. The directory
|
||||
// resolver already returns a usable URL (self-hosted upload or external HR
|
||||
// path), so it is used as-is.
|
||||
avatarUrl: (state) => state.user?.directoryphotourl || null
|
||||
},
|
||||
|
||||
actions: {
|
||||
async login(username, password) {
|
||||
try {
|
||||
const response = await authApi.login(username, password)
|
||||
const { access_token, refresh_token, user } = response.data.data
|
||||
|
||||
this.token = access_token
|
||||
this.user = user
|
||||
|
||||
this.token = access_token
|
||||
localStorage.setItem('token', access_token)
|
||||
localStorage.setItem('refreshToken', refresh_token)
|
||||
await this.enrichFromDirectory()
|
||||
|
||||
return { success: true }
|
||||
} catch (error) {
|
||||
const message = error.response?.data?.message || 'Login failed'
|
||||
return { success: false, message }
|
||||
}
|
||||
},
|
||||
|
||||
async logout() {
|
||||
try {
|
||||
await authApi.logout()
|
||||
} catch (e) {
|
||||
// Ignore logout errors
|
||||
}
|
||||
|
||||
this.token = null
|
||||
this.user = null
|
||||
|
||||
localStorage.removeItem('token')
|
||||
localStorage.removeItem('refreshToken')
|
||||
localStorage.removeItem('user')
|
||||
},
|
||||
|
||||
// Clear the forced-password-change flag after a successful change so the
|
||||
// router guard stops steering the user to the change-password view.
|
||||
clearMustChangePassword() {
|
||||
if (this.user) {
|
||||
this.user.mustchangepassword = false
|
||||
localStorage.setItem('user', JSON.stringify(this.user))
|
||||
}
|
||||
},
|
||||
|
||||
async fetchUser() {
|
||||
try {
|
||||
const response = await authApi.me()
|
||||
this.user = response.data.data
|
||||
await this.enrichFromDirectory()
|
||||
} catch (error) {
|
||||
this.logout()
|
||||
}
|
||||
},
|
||||
|
||||
// Pull the logged-in user's full name + photo from the employee directory
|
||||
// by their SSO (= username). Best-effort: skipped/ignored when the employees
|
||||
// plugin is off, the SSO is not in the directory, or the lookup fails.
|
||||
async enrichFromDirectory() {
|
||||
const sso = this.user?.username
|
||||
if (sso && /^\d+$/.test(sso)) {
|
||||
try {
|
||||
const response = await employeesApi.lookup(sso)
|
||||
const emp = response.data?.data
|
||||
if (emp) {
|
||||
this.user.directoryname = `${emp.First_Name || ''} ${emp.Last_Name || ''}`.trim() || null
|
||||
this.user.directoryphotourl = emp.photourl || null
|
||||
}
|
||||
} catch (err) { /* directory unavailable - fall back to username */ }
|
||||
}
|
||||
localStorage.setItem('user', JSON.stringify(this.user))
|
||||
}
|
||||
}
|
||||
})
|
||||
39
frontend/src/stores/theme.js
Normal file
39
frontend/src/stores/theme.js
Normal file
@@ -0,0 +1,39 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'shopdb-theme'
|
||||
|
||||
// Get initial theme from localStorage or system preference
|
||||
function getInitialTheme() {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) return stored
|
||||
|
||||
// Fall back to system preference
|
||||
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark'
|
||||
}
|
||||
return 'light'
|
||||
}
|
||||
|
||||
export const currentTheme = ref(getInitialTheme())
|
||||
|
||||
// Apply theme to document
|
||||
function applyTheme(theme) {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
}
|
||||
|
||||
// Initialize on load
|
||||
applyTheme(currentTheme.value)
|
||||
|
||||
// Watch for changes
|
||||
watch(currentTheme, (newTheme) => {
|
||||
applyTheme(newTheme)
|
||||
})
|
||||
|
||||
export function toggleTheme() {
|
||||
currentTheme.value = currentTheme.value === 'dark' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
export function setTheme(theme) {
|
||||
currentTheme.value = theme
|
||||
}
|
||||
9
frontend/src/utils/apiError.js
Normal file
9
frontend/src/utils/apiError.js
Normal file
@@ -0,0 +1,9 @@
|
||||
// Extract a human-readable message from an API error. The backend nests the
|
||||
// message at data.data.error.message (see error_response); older call sites read
|
||||
// shallower paths, so try them all before falling back.
|
||||
export function apiError(err, fallback = 'Something went wrong') {
|
||||
return err?.response?.data?.data?.error?.message
|
||||
|| err?.response?.data?.error?.message
|
||||
|| err?.response?.data?.message
|
||||
|| fallback
|
||||
}
|
||||
63
frontend/src/utils/assetTypes.js
Normal file
63
frontend/src/utils/assetTypes.js
Normal file
@@ -0,0 +1,63 @@
|
||||
// Single source of truth for asset-type display labels + detail routing.
|
||||
// The map and shopfloor views used to each hardcode these identical maps.
|
||||
// Keys are lowercase; both 'network_device' and 'network device' are accepted
|
||||
// because the API sends the underscore form and some map data the spaced form.
|
||||
|
||||
const ASSET_TYPE_LABELS = {
|
||||
'machine': 'Machines',
|
||||
'computer': 'Computers',
|
||||
'printer': 'Printers',
|
||||
'network_device': 'Network Devices',
|
||||
'network device': 'Network Devices',
|
||||
'measuring_tool': 'Measuring Tools',
|
||||
}
|
||||
|
||||
const ASSET_TYPE_ROUTES = {
|
||||
'machine': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'network device': '/network',
|
||||
'measuring_tool': '/measuringtools',
|
||||
}
|
||||
|
||||
// Plugin-specific id field inside asset.typedata for each asset type.
|
||||
const ASSET_TYPE_ID_KEYS = {
|
||||
'machine': 'machineid',
|
||||
'computer': 'computerid',
|
||||
'printer': 'printerid',
|
||||
'network_device': 'networkdeviceid',
|
||||
'network device': 'networkdeviceid',
|
||||
'measuring_tool': 'measuringtoolid',
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
return String(text || '')
|
||||
.replace(/[_-]+/g, ' ')
|
||||
.replace(/\b\w/g, c => c.toUpperCase())
|
||||
}
|
||||
|
||||
// Plural, human-friendly label for an asset type. Falls back to title-cased
|
||||
// input so an unknown/new type still reads sensibly.
|
||||
export function assetTypeLabel(type) {
|
||||
if (!type) return type
|
||||
return ASSET_TYPE_LABELS[String(type).toLowerCase()] || titleCase(type)
|
||||
}
|
||||
|
||||
// Base list route for an asset type (e.g. 'computer' -> '/pcs').
|
||||
export function assetTypeRoute(type) {
|
||||
return ASSET_TYPE_ROUTES[String(type || '').toLowerCase()] || '/machines'
|
||||
}
|
||||
|
||||
// Full detail route for a unified-format asset, preferring the plugin-specific
|
||||
// id in typedata and falling back to the asset id.
|
||||
export function assetDetailRoute(asset) {
|
||||
const type = (asset.assettype || '').toLowerCase()
|
||||
const base = assetTypeRoute(type)
|
||||
const idKey = ASSET_TYPE_ID_KEYS[type]
|
||||
let id = asset.assetid
|
||||
if (asset.typedata && idKey && asset.typedata[idKey]) {
|
||||
id = asset.typedata[idKey]
|
||||
}
|
||||
return `${base}/${id}`
|
||||
}
|
||||
13
frontend/src/utils/basePath.js
Normal file
13
frontend/src/utils/basePath.js
Normal file
@@ -0,0 +1,13 @@
|
||||
// Single source for the app's mount path. Vite injects import.meta.env.BASE_URL
|
||||
// from the build-time `base` (default '/', or e.g. '/ops/' for a subpath IIS
|
||||
// mount). Every root-absolute URL to a Flask-served asset or route must go
|
||||
// through withBase() so it resolves under the mount instead of the server root.
|
||||
export const BASE_URL = import.meta.env.BASE_URL
|
||||
|
||||
// Prefix a root-absolute path (e.g. '/ge-aerospace-logo.svg', '/api', '/tv')
|
||||
// with the mount base. Leaves full URLs (http, data:) untouched.
|
||||
export function withBase(path) {
|
||||
if (!path) return path
|
||||
if (/^([a-z]+:)?\/\//i.test(path) || path.startsWith('data:')) return path
|
||||
return BASE_URL + String(path).replace(/^\//, '')
|
||||
}
|
||||
39
frontend/src/utils/colorStyle.js
Normal file
39
frontend/src/utils/colorStyle.js
Normal file
@@ -0,0 +1,39 @@
|
||||
// Data-driven badge colors. A record stores a hex color; the UI renders it with
|
||||
// an auto-picked readable text color, so any color stays legible. Pickers should
|
||||
// offer PALETTE (curated, distinct, accessible) with custom hex as a fallback.
|
||||
|
||||
// Curated categorical palette - distinct + accessible. ~12 is the practical
|
||||
// ceiling for at-a-glance distinguishability, which is plenty per category.
|
||||
export const PALETTE = [
|
||||
'#f5365c', // red
|
||||
'#fb6340', // orange
|
||||
'#ff8800', // amber
|
||||
'#ffc107', // gold
|
||||
'#2dce89', // green
|
||||
'#04b962', // emerald
|
||||
'#11cdef', // cyan
|
||||
'#14abef', // blue
|
||||
'#0d6efd', // royal blue
|
||||
'#7934f3', // purple
|
||||
'#e83e8c', // pink
|
||||
'#6c757d', // gray
|
||||
]
|
||||
|
||||
// Black or white text for a given background, by perceived brightness.
|
||||
export function readableText(bg) {
|
||||
if (!bg || typeof bg !== 'string') return '#ffffff'
|
||||
let hex = bg.trim().replace('#', '')
|
||||
if (hex.length === 3) hex = hex.split('').map(c => c + c).join('')
|
||||
if (hex.length !== 6) return '#ffffff'
|
||||
const r = parseInt(hex.slice(0, 2), 16)
|
||||
const g = parseInt(hex.slice(2, 4), 16)
|
||||
const b = parseInt(hex.slice(4, 6), 16)
|
||||
const brightness = (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
||||
return brightness > 0.6 ? '#1a1a1a' : '#ffffff'
|
||||
}
|
||||
|
||||
// Style object for a badge/pill from a stored color (with a neutral fallback).
|
||||
export function colorStyle(color, fallback = '#6c757d') {
|
||||
const backgroundColor = color || fallback
|
||||
return { backgroundColor, color: readableText(backgroundColor) }
|
||||
}
|
||||
54
frontend/src/utils/mapColors.js
Normal file
54
frontend/src/utils/mapColors.js
Normal file
@@ -0,0 +1,54 @@
|
||||
// Marker color logic for the shop-floor map, shared so the PDF export renders
|
||||
// the exact same colors the map shows on screen. Mirrors the maps defined in
|
||||
// ShopFloorMap.vue - keep the two in sync if the palette changes.
|
||||
|
||||
export const assetTypeColorsMap = {
|
||||
machine: '#F44336', // Red
|
||||
computer: '#2196F3', // Blue
|
||||
printer: '#4CAF50', // Green
|
||||
'network device': '#FF9800', // Orange
|
||||
network_device: '#FF9800', // Orange (alternate key)
|
||||
measuring_tool: '#9C27B0', // Purple
|
||||
'measuring tool': '#9C27B0' // Purple (normalized key)
|
||||
}
|
||||
|
||||
const DEFAULT_COLOR = '#BDBDBD'
|
||||
|
||||
// Canonical form for comparing asset-type strings. The API sends the machine
|
||||
// type as 'network_device' but subtype keys and labels use 'network device',
|
||||
// so normalize underscores to spaces before any comparison. Without this,
|
||||
// network-device subtypes silently fail to match (machine/computer/printer
|
||||
// are single words and were unaffected, which is why only network broke).
|
||||
export function normalizeAssetType(assettype) {
|
||||
return (assettype || '').toLowerCase().replace(/_/g, ' ')
|
||||
}
|
||||
|
||||
// Color for an asset by its top-level asset type (used when no type filter is
|
||||
// active, so every type is distinguished by color).
|
||||
export function getAssetTypeColor(assettype) {
|
||||
if (!assettype) return DEFAULT_COLOR
|
||||
return assetTypeColorsMap[assettype.toLowerCase()] || DEFAULT_COLOR
|
||||
}
|
||||
|
||||
// The subtype id for an asset, read from the plugin-specific typedata block.
|
||||
// Returns null when the asset has no subtype.
|
||||
export function getSubtypeId(asset) {
|
||||
if (!asset || !asset.typedata) return null
|
||||
const typeLower = normalizeAssetType(asset.assettype)
|
||||
if (typeLower === 'machine') return asset.typedata.machinetypeid
|
||||
if (typeLower === 'computer') return asset.typedata.computertypeid
|
||||
if (typeLower === 'network device') return asset.typedata.networkdevicetypeid
|
||||
if (typeLower === 'printer') return asset.typedata.printertypeid
|
||||
if (typeLower === 'measuring tool') return asset.typedata.measuringtooltypeid
|
||||
return null
|
||||
}
|
||||
|
||||
// Resolve the marker color for an asset the same way ShopFloorMap does: when a
|
||||
// type is selected, color by subtype (grey fallback); otherwise by asset type.
|
||||
export function resolveMarkerColor(asset, { selectedType, subtypeColors = {} }) {
|
||||
if (selectedType) {
|
||||
const id = getSubtypeId(asset)
|
||||
return (id != null && subtypeColors[id]) || DEFAULT_COLOR
|
||||
}
|
||||
return getAssetTypeColor(asset.assettype)
|
||||
}
|
||||
135
frontend/src/utils/mapPdf.js
Normal file
135
frontend/src/utils/mapPdf.js
Normal file
@@ -0,0 +1,135 @@
|
||||
// Export the shop-floor map (current filtered assets on the facility blueprint)
|
||||
// to a PDF, entirely client-side. The blueprint image is drawn full-size and
|
||||
// each visible marker is placed at its scaled coordinate, so the PDF is a crisp
|
||||
// vector-over-raster page rather than a screen capture.
|
||||
import { jsPDF } from 'jspdf'
|
||||
import { resolveMarkerColor, getSubtypeId, getAssetTypeColor } from './mapColors'
|
||||
|
||||
function loadImage(url) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
img.crossOrigin = 'anonymous'
|
||||
img.onload = () => resolve(img)
|
||||
img.onerror = () => reject(new Error('Failed to load blueprint image: ' + url))
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function hexToRgb(hex) {
|
||||
const h = (hex || '#BDBDBD').replace('#', '')
|
||||
const v = h.length === 3 ? h.split('').map(c => c + c).join('') : h
|
||||
return [parseInt(v.slice(0, 2), 16), parseInt(v.slice(2, 4), 16), parseInt(v.slice(4, 6), 16)]
|
||||
}
|
||||
|
||||
// Build the legend entries (color + label) for the assets present, matching the
|
||||
// active coloring mode.
|
||||
function buildLegend(assets, { selectedType, subtypeColors, subtypeNames }) {
|
||||
const seen = new Map()
|
||||
for (const a of assets) {
|
||||
let key, label, color
|
||||
if (selectedType) {
|
||||
const id = getSubtypeId(a)
|
||||
key = id != null ? String(id) : 'none'
|
||||
label = (id != null && subtypeNames[id]) || 'Unspecified'
|
||||
color = (id != null && subtypeColors[id]) || '#BDBDBD'
|
||||
} else {
|
||||
key = (a.assettype || 'unknown').toLowerCase()
|
||||
label = a.assettype || 'Unknown'
|
||||
color = getAssetTypeColor(a.assettype)
|
||||
}
|
||||
if (!seen.has(key)) seen.set(key, { label, color, count: 0 })
|
||||
seen.get(key).count += 1
|
||||
}
|
||||
return [...seen.values()].sort((a, b) => b.count - a.count)
|
||||
}
|
||||
|
||||
export async function exportMapPdf(opts) {
|
||||
const {
|
||||
assets = [],
|
||||
blueprintUrl,
|
||||
mapWidth = 3300,
|
||||
mapHeight = 2550,
|
||||
selectedType = '',
|
||||
subtypeColors = {},
|
||||
subtypeNames = {},
|
||||
filters = [],
|
||||
facility = '',
|
||||
title = 'Shop Floor Map'
|
||||
} = opts
|
||||
|
||||
const img = await loadImage(blueprintUrl)
|
||||
|
||||
const doc = new jsPDF({ orientation: 'landscape', unit: 'pt', format: 'a4', compress: true })
|
||||
const pageW = doc.internal.pageSize.getWidth()
|
||||
const pageH = doc.internal.pageSize.getHeight()
|
||||
const margin = 28
|
||||
|
||||
// ---- Header ----
|
||||
doc.setTextColor('#111111')
|
||||
doc.setFont('helvetica', 'bold')
|
||||
doc.setFontSize(16)
|
||||
doc.text(title, margin, margin + 6)
|
||||
|
||||
doc.setFont('helvetica', 'normal')
|
||||
doc.setFontSize(9)
|
||||
doc.setTextColor('#555555')
|
||||
const stamp = new Date().toLocaleString()
|
||||
const subParts = [facility, stamp, `${assets.length} asset${assets.length === 1 ? '' : 's'}`].filter(Boolean)
|
||||
doc.text(subParts.join(' | '), margin, margin + 22)
|
||||
|
||||
const filterText = filters.length ? 'Filters: ' + filters.join(' ') : 'Filters: none (all assets)'
|
||||
doc.text(filterText, margin, margin + 35)
|
||||
|
||||
// ---- Legend (wraps under the header, pushes the map down) ----
|
||||
const legend = buildLegend(assets, { selectedType, subtypeColors, subtypeNames })
|
||||
const swatch = 8
|
||||
const legendY0 = margin + 50
|
||||
const lineH = 15
|
||||
let lx = margin
|
||||
let ly = legendY0
|
||||
doc.setFontSize(8.5)
|
||||
for (const entry of legend) {
|
||||
const label = `${entry.label} (${entry.count})`
|
||||
const w = swatch + 4 + doc.getTextWidth(label) + 16
|
||||
if (lx + w > pageW - margin) { lx = margin; ly += lineH }
|
||||
const [r, g, b] = hexToRgb(entry.color)
|
||||
doc.setFillColor(r, g, b)
|
||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.4)
|
||||
doc.rect(lx, ly - swatch + 1, swatch, swatch, 'F')
|
||||
doc.setTextColor('#333333')
|
||||
doc.text(label, lx + swatch + 4, ly)
|
||||
lx += w
|
||||
}
|
||||
const legendBottom = legend.length ? ly + 6 : legendY0
|
||||
|
||||
// ---- Blueprint image, fit into the area below the legend ----
|
||||
const aspect = mapWidth / mapHeight
|
||||
const contentTop = legendBottom + 8
|
||||
const availW = pageW - margin * 2
|
||||
const availH = pageH - contentTop - margin
|
||||
let imgW = availW
|
||||
let imgH = availW / aspect
|
||||
if (imgH > availH) { imgH = availH; imgW = availH * aspect }
|
||||
const imgX = margin + (availW - imgW) / 2
|
||||
const imgY = contentTop + (availH - imgH) / 2
|
||||
|
||||
doc.addImage(img, 'PNG', imgX, imgY, imgW, imgH, undefined, 'FAST')
|
||||
doc.setDrawColor('#cccccc'); doc.setLineWidth(0.5)
|
||||
doc.rect(imgX, imgY, imgW, imgH)
|
||||
|
||||
// ---- Markers (database Y is top-down, same origin as the drawn image) ----
|
||||
const radius = 3.2
|
||||
for (const a of assets) {
|
||||
if (a.mapx == null || a.mapy == null) continue
|
||||
const x = imgX + (a.mapx / mapWidth) * imgW
|
||||
const y = imgY + (a.mapy / mapHeight) * imgH
|
||||
if (x < imgX || x > imgX + imgW || y < imgY || y > imgY + imgH) continue
|
||||
const [r, g, b] = hexToRgb(resolveMarkerColor(a, { selectedType, subtypeColors }))
|
||||
doc.setFillColor(r, g, b)
|
||||
doc.setDrawColor('#ffffff'); doc.setLineWidth(0.6)
|
||||
doc.circle(x, y, radius, 'FD')
|
||||
}
|
||||
|
||||
const dateSlug = new Date().toISOString().slice(0, 10)
|
||||
doc.save(`shopfloor-map-${dateSlug}.pdf`)
|
||||
}
|
||||
26
frontend/src/utils/qrTarget.js
Normal file
26
frontend/src/utils/qrTarget.js
Normal file
@@ -0,0 +1,26 @@
|
||||
// Shared QR target resolution for printed labels. Each label surface has a
|
||||
// qr_target_<domain> setting: blank means the QR links to the asset's own
|
||||
// detail page on this instance; a non-blank value is treated as a URL
|
||||
// template and every {placeholder} is substituted from the tokens map.
|
||||
import { getSetting, getSiteBaseUrl } from './siteSettings'
|
||||
|
||||
// Substitute {name} placeholders. Values are URL-encoded; unknown or empty
|
||||
// placeholders collapse to an empty string rather than leaking the braces.
|
||||
export function fillUrlTemplate(template, tokens) {
|
||||
return template.replace(/\{([a-z0-9_]+)\}/gi, (match, name) => {
|
||||
const value = tokens[name]
|
||||
return (value === undefined || value === null) ? '' : encodeURIComponent(String(value))
|
||||
})
|
||||
}
|
||||
|
||||
// Resolve the QR URL for one asset. settingKey is the qr_target_<domain>
|
||||
// setting, defaultPath the in-app detail route (e.g. /printers/12), tokens
|
||||
// the placeholder values available to a custom template.
|
||||
export async function buildQrUrl(settingKey, defaultPath, tokens = {}) {
|
||||
const template = (await getSetting(settingKey, '')).trim()
|
||||
if (!template) {
|
||||
const baseUrl = await getSiteBaseUrl()
|
||||
return `${baseUrl}${defaultPath}`
|
||||
}
|
||||
return fillUrlTemplate(template, tokens)
|
||||
}
|
||||
169
frontend/src/utils/siteSettings.js
Normal file
169
frontend/src/utils/siteSettings.js
Normal file
@@ -0,0 +1,169 @@
|
||||
// Shared read-through for public site settings (site_base_url, facility_name).
|
||||
// The settings GET is public (jwt optional), so the kiosk dashboard and the
|
||||
// print views can read these without auth. Fetched once and cached per page.
|
||||
import { settingsApi } from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
let settingsCache = null
|
||||
|
||||
async function loadSettings() {
|
||||
if (settingsCache) return settingsCache
|
||||
try {
|
||||
const response = await settingsApi.list()
|
||||
const items = response.data?.data || response.data || []
|
||||
settingsCache = {}
|
||||
for (const s of items) settingsCache[s.key] = s.value
|
||||
} catch (err) {
|
||||
console.error('Error loading site settings:', err)
|
||||
settingsCache = {}
|
||||
}
|
||||
return settingsCache
|
||||
}
|
||||
|
||||
export async function getSetting(key, fallback = '') {
|
||||
const settings = await loadSettings()
|
||||
const value = settings[key]
|
||||
return (value === undefined || value === null || value === '') ? fallback : value
|
||||
}
|
||||
|
||||
// Public base URL for QR codes / absolute links. Falls back to the current
|
||||
// browsing origin when a site has not set one.
|
||||
export async function getSiteBaseUrl() {
|
||||
return getSetting('site_base_url', window.location.origin)
|
||||
}
|
||||
|
||||
// Facility name shown on the shopfloor dashboard.
|
||||
export async function getFacilityName() {
|
||||
return getSetting('facility_name', 'ShopDB')
|
||||
}
|
||||
|
||||
// Main site logo (sidebar, login, dashboard header). Fallback = shipped GE mark.
|
||||
export async function getSiteLogo() {
|
||||
return withBase(await getSetting('site_logo', '/ge-aerospace-logo.svg'))
|
||||
}
|
||||
|
||||
// Logo composited into the center of printer QR codes. Empty = no overlay.
|
||||
// Note: '' is a valid "no overlay" value, so read the raw setting rather than
|
||||
// getSetting (which swaps '' for the fallback).
|
||||
export async function getQrLogo() {
|
||||
const settings = await loadSettings()
|
||||
const value = settings['qr_logo']
|
||||
return withBase((value === undefined || value === null) ? '/ge-monogram.svg' : value)
|
||||
}
|
||||
|
||||
// Logo printed on machine inspection badges.
|
||||
export async function getBadgeLogo() {
|
||||
return withBase(await getSetting('badge_logo', '/ge-aerospace-logo.svg'))
|
||||
}
|
||||
|
||||
// Browser-tab favicon. Empty = keep the shipped /favicon.svg.
|
||||
export async function getFavicon() {
|
||||
const value = await getSetting('site_favicon', '')
|
||||
return value ? withBase(value) : value
|
||||
}
|
||||
|
||||
// Brand primary color override. Empty = built-in palette from style.css.
|
||||
export async function getBrandPrimaryColor() {
|
||||
return getSetting('brand_primary_color', '')
|
||||
}
|
||||
|
||||
// Primary hover/active color (--primary-dark). Empty = derive from primary.
|
||||
export async function getBrandPrimaryDarkColor() {
|
||||
return getSetting('brand_primary_dark_color', '')
|
||||
}
|
||||
|
||||
// Accent color (--secondary). Empty = built-in palette from style.css.
|
||||
export async function getBrandAccentColor() {
|
||||
return getSetting('brand_accent_color', '')
|
||||
}
|
||||
|
||||
// Sidebar background color (--sidebar-bg). Empty = built-in palette.
|
||||
export async function getBrandSidebarColor() {
|
||||
return getSetting('brand_sidebar_color', '')
|
||||
}
|
||||
|
||||
// Darken a #rrggbb hex color by the given fraction (0.15 = 15% darker).
|
||||
// Returns null for anything that is not a 6-digit hex so callers can skip it.
|
||||
function darkenHexColor(hex, fraction) {
|
||||
const match = /^#([0-9a-fA-F]{6})$/.exec(hex)
|
||||
if (!match) return null
|
||||
const num = parseInt(match[1], 16)
|
||||
const scale = 1 - fraction
|
||||
const red = Math.round(((num >> 16) & 0xff) * scale)
|
||||
const green = Math.round(((num >> 8) & 0xff) * scale)
|
||||
const blue = Math.round((num & 0xff) * scale)
|
||||
const toHex = (channel) => channel.toString(16).padStart(2, '0')
|
||||
return `#${toHex(red)}${toHex(green)}${toHex(blue)}`
|
||||
}
|
||||
|
||||
// ServiceNow ticket-link config. Returns the enabled flag, incident/change URL
|
||||
// templates, and the global-search URL template (all with a {ticket}
|
||||
// placeholder). Defaults mirror the previously-hardcoded GE ServiceNow URLs.
|
||||
export async function getServicenowUrls() {
|
||||
const enabledRaw = await getSetting('servicenow_enabled', 'true')
|
||||
const enabled = enabledRaw !== 'false' && enabledRaw !== '0' && enabledRaw !== false
|
||||
const incidentUrl = await getSetting(
|
||||
'servicenow_incident_url',
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
)
|
||||
const changeUrl = await getSetting(
|
||||
'servicenow_change_url',
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
)
|
||||
const searchUrl = await getSetting(
|
||||
'servicenow_search_url',
|
||||
'https://geaerospaceqa.service-now.com/now/nav/ui/search/0f8b85d0c7922010099a308dc7c2606a/params/search-term/{ticket}/global-search-data-config-id/c861cea2c7022010099a308dc7c26041/'
|
||||
)
|
||||
return { enabled, incidentUrl, changeUrl, searchUrl }
|
||||
}
|
||||
|
||||
// Printer hostname template. {ip} is replaced with the dash-separated IP.
|
||||
export async function getPrinterHostnameTemplate() {
|
||||
return getSetting('printer_hostname_template', 'Printer-{ip}.printer.geaerospace.net')
|
||||
}
|
||||
|
||||
// Email domain appended to a contact SSO to build email/Teams links
|
||||
// (sso@domain). Unset falls back to geaerospace.com; an explicit blank value
|
||||
// means "disable contact action buttons", so read the raw setting rather than
|
||||
// getSetting (which swaps '' for the fallback).
|
||||
export async function getContactEmailDomain() {
|
||||
const settings = await loadSettings()
|
||||
const value = settings['contact_email_domain']
|
||||
return (value === undefined || value === null) ? 'geaerospace.com' : value
|
||||
}
|
||||
|
||||
// Apply per-site favicon + brand color at bootstrap. Empty settings keep the
|
||||
// shipped defaults. Do not touch the style.css palette here.
|
||||
export async function applyBranding() {
|
||||
const favicon = await getFavicon()
|
||||
if (favicon) {
|
||||
let link = document.querySelector('link[rel="icon"]')
|
||||
if (!link) {
|
||||
link = document.createElement('link')
|
||||
link.rel = 'icon'
|
||||
document.head.appendChild(link)
|
||||
}
|
||||
link.href = favicon
|
||||
}
|
||||
const rootStyle = document.documentElement.style
|
||||
const primaryColor = await getBrandPrimaryColor()
|
||||
if (primaryColor) {
|
||||
rootStyle.setProperty('--primary', primaryColor)
|
||||
}
|
||||
// Hover color: use the explicit value, else darken primary ~15%.
|
||||
const primaryDarkColor = await getBrandPrimaryDarkColor()
|
||||
if (primaryDarkColor) {
|
||||
rootStyle.setProperty('--primary-dark', primaryDarkColor)
|
||||
} else if (primaryColor) {
|
||||
const derived = darkenHexColor(primaryColor, 0.15)
|
||||
if (derived) rootStyle.setProperty('--primary-dark', derived)
|
||||
}
|
||||
const accentColor = await getBrandAccentColor()
|
||||
if (accentColor) {
|
||||
rootStyle.setProperty('--secondary', accentColor)
|
||||
}
|
||||
const sidebarColor = await getBrandSidebarColor()
|
||||
if (sidebarColor) {
|
||||
rootStyle.setProperty('--sidebar-bg', sidebarColor)
|
||||
}
|
||||
}
|
||||
278
frontend/src/views/AppLayout.vue
Normal file
278
frontend/src/views/AppLayout.vue
Normal file
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<div class="app-layout">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-header">
|
||||
<img :src="siteLogo" alt="Site logo" class="sidebar-logo" />
|
||||
<h1>{{ facilityName }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="sidebar-search">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
placeholder="Search..."
|
||||
@keyup.enter="performSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<nav class="sidebar-nav">
|
||||
<template v-for="item in navItems" :key="item.route">
|
||||
<div v-if="item.section" class="nav-section">{{ item.section }}</div>
|
||||
<router-link :to="item.route">
|
||||
<component v-if="item.iconComponent" :is="item.iconComponent" :size="16" />
|
||||
{{ item.name }}
|
||||
</router-link>
|
||||
</template>
|
||||
|
||||
<div class="nav-section">Displays</div>
|
||||
<a :href="withBase('/shopfloor')" target="_blank" class="external-link">Shopfloor Dashboard</a>
|
||||
<a :href="withBase('/tv')" target="_blank" class="external-link">TV Slideshow</a>
|
||||
|
||||
<router-link v-if="authStore.isAdmin" to="/settings">Settings</router-link>
|
||||
</nav>
|
||||
|
||||
<div class="sidebar-footer">
|
||||
<button class="theme-toggle" @click="toggleTheme" :title="currentTheme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'">
|
||||
<span v-if="currentTheme === 'dark'"><Sun :size="14" /> Light</span>
|
||||
<span v-else><Moon :size="14" /> Dark</span>
|
||||
</button>
|
||||
|
||||
<div class="user-menu">
|
||||
<template v-if="authStore.isAuthenticated">
|
||||
<div class="user-identity">
|
||||
<img v-if="authStore.avatarUrl" :src="authStore.avatarUrl" class="user-avatar"
|
||||
:alt="authStore.displayName" @error="onAvatarError" />
|
||||
<div class="user-ident">
|
||||
<div class="username">{{ authStore.displayName }}</div>
|
||||
<div v-if="authStore.displayName !== authStore.username" class="user-sso">{{ authStore.username }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-actions">
|
||||
<router-link to="/change-password" class="icon-btn" title="Change password">
|
||||
<KeyRound :size="16" /> Password
|
||||
</router-link>
|
||||
<button class="icon-btn" @click="handleLogout" title="Log out">
|
||||
<LogOut :size="16" /> Logout
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<router-link v-else to="/login" class="btn btn-primary">Login</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="main-content">
|
||||
<div v-if="activeNotifications.length" class="notification-banner">
|
||||
<div
|
||||
v-for="n in activeNotifications"
|
||||
:key="n.notificationid"
|
||||
class="notification-item"
|
||||
:class="getNotificationType(n)"
|
||||
>
|
||||
<span class="notification-text">{{ n.notification }}</span>
|
||||
<span class="notification-meta">
|
||||
<span v-if="n.starttime" class="notification-date">{{ formatDate(n.starttime) }}</span>
|
||||
<template v-if="n.ticketnumber">
|
||||
<a
|
||||
v-if="getTicketSearchUrl(n.ticketnumber)"
|
||||
:href="getTicketSearchUrl(n.ticketnumber)"
|
||||
target="_blank"
|
||||
class="notification-ticket"
|
||||
>{{ n.ticketnumber }}</a>
|
||||
<span v-else class="notification-ticket">{{ n.ticketnumber }}</span>
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Keyed on path so same-component navigation (machine -> machine via
|
||||
a relationship link) remounts and reloads; query-only changes
|
||||
(e.g. /reports?report=x) do not remount, and the settings shell
|
||||
keys as one unit so its rail survives child navigation. -->
|
||||
<router-view :key="routeViewKey" />
|
||||
</main>
|
||||
<ToastHost />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import ToastHost from '../components/ToastHost.vue'
|
||||
import {
|
||||
Sun, Moon, LayoutDashboard, Calendar, Map, Cog, Monitor,
|
||||
Printer, Globe, Usb, AppWindow, BookOpen, BarChart3, Bell, Image, ShieldCheck, Ruler,
|
||||
KeyRound, LogOut
|
||||
} from 'lucide-vue-next'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { currentTheme, toggleTheme } from '../stores/theme'
|
||||
import { dashboardApi, notificationsApi } from '../api'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
const routeViewKey = computed(() =>
|
||||
route.path.startsWith('/settings') ? '/settings' : route.path
|
||||
)
|
||||
const searchQuery = ref('')
|
||||
const navItems = ref([])
|
||||
const activeNotifications = ref([])
|
||||
const facilityName = ref('ShopDB')
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const servicenowConfig = ref({ enabled: true, searchUrl: '' })
|
||||
|
||||
function getTicketSearchUrl(ticketnumber) {
|
||||
// Null when ServiceNow is disabled or no search template is set: the
|
||||
// ticket number renders as plain text instead of a link.
|
||||
const config = servicenowConfig.value
|
||||
if (!ticketnumber || !config.enabled || !config.searchUrl) return null
|
||||
return config.searchUrl.replace('{ticket}', encodeURIComponent(ticketnumber))
|
||||
}
|
||||
|
||||
// Map backend icon names to Lucide components
|
||||
const iconMap = {
|
||||
'layout-dashboard': LayoutDashboard,
|
||||
'calendar': Calendar,
|
||||
'map': Map,
|
||||
'cog': Cog,
|
||||
'desktop': Monitor,
|
||||
'printer': Printer,
|
||||
'network-wired': Globe,
|
||||
'usb': Usb,
|
||||
'bell': Bell,
|
||||
'app-window': AppWindow,
|
||||
'book-open': BookOpen,
|
||||
'bar-chart-3': BarChart3,
|
||||
'image': Image,
|
||||
'shield': ShieldCheck,
|
||||
'ruler': Ruler,
|
||||
}
|
||||
|
||||
// Default navigation (used as fallback if API fails)
|
||||
const defaultNav = [
|
||||
{ name: 'Dashboard', icon: 'layout-dashboard', route: '/', position: 0 },
|
||||
{ name: 'Notifications', icon: 'bell', route: '/notifications', position: 5 },
|
||||
{ name: 'Calendar', icon: 'calendar', route: '/calendar', position: 6 },
|
||||
{ name: 'Map', icon: 'map', route: '/map', position: 4 },
|
||||
{ name: 'Machines', icon: 'cog', route: '/machines', position: 10 },
|
||||
{ name: 'PCs', icon: 'desktop', route: '/pcs', position: 15 },
|
||||
{ name: 'Network', icon: 'network-wired', route: '/network', position: 18 },
|
||||
{ name: 'Printers', icon: 'printer', route: '/printers', position: 20 },
|
||||
{ name: 'USB Devices', icon: 'usb', route: '/usb', position: 45 },
|
||||
{ name: 'Applications', icon: 'app-window', route: '/applications', position: 30, section: 'information' },
|
||||
{ name: 'Knowledge Base', icon: 'book-open', route: '/knowledgebase', position: 35 },
|
||||
{ name: 'Reports', icon: 'bar-chart-3', route: '/reports', position: 40 },
|
||||
]
|
||||
|
||||
function buildNavItems(items) {
|
||||
// Sort by position
|
||||
const sorted = [...items].sort((a, b) => (a.position || 99) - (b.position || 99))
|
||||
|
||||
// Assign section headers based on position ranges
|
||||
const result = []
|
||||
let currentSection = null
|
||||
|
||||
for (const item of sorted) {
|
||||
let section = null
|
||||
if (item.position >= 10 && item.position < 30 && currentSection !== 'Assets') {
|
||||
section = 'Assets'
|
||||
currentSection = 'Assets'
|
||||
} else if (item.section === 'information' || (item.position >= 30 && item.position < 50 && currentSection !== 'Information')) {
|
||||
if (currentSection !== 'Information') {
|
||||
section = 'Information'
|
||||
currentSection = 'Information'
|
||||
}
|
||||
}
|
||||
|
||||
result.push({
|
||||
...item,
|
||||
section,
|
||||
iconComponent: iconMap[item.icon] || null
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
getFacilityName().then(name => { facilityName.value = name })
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
getServicenowUrls().then(config => { servicenowConfig.value = config })
|
||||
|
||||
try {
|
||||
const response = await dashboardApi.navigation()
|
||||
navItems.value = buildNavItems(response.data.data || [])
|
||||
} catch (err) {
|
||||
navItems.value = buildNavItems(defaultNav)
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await notificationsApi.active()
|
||||
activeNotifications.value = resp.data.data?.notifications || []
|
||||
} catch (err) {
|
||||
// Notifications banner is non-critical
|
||||
}
|
||||
})
|
||||
|
||||
function getNotificationType(n) {
|
||||
const type = (n.typename || '').toLowerCase()
|
||||
if (type.includes('incident')) return 'type-incident'
|
||||
if (type.includes('change')) return 'type-change'
|
||||
if (type.includes('awareness')) return 'type-awareness'
|
||||
return 'type-default'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const d = new Date(dateStr)
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' })
|
||||
}
|
||||
|
||||
function performSearch() {
|
||||
if (searchQuery.value.trim()) {
|
||||
router.push({ path: '/search', query: { q: searchQuery.value.trim() } })
|
||||
searchQuery.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.logout()
|
||||
router.push('/login')
|
||||
}
|
||||
|
||||
// Hide a broken avatar (photo filename set but file missing).
|
||||
function onAvatarError(event) {
|
||||
event.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Footer user block: identity row on top, compact icon actions below, all
|
||||
kept inside the fixed-width sidebar (no full-width buttons overflowing). */
|
||||
.user-menu { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.user-identity { display: flex; align-items: center; gap: 0.6rem; min-width: 0; }
|
||||
.user-avatar { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; border: 1px solid var(--border); flex-shrink: 0; }
|
||||
.user-ident { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
|
||||
.user-ident .username { margin-bottom: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.user-sso { font-size: 0.72rem; color: var(--text-light); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.user-actions { display: flex; gap: 0.5rem; }
|
||||
.icon-btn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: rgba(255,255,255,0.08);
|
||||
border: 1px solid rgba(255,255,255,0.15);
|
||||
border-radius: 6px;
|
||||
color: rgba(255,255,255,0.8);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
}
|
||||
.icon-btn:hover { background: rgba(255,255,255,0.16); color: #fff; }
|
||||
</style>
|
||||
380
frontend/src/views/CalendarView.vue
Normal file
380
frontend/src/views/CalendarView.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Calendar</h1>
|
||||
</div>
|
||||
|
||||
<div class="calendar-container card">
|
||||
<FullCalendar :options="calendarOptions" />
|
||||
</div>
|
||||
|
||||
<!-- More events tooltip -->
|
||||
<div
|
||||
v-if="moreTooltipData.length"
|
||||
ref="moreTooltip"
|
||||
class="fc-more-tooltip"
|
||||
:style="{ left: tooltipPosition.left + 'px', top: tooltipPosition.top + 'px' }"
|
||||
@mouseleave="hideMoreTooltip"
|
||||
>
|
||||
<div
|
||||
v-for="(evt, idx) in moreTooltipData"
|
||||
:key="idx"
|
||||
class="fc-more-tooltip-event"
|
||||
:style="{ borderLeftColor: evt.color }"
|
||||
@click="openEventFromTooltip(evt)"
|
||||
>
|
||||
{{ evt.title }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event details modal -->
|
||||
<div v-if="selectedEvent" class="modal-overlay" @click.self="closeEventModal">
|
||||
<div class="modal">
|
||||
<!-- 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">{{ 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>
|
||||
<span class="employee-name">{{ selectedEvent.extendedProps.employeename || selectedEvent.extendedProps.employeesso }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Regular event header -->
|
||||
<h2 v-else>{{ selectedEvent.title }}</h2>
|
||||
|
||||
<div class="event-details">
|
||||
<p v-if="selectedEvent.extendedProps?.typename && !selectedEvent.extendedProps?.showemployeephoto">
|
||||
<strong>Type:</strong> {{ selectedEvent.extendedProps.typename }}
|
||||
</p>
|
||||
<p>
|
||||
<strong>Start:</strong> {{ formatDate(selectedEvent.start) }}
|
||||
</p>
|
||||
<p v-if="selectedEvent.end">
|
||||
<strong>End:</strong> {{ formatDate(selectedEvent.end) }}
|
||||
</p>
|
||||
<p v-if="selectedEvent.extendedProps?.message && !selectedEvent.extendedProps?.showemployeephoto" class="message-block">
|
||||
<strong>Details:</strong>
|
||||
<span class="message-text">{{ selectedEvent.extendedProps.message }}</span>
|
||||
</p>
|
||||
<p v-if="selectedEvent.extendedProps?.ticketnumber">
|
||||
<strong>Ticket:</strong> {{ selectedEvent.extendedProps.ticketnumber }}
|
||||
</p>
|
||||
<p v-if="selectedEvent.extendedProps?.linkurl">
|
||||
<a :href="selectedEvent.extendedProps.linkurl" target="_blank" class="btn btn-link">
|
||||
More Info
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<router-link
|
||||
v-if="selectedEvent.extendedProps?.notificationid"
|
||||
:to="`/notifications/${selectedEvent.extendedProps.notificationid}`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
View Notification
|
||||
</router-link>
|
||||
<button class="btn btn-primary" @click="closeEventModal">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import FullCalendar from '@fullcalendar/vue3'
|
||||
import dayGridPlugin from '@fullcalendar/daygrid'
|
||||
import { Trophy, User } from 'lucide-vue-next'
|
||||
import { notificationsApi } from '@/api'
|
||||
|
||||
const events = ref([])
|
||||
const selectedEvent = ref(null)
|
||||
const calendarRef = ref(null)
|
||||
const moreTooltip = ref(null)
|
||||
const moreTooltipData = ref([])
|
||||
const tooltipPosition = ref({ left: 0, top: 0 })
|
||||
|
||||
// Store events by date for hover lookup
|
||||
const eventsByDate = ref({})
|
||||
|
||||
const calendarOptions = ref({
|
||||
plugins: [dayGridPlugin],
|
||||
initialView: 'dayGridMonth',
|
||||
events: [],
|
||||
eventClick: (info) => {
|
||||
selectedEvent.value = info.event
|
||||
},
|
||||
headerToolbar: {
|
||||
left: 'prev,next today',
|
||||
center: 'title',
|
||||
right: 'dayGridMonth,dayGridWeek'
|
||||
},
|
||||
height: 'auto',
|
||||
dayMaxEvents: 3,
|
||||
moreLinkClick: () => 'none' // Disable click, we use hover
|
||||
})
|
||||
|
||||
function hideMoreTooltip() {
|
||||
moreTooltipData.value = []
|
||||
}
|
||||
|
||||
function handleMoreLinkHover(e) {
|
||||
const moreLink = e.target.closest('.fc-daygrid-more-link')
|
||||
if (!moreLink) {
|
||||
return
|
||||
}
|
||||
|
||||
// Find the day cell and get its date
|
||||
const dayCell = moreLink.closest('.fc-daygrid-day')
|
||||
if (!dayCell) return
|
||||
|
||||
const dateStr = dayCell.getAttribute('data-date')
|
||||
if (!dateStr || !eventsByDate.value[dateStr]) return
|
||||
|
||||
// Get events for this date that would be hidden (after first 3)
|
||||
const dayEvents = eventsByDate.value[dateStr]
|
||||
const hiddenEvents = dayEvents.slice(3).map(evt => ({
|
||||
title: evt.title,
|
||||
color: evt.backgroundColor || '#14abef',
|
||||
// Include full event data for modal
|
||||
start: evt.start,
|
||||
end: evt.end,
|
||||
extendedProps: evt.extendedProps || {}
|
||||
}))
|
||||
|
||||
if (hiddenEvents.length === 0) return
|
||||
|
||||
moreTooltipData.value = hiddenEvents
|
||||
|
||||
// Position tooltip
|
||||
const rect = moreLink.getBoundingClientRect()
|
||||
tooltipPosition.value = {
|
||||
left: rect.left,
|
||||
top: rect.bottom + 5
|
||||
}
|
||||
}
|
||||
|
||||
function handleMoreLinkLeave(e) {
|
||||
const related = e.relatedTarget
|
||||
// Don't hide if moving to the tooltip itself
|
||||
if (related && (related.closest('.fc-more-tooltip') || related.closest('.fc-daygrid-more-link'))) {
|
||||
return
|
||||
}
|
||||
hideMoreTooltip()
|
||||
}
|
||||
|
||||
function buildEventsByDate() {
|
||||
const byDate = {}
|
||||
for (const evt of events.value) {
|
||||
const dateStr = evt.start ? evt.start.split('T')[0] : null
|
||||
if (dateStr) {
|
||||
if (!byDate[dateStr]) byDate[dateStr] = []
|
||||
byDate[dateStr].push(evt)
|
||||
}
|
||||
}
|
||||
eventsByDate.value = byDate
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEvents()
|
||||
|
||||
// Add event delegation for more links
|
||||
await nextTick()
|
||||
const container = document.querySelector('.calendar-container')
|
||||
if (container) {
|
||||
container.addEventListener('mouseenter', handleMoreLinkHover, true)
|
||||
container.addEventListener('mouseleave', handleMoreLinkLeave, true)
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
const container = document.querySelector('.calendar-container')
|
||||
if (container) {
|
||||
container.removeEventListener('mouseenter', handleMoreLinkHover, true)
|
||||
container.removeEventListener('mouseleave', handleMoreLinkLeave, true)
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEvents()
|
||||
})
|
||||
|
||||
async function loadEvents() {
|
||||
try {
|
||||
const response = await notificationsApi.getCalendar()
|
||||
events.value = response.data.data
|
||||
calendarOptions.value.events = events.value
|
||||
buildEventsByDate()
|
||||
} catch (error) {
|
||||
console.error('Error loading calendar events:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function closeEventModal() {
|
||||
selectedEvent.value = null
|
||||
}
|
||||
|
||||
function openEventFromTooltip(evt) {
|
||||
// Create an event-like object that matches FullCalendar's event structure
|
||||
selectedEvent.value = {
|
||||
title: evt.title,
|
||||
start: evt.start,
|
||||
end: evt.end,
|
||||
extendedProps: evt.extendedProps
|
||||
}
|
||||
hideMoreTooltip()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||||
weekday: 'short',
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.calendar-container {
|
||||
min-height: 500px;
|
||||
}
|
||||
|
||||
.modal {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.modal h2 {
|
||||
margin: 0 0 1rem 0;
|
||||
font-size: 18px;
|
||||
color: var(--text);
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.event-details {
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.event-details p {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.event-details p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.event-details strong {
|
||||
color: var(--text-light);
|
||||
display: inline-block;
|
||||
min-width: 70px;
|
||||
}
|
||||
|
||||
.message-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.message-block strong {
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
display: block;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg);
|
||||
border-radius: 0.25rem;
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Recognition event styling */
|
||||
.recognition-header {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.recognition-badge {
|
||||
flex-shrink: 0;
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
background: linear-gradient(135deg, #ffd700 0%, #ffaa00 100%);
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4px 12px rgba(255, 215, 0, 0.3);
|
||||
}
|
||||
|
||||
.recognition-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.recognition-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.recognition-label {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.recognition-title {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: none !important;
|
||||
font-size: 16px !important;
|
||||
line-height: 1.4;
|
||||
color: var(--text) !important;
|
||||
}
|
||||
|
||||
.recognition-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: rgba(65, 129, 255, 0.1);
|
||||
border-radius: 0.25rem;
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.employee-icon {
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.employee-name {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
}
|
||||
</style>
|
||||
90
frontend/src/views/ChangePassword.vue
Normal file
90
frontend/src/views/ChangePassword.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<img :src="siteLogo" alt="Site logo" class="login-logo" />
|
||||
<h1>Change Password</h1>
|
||||
|
||||
<p v-if="forced" class="first-run-note">
|
||||
Your account uses a temporary password. Set a new one to continue.
|
||||
</p>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div v-if="!forced" class="form-group">
|
||||
<label for="currentpassword">Current Password</label>
|
||||
<input id="currentpassword" v-model="currentPassword" type="password"
|
||||
class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newpassword">New Password</label>
|
||||
<input id="newpassword" v-model="newPassword" type="password"
|
||||
class="form-control" required minlength="8" autofocus />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="confirmpassword">Confirm New Password</label>
|
||||
<input id="confirmpassword" v-model="confirmPassword" type="password"
|
||||
class="form-control" required minlength="8" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ loading ? 'Saving...' : 'Change password' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { authApi } from '../api'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
import { apiError } from '../utils/apiError'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const forced = computed(() => authStore.mustChangePassword)
|
||||
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
})
|
||||
|
||||
async function handleSubmit() {
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = 'New passwords do not match'
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
try {
|
||||
const payload = { new_password: newPassword.value }
|
||||
if (!forced.value) payload.current_password = currentPassword.value
|
||||
await authApi.changePassword(payload)
|
||||
authStore.clearMustChangePassword()
|
||||
success.value = 'Password changed.'
|
||||
// Land in the app now that the forced-change flag is cleared.
|
||||
setTimeout(() => router.push('/'), 600)
|
||||
} catch (event) {
|
||||
error.value = apiError(event, 'Failed to change password')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.first-run-note { color: var(--text-light); font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
129
frontend/src/views/Dashboard.vue
Normal file
129
frontend/src/views/Dashboard.vue
Normal file
@@ -0,0 +1,129 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Dashboard</h2>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Main Stats -->
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Machines</div>
|
||||
<div class="value">{{ stats.totalmachines || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="label">Active</div>
|
||||
<div class="value">{{ stats.activeassets || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="label">In Repair</div>
|
||||
<div class="value">{{ stats.inrepair || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="label">PCs</div>
|
||||
<div class="value">{{ stats.totalpc || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer Stats -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Printers</h3>
|
||||
<router-link to="/printers" class="btn btn-secondary btn-sm">View All</router-link>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-grid">
|
||||
<div class="stat-card">
|
||||
<div class="label">Total Printers</div>
|
||||
<div class="value">{{ printerStats.totalprinters || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card success">
|
||||
<div class="label">Online</div>
|
||||
<div class="value">{{ printerStats.online || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card warning">
|
||||
<div class="label">Low Supplies</div>
|
||||
<div class="value">{{ printerStats.lowsupplies || 0 }}</div>
|
||||
</div>
|
||||
<div class="stat-card danger">
|
||||
<div class="label">Critical</div>
|
||||
<div class="value">{{ printerStats.criticalsupplies || 0 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Devices -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Recent Devices</h3>
|
||||
<router-link to="/machines" class="btn btn-secondary btn-sm">View All</router-link>
|
||||
</div>
|
||||
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Type</th>
|
||||
<th>Status</th>
|
||||
<th>Business Unit</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="machine in recentMachines" :key="machine.assetid">
|
||||
<td>{{ machine.assetnumber || machine.name || '-' }}</td>
|
||||
<td>{{ machine.assettypename || '-' }}</td>
|
||||
<td>{{ machine.statusname || '-' }}</td>
|
||||
<td>{{ machine.businessunitname || '-' }}</td>
|
||||
</tr>
|
||||
<tr v-if="recentMachines.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No devices found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { dashboardApi, assetsApi, printersApi } from '../api'
|
||||
|
||||
const loading = ref(true)
|
||||
const stats = ref({})
|
||||
const printerStats = ref({})
|
||||
const recentMachines = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [dashRes, machinesRes, printersRes] = await Promise.all([
|
||||
dashboardApi.summary().catch(() => ({ data: { data: {} } })),
|
||||
assetsApi.list({ perpage: 5 }).catch(() => ({ data: { data: [] } })),
|
||||
printersApi.dashboardSummary().catch(() => ({ data: { data: {} } }))
|
||||
])
|
||||
|
||||
stats.value = dashRes.data.data || {}
|
||||
recentMachines.value = machinesRes.data.data || []
|
||||
printerStats.value = printersRes.data.data || {}
|
||||
} catch (error) {
|
||||
console.error('Dashboard load error:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
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>
|
||||
118
frontend/src/views/Login.vue
Normal file
118
frontend/src/views/Login.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<div class="login-container">
|
||||
<div class="login-box">
|
||||
<img :src="siteLogo" alt="Site logo" class="login-logo" />
|
||||
<h1>ShopDB</h1>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<!-- First run: no users yet - create the first admin -->
|
||||
<template v-if="mode === 'createadmin'">
|
||||
<p class="first-run-note">First run - create your admin account.</p>
|
||||
<form @submit.prevent="handleCreateAdmin">
|
||||
<div class="form-group">
|
||||
<label for="newusername">Username <span class="hint">(your SSO)</span></label>
|
||||
<input id="newusername" v-model="username" type="text" class="form-control" required autofocus />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email</label>
|
||||
<input id="email" v-model="email" type="email" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="newpassword">Password</label>
|
||||
<input id="newpassword" v-model="password" type="password" class="form-control" required minlength="8" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ loading ? 'Creating...' : 'Create admin & continue' }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<!-- Normal login -->
|
||||
<form v-else @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="username">Username</label>
|
||||
<input id="username" v-model="username" type="text" class="form-control" required autofocus />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Password</label>
|
||||
<input id="password" v-model="password" type="password" class="form-control" required />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" :disabled="loading">
|
||||
{{ loading ? 'Logging in...' : 'Login' }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { setupApi } from '../api'
|
||||
import { getSiteLogo } from '../utils/siteSettings'
|
||||
import { withBase } from '../utils/basePath'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
// Where to land after login: the guard-supplied ?redirect target, else the
|
||||
// dashboard. Only accept same-site absolute paths to avoid open redirects.
|
||||
function postLoginTarget() {
|
||||
const redirect = route.query.redirect
|
||||
if (typeof redirect === 'string' && redirect.startsWith('/') && !redirect.startsWith('//')) {
|
||||
return redirect
|
||||
}
|
||||
return '/'
|
||||
}
|
||||
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
const mode = ref('login')
|
||||
const username = ref('')
|
||||
const email = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
|
||||
// If the instance has no users yet, offer to create the first admin.
|
||||
try {
|
||||
const response = await setupApi.needsAdmin()
|
||||
if (response.data?.data?.needsadmin) mode.value = 'createadmin'
|
||||
} catch (err) { /* fall back to normal login */ }
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
const result = await authStore.login(username.value, password.value)
|
||||
loading.value = false
|
||||
if (result.success) router.push(postLoginTarget())
|
||||
else error.value = result.message
|
||||
}
|
||||
|
||||
async function handleCreateAdmin() {
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
try {
|
||||
await setupApi.createAdmin({ username: username.value, email: email.value, password: password.value })
|
||||
// Log straight in with the new credentials, then on to the setup wizard.
|
||||
const result = await authStore.login(username.value, password.value)
|
||||
if (result.success) router.push('/setup')
|
||||
else { mode.value = 'login'; error.value = 'Admin created - please sign in.' }
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.data?.error?.message || err.response?.data?.message || 'Could not create admin'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.first-run-note { color: var(--text-light); font-size: 0.9rem; margin-bottom: 1rem; }
|
||||
.hint { color: var(--text-light); font-weight: normal; font-size: 0.85em; }
|
||||
</style>
|
||||
412
frontend/src/views/MapEditor.vue
Normal file
412
frontend/src/views/MapEditor.vue
Normal file
@@ -0,0 +1,412 @@
|
||||
<template>
|
||||
<div class="map-editor">
|
||||
<div class="page-header">
|
||||
<h2>Map Editor</h2>
|
||||
<div class="header-actions">
|
||||
<router-link to="/map" class="btn btn-secondary">Back to Map</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-layout">
|
||||
<!-- Asset List Panel -->
|
||||
<div class="asset-panel">
|
||||
<div class="panel-header">
|
||||
<h3>Assets</h3>
|
||||
<select v-model="filterType" class="form-control">
|
||||
<option value="">All Types</option>
|
||||
<option value="machine">Machines</option>
|
||||
<option value="computer">Computers</option>
|
||||
<option value="printer">Printers</option>
|
||||
<option value="network_device">Network Devices</option>
|
||||
<option value="measuring_tool">Measuring Tools</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="asset-filter">
|
||||
<label class="filter-checkbox">
|
||||
<input type="checkbox" v-model="showUnplacedOnly" />
|
||||
Show unplaced only
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="asset-list">
|
||||
<div
|
||||
v-for="asset in filteredAssets"
|
||||
:key="asset.assetid"
|
||||
class="asset-item"
|
||||
:class="{
|
||||
selected: selectedAsset?.assetid === asset.assetid,
|
||||
placed: asset.mapx && asset.mapy,
|
||||
unplaced: !asset.mapx || !asset.mapy
|
||||
}"
|
||||
@click="selectAsset(asset)"
|
||||
>
|
||||
<span class="asset-icon"><component :is="getTypeIcon(asset.assettype)" :size="16" /></span>
|
||||
<div class="asset-info">
|
||||
<div class="asset-name">{{ asset.name || asset.assetnumber }}</div>
|
||||
<div class="asset-meta">
|
||||
<span class="badge badge-sm">{{ asset.assettype }}</span>
|
||||
<span v-if="asset.mapx && asset.mapy" class="placed-indicator" title="Placed on map"><MapPin :size="12" /></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredAssets.length === 0" class="empty">
|
||||
No assets found.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Panel -->
|
||||
<div class="map-panel">
|
||||
<div class="map-toolbar" v-if="selectedAsset">
|
||||
<span class="selected-info">
|
||||
<strong>Selected:</strong> {{ selectedAsset.name || selectedAsset.assetnumber }}
|
||||
</span>
|
||||
<span class="position-info" v-if="pickedPosition">
|
||||
Position: ({{ Math.round(pickedPosition.left) }}, {{ Math.round(pickedPosition.top) }})
|
||||
</span>
|
||||
<div class="toolbar-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!pickedPosition"
|
||||
@click="savePosition"
|
||||
>
|
||||
Save Position
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-danger"
|
||||
v-if="selectedAsset.mapx && selectedAsset.mapy"
|
||||
@click="clearPosition"
|
||||
>
|
||||
Remove from Map
|
||||
</button>
|
||||
<button class="btn btn-secondary" @click="cancelEdit">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="map-toolbar" v-else>
|
||||
<span class="instruction">Select an asset from the list to place it on the map</span>
|
||||
</div>
|
||||
|
||||
<ShopFloorMap
|
||||
ref="mapRef"
|
||||
:machines="placedAssets"
|
||||
:assetTypeMode="true"
|
||||
:theme="currentTheme"
|
||||
:pickerMode="!!selectedAsset"
|
||||
:initialPosition="selectedAsset ? { left: selectedAsset.mapx, top: selectedAsset.mapy } : null"
|
||||
@positionPicked="handlePositionPicked"
|
||||
@markerClick="handleMarkerClick"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { Cog, Monitor, Printer, Globe, Ruler, Package, MapPin } from 'lucide-vue-next'
|
||||
import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||
import { assetsApi } from '../api'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
import { useToast } from '../composables/toast'
|
||||
const toast = useToast()
|
||||
|
||||
const assets = ref([])
|
||||
const loading = ref(true)
|
||||
const selectedAsset = ref(null)
|
||||
const pickedPosition = ref(null)
|
||||
const filterType = ref('')
|
||||
const showUnplacedOnly = ref(false)
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
let result = assets.value
|
||||
|
||||
if (filterType.value) {
|
||||
result = result.filter(a => a.assettype === filterType.value)
|
||||
}
|
||||
|
||||
if (showUnplacedOnly.value) {
|
||||
result = result.filter(a => !a.mapx || !a.mapy)
|
||||
}
|
||||
|
||||
return result
|
||||
})
|
||||
|
||||
const placedAssets = computed(() => {
|
||||
return assets.value.filter(a => a.mapx && a.mapy)
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadAssets()
|
||||
})
|
||||
|
||||
async function loadAssets() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await assetsApi.getMap()
|
||||
assets.value = response.data.data?.assets || []
|
||||
} catch (error) {
|
||||
console.error('Failed to load assets:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeIcon(assettype) {
|
||||
const icons = {
|
||||
'machine': Cog,
|
||||
'computer': Monitor,
|
||||
'printer': Printer,
|
||||
'network_device': Globe,
|
||||
'measuring_tool': Ruler
|
||||
}
|
||||
return icons[assettype] || Package
|
||||
}
|
||||
|
||||
function selectAsset(asset) {
|
||||
selectedAsset.value = asset
|
||||
pickedPosition.value = asset.mapx && asset.mapy
|
||||
? { left: asset.mapx, top: asset.mapy }
|
||||
: null
|
||||
}
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
pickedPosition.value = position
|
||||
}
|
||||
|
||||
function handleMarkerClick(asset) {
|
||||
selectAsset(asset)
|
||||
}
|
||||
|
||||
async function savePosition() {
|
||||
if (!selectedAsset.value || !pickedPosition.value) return
|
||||
|
||||
try {
|
||||
await assetsApi.update(selectedAsset.value.assetid, {
|
||||
mapx: Math.round(pickedPosition.value.left),
|
||||
mapy: Math.round(pickedPosition.value.top)
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
||||
if (asset) {
|
||||
asset.mapx = Math.round(pickedPosition.value.left)
|
||||
asset.mapy = Math.round(pickedPosition.value.top)
|
||||
}
|
||||
|
||||
selectedAsset.value = null
|
||||
pickedPosition.value = null
|
||||
} catch (error) {
|
||||
console.error('Failed to save position:', error)
|
||||
toast.error('Failed to save position')
|
||||
}
|
||||
}
|
||||
|
||||
async function clearPosition() {
|
||||
if (!selectedAsset.value) return
|
||||
|
||||
if (!confirm(`Remove ${selectedAsset.value.name || selectedAsset.value.assetnumber} from the map?`)) return
|
||||
|
||||
try {
|
||||
await assetsApi.update(selectedAsset.value.assetid, {
|
||||
mapx: null,
|
||||
mapy: null
|
||||
})
|
||||
|
||||
// Update local state
|
||||
const asset = assets.value.find(a => a.assetid === selectedAsset.value.assetid)
|
||||
if (asset) {
|
||||
asset.mapx = null
|
||||
asset.mapy = null
|
||||
}
|
||||
|
||||
selectedAsset.value = null
|
||||
pickedPosition.value = null
|
||||
} catch (error) {
|
||||
console.error('Failed to clear position:', error)
|
||||
toast.error('Failed to clear position')
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
selectedAsset.value = null
|
||||
pickedPosition.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 40px);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.editor-layout {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
gap: 1rem;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.asset-panel {
|
||||
width: 320px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.panel-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.panel-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.panel-header select {
|
||||
width: auto;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.asset-filter {
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.filter-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.asset-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.asset-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.asset-item:hover {
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.asset-item.selected {
|
||||
background: rgba(65, 129, 255, 0.2);
|
||||
border: 1px solid var(--primary);
|
||||
}
|
||||
|
||||
.asset-item.unplaced {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.asset-icon {
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.asset-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.asset-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
font-size: 0.7rem;
|
||||
padding: 0.15rem 0.4rem;
|
||||
}
|
||||
|
||||
.placed-indicator {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.map-panel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.map-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.selected-info {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.position-info {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-light);
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.toolbar-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.instruction {
|
||||
color: var(--text-light);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.map-panel :deep(.shopfloor-map) {
|
||||
flex: 1;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
390
frontend/src/views/MapView.vue
Normal file
390
frontend/src/views/MapView.vue
Normal file
@@ -0,0 +1,390 @@
|
||||
<template>
|
||||
<div class="map-page">
|
||||
<div class="page-header">
|
||||
<h2>Shop Floor Map</h2>
|
||||
<router-link v-if="authStore.isAuthenticated" to="/map/editor" class="btn btn-primary">
|
||||
Edit Map
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Filter Controls -->
|
||||
<div class="map-filters">
|
||||
<select v-model="selectedType" @change="onTypeChange">
|
||||
<option value="">All Asset Types</option>
|
||||
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettype">
|
||||
{{ formatTypeName(t.assettype) }} ({{ getTypeCount(t.assettype) }})
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="selectedSubtype" @change="updateMapLayers" :disabled="!selectedType || !currentSubtypes.length">
|
||||
<option value="">{{ subtypeLabel }}</option>
|
||||
<option v-for="st in currentSubtypes" :key="st.id" :value="st.id">
|
||||
{{ st.name }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="selectedBusinessUnit" @change="updateMapLayers">
|
||||
<option value="">All Business Units</option>
|
||||
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<select v-model="selectedStatus" @change="updateMapLayers">
|
||||
<option value="">All Statuses</option>
|
||||
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
v-model="searchQuery"
|
||||
placeholder="Search assets..."
|
||||
@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>
|
||||
|
||||
<ShopFloorMap
|
||||
:machines="filteredAssets"
|
||||
:machinetypes="[]"
|
||||
:businessunits="businessunits"
|
||||
:statuses="statuses"
|
||||
:assetTypeMode="true"
|
||||
:theme="currentTheme"
|
||||
:selectedAssetType="selectedType"
|
||||
:subtypeColors="subtypeColorMap"
|
||||
:subtypeNames="subtypeNameMap"
|
||||
@markerClick="handleMarkerClick"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
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'
|
||||
import { useToast } from '../composables/toast'
|
||||
const toast = useToast()
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
const loading = ref(true)
|
||||
const assets = ref([])
|
||||
const assetTypes = ref([])
|
||||
const businessunits = ref([])
|
||||
const statuses = ref([])
|
||||
const subtypes = ref({})
|
||||
|
||||
// Filter state
|
||||
const selectedType = ref('')
|
||||
const selectedSubtype = ref('')
|
||||
const selectedBusinessUnit = ref('')
|
||||
const selectedStatus = ref('')
|
||||
const searchQuery = ref('')
|
||||
const exporting = ref(false)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// 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]
|
||||
const norm = typeName.toLowerCase().replace(/_/g, ' ')
|
||||
for (const [key, value] of Object.entries(subtypes.value)) {
|
||||
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
const currentSubtypes = computed(() => {
|
||||
if (!selectedType.value) return []
|
||||
return getSubtypesForType(selectedType.value)
|
||||
})
|
||||
|
||||
const subtypeLabel = computed(() => {
|
||||
if (!selectedType.value) return 'Select type first'
|
||||
if (!currentSubtypes.value.length) return 'No subtypes'
|
||||
const labels = {
|
||||
'machine': 'All Machine Types',
|
||||
'computer': 'All Computer Types',
|
||||
'network device': 'All Device Types',
|
||||
'printer': 'All Printer Types'
|
||||
}
|
||||
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
|
||||
})
|
||||
|
||||
// Generate distinct colors for subtypes
|
||||
const subtypeColorPalette = [
|
||||
'#F44336', '#E91E63', '#9C27B0', '#673AB7', '#3F51B5',
|
||||
'#2196F3', '#03A9F4', '#00BCD4', '#009688', '#4CAF50',
|
||||
'#8BC34A', '#CDDC39', '#FFEB3B', '#FFC107', '#FF9800',
|
||||
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
|
||||
]
|
||||
|
||||
// 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] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
|
||||
})
|
||||
return colorMap
|
||||
})
|
||||
|
||||
// Map subtype IDs to names
|
||||
const subtypeNameMap = computed(() => {
|
||||
const nameMap = {}
|
||||
currentSubtypes.value.forEach(st => {
|
||||
nameMap[st.id] = st.name
|
||||
})
|
||||
return nameMap
|
||||
})
|
||||
|
||||
const filteredAssets = computed(() => {
|
||||
let result = assets.value
|
||||
|
||||
// Filter by asset type (case-insensitive)
|
||||
if (selectedType.value) {
|
||||
const selectedLower = selectedType.value.toLowerCase()
|
||||
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
|
||||
}
|
||||
|
||||
// Filter by subtype (normalize network_device -> network device)
|
||||
if (selectedSubtype.value) {
|
||||
const subtypeId = parseInt(selectedSubtype.value)
|
||||
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
|
||||
result = result.filter(a => {
|
||||
if (!a.typedata) return false
|
||||
// Check different ID fields based on asset type
|
||||
if (typeLower === 'machine') {
|
||||
return a.typedata.machinetypeid === subtypeId
|
||||
} else if (typeLower === 'computer') {
|
||||
return a.typedata.computertypeid === subtypeId
|
||||
} else if (typeLower === 'network device') {
|
||||
return a.typedata.networkdevicetypeid === subtypeId
|
||||
} else if (typeLower === 'printer') {
|
||||
return a.typedata.printertypeid === subtypeId
|
||||
}
|
||||
return false
|
||||
})
|
||||
}
|
||||
|
||||
// Filter by business unit
|
||||
if (selectedBusinessUnit.value) {
|
||||
result = result.filter(a => a.businessunitid === parseInt(selectedBusinessUnit.value))
|
||||
}
|
||||
|
||||
// Filter by status
|
||||
if (selectedStatus.value) {
|
||||
result = result.filter(a => a.statusid === parseInt(selectedStatus.value))
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
if (searchQuery.value) {
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
result = result.filter(a =>
|
||||
(a.assetnumber && a.assetnumber.toLowerCase().includes(q)) ||
|
||||
(a.name && a.name.toLowerCase().includes(q)) ||
|
||||
(a.displayname && a.displayname.toLowerCase().includes(q)) ||
|
||||
(a.serialnumber && a.serialnumber.toLowerCase().includes(q))
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
toast.error('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 || {}
|
||||
|
||||
assets.value = data.assets || []
|
||||
assetTypes.value = data.filters?.assettypes || []
|
||||
businessunits.value = data.filters?.businessunits || []
|
||||
statuses.value = data.filters?.statuses || []
|
||||
subtypes.value = data.filters?.subtypes || {}
|
||||
} catch (error) {
|
||||
console.error('Failed to load map data:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatTypeName(assettype) {
|
||||
return assetTypeLabel(assettype)
|
||||
}
|
||||
|
||||
function getTypeCount(assettype) {
|
||||
if (!assettype) return 0
|
||||
const lowerType = assettype.toLowerCase()
|
||||
return assets.value.filter(a => a.assettype && a.assettype.toLowerCase() === lowerType).length
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
// Reset subtype when type changes
|
||||
selectedSubtype.value = ''
|
||||
updateMapLayers()
|
||||
}
|
||||
|
||||
function updateMapLayers() {
|
||||
// Filter is reactive via computed property
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
updateMapLayers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleMarkerClick(asset) {
|
||||
router.push(assetDetailRoute(asset))
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: calc(100vh - 2rem);
|
||||
}
|
||||
|
||||
.map-page .page-header {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.map-filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.map-filters select,
|
||||
.map-filters input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.map-filters select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.map-filters select:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.map-filters input {
|
||||
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;
|
||||
}
|
||||
|
||||
.map-page :deep(.shopfloor-map) {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
479
frontend/src/views/SearchResults.vue
Normal file
479
frontend/src/views/SearchResults.vue
Normal file
@@ -0,0 +1,479 @@
|
||||
<template>
|
||||
<div class="search-results">
|
||||
<div class="page-header">
|
||||
<h2>Search Results</h2>
|
||||
<span v-if="results.length" class="results-count">
|
||||
{{ totalAll || results.length }} result{{ (totalAll || results.length) !== 1 ? 's' : '' }} for "{{ query }}"
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="search-box">
|
||||
<input
|
||||
v-model="searchInput"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search machines, applications, knowledge base, IPs, hostnames..."
|
||||
@keyup.enter="performSearch"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="performSearch">Search</button>
|
||||
</div>
|
||||
|
||||
<div v-if="results.length" class="filter-buttons">
|
||||
<button
|
||||
v-for="filter in filterList"
|
||||
:key="filter.key"
|
||||
class="filter-btn"
|
||||
:class="{ active: activeFilter === filter.key }"
|
||||
@click="activeFilter = filter.key"
|
||||
>
|
||||
{{ filter.label }}
|
||||
<span class="filter-count">{{ getFilterCount(filter.key) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Searching...</div>
|
||||
|
||||
<template v-else-if="query">
|
||||
<div v-if="filteredResults.length === 0 && results.length > 0" class="no-results">
|
||||
No {{ activeFilter }} results for "{{ query }}"
|
||||
<button class="btn btn-secondary" style="margin-top: 0.5rem;" @click="activeFilter = 'all'">Show all results</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="results.length === 0" class="no-results">
|
||||
No results found for "{{ query }}"
|
||||
</div>
|
||||
|
||||
<div v-else class="results-list">
|
||||
<div
|
||||
v-for="result in filteredResults"
|
||||
:key="`${result.type}-${result.id}`"
|
||||
:id="`result-${result.type}-${result.id}`"
|
||||
class="result-item"
|
||||
:class="{ highlighted: highlightId === `${result.type}-${result.id}` }"
|
||||
>
|
||||
<span class="result-type" :class="result.type">{{ typeLabel(result.type) }}</span>
|
||||
<div class="result-content">
|
||||
<router-link v-if="result.type !== 'knowledgebase'" :to="result.url" class="result-title">
|
||||
{{ result.title }}
|
||||
</router-link>
|
||||
<a
|
||||
v-else
|
||||
href="#"
|
||||
class="result-title"
|
||||
@click.prevent="openKBArticle(result)"
|
||||
>
|
||||
{{ result.title }}
|
||||
</a>
|
||||
<div class="result-meta">
|
||||
<span v-if="result.subtitle" class="result-subtitle">{{ result.subtitle }}</span>
|
||||
<span v-if="result.location" class="result-location">{{ result.location }}</span>
|
||||
<span v-if="result.ticketnumber" class="result-ticket">{{ result.ticketnumber }}</span>
|
||||
<span v-if="result.iscurrent" class="badge badge-success">Active</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="share-btn"
|
||||
@click="shareResult(result)"
|
||||
:title="copiedId === `${result.type}-${result.id}` ? 'Copied!' : 'Copy link'"
|
||||
>
|
||||
{{ copiedId === `${result.type}-${result.id}` ? 'Copied' : 'Share' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="no-results">
|
||||
Enter a search term to find machines, applications, printers, knowledge base articles, IPs, and more.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { searchApi, knowledgebaseApi } from '../api'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const loading = ref(false)
|
||||
const results = ref([])
|
||||
const query = ref('')
|
||||
const searchInput = ref('')
|
||||
const activeFilter = ref('all')
|
||||
const typeCounts = ref({})
|
||||
const totalAll = ref(0)
|
||||
const highlightId = ref(null)
|
||||
const copiedId = ref(null)
|
||||
|
||||
const typeLabels = {
|
||||
machine: 'Machine',
|
||||
pc: 'PC',
|
||||
computer: 'PC',
|
||||
application: 'App',
|
||||
knowledgebase: 'KB',
|
||||
printer: 'Printer',
|
||||
network_device: 'Network',
|
||||
measuring_tool: 'Measuring Tool',
|
||||
employee: 'Employee',
|
||||
notification: 'Notice',
|
||||
subnet: 'Subnet'
|
||||
}
|
||||
|
||||
const filterTypeMap = {
|
||||
all: null,
|
||||
machines: ['machine'],
|
||||
computers: ['computer'],
|
||||
printers: ['printer'],
|
||||
network: ['network_device', 'subnet'],
|
||||
measuringtools: ['measuring_tool'],
|
||||
applications: ['application'],
|
||||
knowledgebase: ['knowledgebase'],
|
||||
notifications: ['notification'],
|
||||
employees: ['employee']
|
||||
}
|
||||
|
||||
const filterList = [
|
||||
{ key: 'all', label: 'All' },
|
||||
{ key: 'machines', label: 'Machines' },
|
||||
{ key: 'computers', label: 'PCs' },
|
||||
{ key: 'printers', label: 'Printers' },
|
||||
{ key: 'network', label: 'Network' },
|
||||
{ key: 'measuringtools', label: 'Measuring Tools' },
|
||||
{ key: 'applications', label: 'Apps' },
|
||||
{ key: 'knowledgebase', label: 'KB' },
|
||||
{ key: 'notifications', label: 'Notices' },
|
||||
{ key: 'employees', label: 'Employees' }
|
||||
]
|
||||
|
||||
function typeLabel(type) {
|
||||
return typeLabels[type] || type
|
||||
}
|
||||
|
||||
function getFilterCount(filterKey) {
|
||||
if (filterKey === 'all') return totalAll.value || results.value.length
|
||||
const types = filterTypeMap[filterKey]
|
||||
if (!types) return 0
|
||||
return types.reduce((sum, t) => sum + (typeCounts.value[t] || 0), 0)
|
||||
}
|
||||
|
||||
const filteredResults = computed(() => {
|
||||
if (activeFilter.value === 'all') return results.value
|
||||
const types = filterTypeMap[activeFilter.value]
|
||||
if (!types) return results.value
|
||||
return results.value.filter(r => types.includes(r.type))
|
||||
})
|
||||
|
||||
async function search(q) {
|
||||
if (!q || q.length < 2) {
|
||||
results.value = []
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
activeFilter.value = 'all'
|
||||
|
||||
try {
|
||||
const response = await searchApi.search(q)
|
||||
const data = response.data.data
|
||||
|
||||
// Handle ServiceNOW redirect
|
||||
if (data?.redirect?.type === 'servicenow') {
|
||||
window.open(data.redirect.url, '_blank')
|
||||
query.value = q
|
||||
results.value = []
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// Smart redirect - auto-navigate to exact match
|
||||
if (data?.redirect) {
|
||||
router.replace(data.redirect.url)
|
||||
return
|
||||
}
|
||||
|
||||
results.value = data?.results || []
|
||||
typeCounts.value = data?.counts || {}
|
||||
totalAll.value = data?.total_all || results.value.length
|
||||
query.value = q
|
||||
} catch (error) {
|
||||
console.error('Search error:', error)
|
||||
results.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function performSearch() {
|
||||
if (searchInput.value.trim()) {
|
||||
router.push({ path: '/search', query: { q: searchInput.value.trim() } })
|
||||
}
|
||||
}
|
||||
|
||||
async function openKBArticle(result) {
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(result.id)
|
||||
if (result.linkurl) {
|
||||
window.open(result.linkurl, '_blank')
|
||||
} else {
|
||||
router.push(result.url)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
if (result.linkurl) {
|
||||
window.open(result.linkurl, '_blank')
|
||||
} else {
|
||||
router.push(result.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function shareResult(result) {
|
||||
const url = new URL(window.location.href)
|
||||
url.searchParams.set('highlight', `${result.type}-${result.id}`)
|
||||
navigator.clipboard.writeText(url.toString()).then(() => {
|
||||
copiedId.value = `${result.type}-${result.id}`
|
||||
setTimeout(() => { copiedId.value = null }, 2000)
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToHighlight() {
|
||||
if (highlightId.value) {
|
||||
nextTick(() => {
|
||||
const el = document.getElementById(`result-${highlightId.value}`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
setTimeout(() => { highlightId.value = null }, 3000)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const q = route.query.q
|
||||
const hl = route.query.highlight
|
||||
if (hl) highlightId.value = hl
|
||||
if (q) {
|
||||
searchInput.value = q
|
||||
search(q)
|
||||
}
|
||||
})
|
||||
|
||||
watch(() => route.query.q, (newQ) => {
|
||||
if (newQ) {
|
||||
searchInput.value = newQ
|
||||
const hl = route.query.highlight
|
||||
if (hl) highlightId.value = hl
|
||||
search(newQ)
|
||||
}
|
||||
})
|
||||
|
||||
watch(results, () => {
|
||||
scrollToHighlight()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.page-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.page-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.results-count {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.search-box {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.search-box input {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-buttons {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
padding: 0.3rem 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.filter-btn:hover:not(.active) {
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
background: rgba(128, 128, 128, 0.15);
|
||||
padding: 0.1rem 0.35rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.75rem;
|
||||
min-width: 1.25rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.filter-btn.active .filter-count {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
.no-results {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.results-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.result-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.3s ease;
|
||||
}
|
||||
|
||||
.result-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.result-item.highlighted {
|
||||
background: rgba(65, 129, 255, 0.08);
|
||||
border-left: 3px solid var(--primary);
|
||||
}
|
||||
|
||||
.result-type {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
min-width: 65px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Per-domain badge palette. Values live in CSS variables on the container so
|
||||
the dark theme overrides them in one place (below) instead of restating
|
||||
every selector. Each badge rule just references its pair. */
|
||||
.search-results {
|
||||
--rt-machine-bg: #e3f2fd; --rt-machine-fg: #1565c0;
|
||||
--rt-computer-bg: #e8f5e9; --rt-computer-fg: #2e7d32;
|
||||
--rt-application-bg: #fff3e0; --rt-application-fg: #e65100;
|
||||
--rt-knowledgebase-bg: #f3e5f5; --rt-knowledgebase-fg: #7b1fa2;
|
||||
--rt-printer-bg: #fce4ec; --rt-printer-fg: #c2185b;
|
||||
--rt-network-bg: #fff8e1; --rt-network-fg: #f57f17;
|
||||
--rt-measuring-bg: #e0f7fa; --rt-measuring-fg: #00838f;
|
||||
--rt-employee-bg: #e0f2f1; --rt-employee-fg: #00695c;
|
||||
--rt-notification-bg: #e8eaf6; --rt-notification-fg: #283593;
|
||||
--rt-subnet-bg: #fbe9e7; --rt-subnet-fg: #bf360c;
|
||||
}
|
||||
|
||||
.result-type.machine { background: var(--rt-machine-bg); color: var(--rt-machine-fg); }
|
||||
.result-type.pc,
|
||||
.result-type.computer { background: var(--rt-computer-bg); color: var(--rt-computer-fg); }
|
||||
.result-type.application { background: var(--rt-application-bg); color: var(--rt-application-fg); }
|
||||
.result-type.knowledgebase { background: var(--rt-knowledgebase-bg); color: var(--rt-knowledgebase-fg); }
|
||||
.result-type.printer { background: var(--rt-printer-bg); color: var(--rt-printer-fg); }
|
||||
.result-type.network_device { background: var(--rt-network-bg); color: var(--rt-network-fg); }
|
||||
.result-type.measuring_tool { background: var(--rt-measuring-bg); color: var(--rt-measuring-fg); }
|
||||
.result-type.employee { background: var(--rt-employee-bg); color: var(--rt-employee-fg); }
|
||||
.result-type.notification { background: var(--rt-notification-bg); color: var(--rt-notification-fg); }
|
||||
.result-type.subnet { background: var(--rt-subnet-bg); color: var(--rt-subnet-fg); }
|
||||
|
||||
.result-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.result-title {
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.result-title:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.result-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.result-subtitle {
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.result-ticket {
|
||||
font-family: monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.share-btn {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
font-size: 0.7rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.share-btn:hover {
|
||||
color: var(--primary);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.search-results {
|
||||
--rt-machine-bg: rgba(21, 101, 192, 0.2); --rt-machine-fg: #64b5f6;
|
||||
--rt-computer-bg: rgba(46, 125, 50, 0.2); --rt-computer-fg: #81c784;
|
||||
--rt-application-bg: rgba(230, 81, 0, 0.2); --rt-application-fg: #ffb74d;
|
||||
--rt-knowledgebase-bg: rgba(123, 31, 162, 0.2); --rt-knowledgebase-fg: #ce93d8;
|
||||
--rt-printer-bg: rgba(194, 24, 91, 0.2); --rt-printer-fg: #f48fb1;
|
||||
--rt-network-bg: rgba(245, 127, 23, 0.2); --rt-network-fg: #ffd54f;
|
||||
--rt-measuring-bg: rgba(0, 131, 143, 0.2); --rt-measuring-fg: #80deea;
|
||||
--rt-employee-bg: rgba(0, 105, 92, 0.2); --rt-employee-fg: #80cbc4;
|
||||
--rt-notification-bg: rgba(40, 53, 147, 0.2); --rt-notification-fg: #9fa8da;
|
||||
--rt-subnet-bg: rgba(191, 54, 12, 0.2); --rt-subnet-fg: #ffab91;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
458
frontend/src/views/SetupWizard.vue
Normal file
458
frontend/src/views/SetupWizard.vue
Normal file
@@ -0,0 +1,458 @@
|
||||
<template>
|
||||
<div class="setup-wizard">
|
||||
<div class="wizard-card">
|
||||
<header class="wizard-head">
|
||||
<h1>Set up ShopDB</h1>
|
||||
<p class="wizard-sub">A few quick steps to get this site ready.</p>
|
||||
<ol class="wizard-steps">
|
||||
<li v-for="(s, i) in steps" :key="s.key" :class="{ active: i === step, done: i < step }">
|
||||
<span class="step-num">{{ i < step ? '✓' : i + 1 }}</span>
|
||||
<span class="step-label">{{ s.label }}</span>
|
||||
</li>
|
||||
</ol>
|
||||
</header>
|
||||
|
||||
<section class="wizard-body">
|
||||
<!-- Site -->
|
||||
<div v-if="current.key === 'site'">
|
||||
<h2>Site details</h2>
|
||||
<p class="hint">Used for the dashboard header and QR / absolute links.</p>
|
||||
<div class="form-group">
|
||||
<label>Facility name</label>
|
||||
<input v-model="form.facility_name" type="text" class="form-control" placeholder="West Jefferson" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Site base URL <span class="hint">(blank = use the browsing origin)</span></label>
|
||||
<input v-model="form.site_base_url" type="url" class="form-control" placeholder="https://shopdb.example.net" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Site logo <span class="hint">(optional)</span></label>
|
||||
<input type="file" accept="image/*" @change="uploadSiteLogo($event)" :disabled="logoUploading" />
|
||||
<img v-if="siteLogo" :src="siteLogo" class="wizard-map-thumb" alt="site logo" />
|
||||
<small class="hint">Skip to keep the shipped GE branding. You can change this later under Settings > Branding.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plugins -->
|
||||
<div v-else-if="current.key === 'plugins'">
|
||||
<h2>Features</h2>
|
||||
<p class="hint">Turn features on or off. Changes to routes take effect after the app restarts.</p>
|
||||
<div v-if="plugins.length" class="plugin-list">
|
||||
<div v-for="p in plugins" :key="p.name" class="plugin-item">
|
||||
<label class="plugin-row">
|
||||
<input type="checkbox" :checked="p.enabled" @change="togglePlugin(p, $event.target.checked)" />
|
||||
<span class="plugin-name">{{ p.displayname || p.name }}</span>
|
||||
<span class="plugin-desc">{{ p.description }}</span>
|
||||
</label>
|
||||
|
||||
<!-- Plugins that provision tables: choose to create them here or
|
||||
connect an existing database -->
|
||||
<div v-if="p.enabled && p.provisioning_note && p.provisioning_note.mode_setting" class="plugin-mode">
|
||||
<label class="mode-opt">
|
||||
<input type="radio" value="selfhosted" v-model="pluginModes[p.provisioning_note.mode_setting]" />
|
||||
Create the tables here <span class="hint">(recommended)</span>
|
||||
</label>
|
||||
<label class="mode-opt">
|
||||
<input type="radio" value="external" v-model="pluginModes[p.provisioning_note.mode_setting]" />
|
||||
Connect to your own database
|
||||
</label>
|
||||
|
||||
<!-- Self-hosted: transparency note -->
|
||||
<div v-if="pluginModes[p.provisioning_note.mode_setting] !== 'external'" class="provision-note">
|
||||
<p>{{ p.provisioning_note.note }}</p>
|
||||
<p class="provision-tables">Creates in shopdb: <code v-for="t in p.provisioning_note.tables" :key="t">{{ t }}</code></p>
|
||||
<p v-if="p.provisioning_note.docs" class="provision-docs">Schema: <code>{{ p.provisioning_note.docs }}</code></p>
|
||||
</div>
|
||||
|
||||
<!-- External: connection config -->
|
||||
<div v-else class="config-block">
|
||||
<div v-for="field in p.config_schema" :key="field.key" class="form-group">
|
||||
<label>{{ field.label }}<span v-if="field.secret" class="secret-tag">secret</span></label>
|
||||
<input
|
||||
:type="field.type === 'password' ? 'password' : (field.type === 'number' ? 'number' : 'text')"
|
||||
:placeholder="field.default || ''"
|
||||
v-model="pluginConfig[field.key]"
|
||||
class="form-control" />
|
||||
<small v-if="field.help" class="hint">{{ field.help }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="hint">No optional plugins found.</p>
|
||||
|
||||
<!-- Secrets are never stored in the DB; emit .env lines to paste -->
|
||||
<div v-if="envLines" class="env-block">
|
||||
<p class="hint">Paste these into <code>.env</code> and restart the app (secrets are not stored in the database):</p>
|
||||
<pre class="env-pre">{{ envLines }}</pre>
|
||||
<button type="button" class="btn btn-secondary btn-sm" @click="copyEnv">Copy</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Floor map -->
|
||||
<div v-else-if="current.key === 'map'">
|
||||
<h2>Floor map</h2>
|
||||
<p class="hint">Upload your facility blueprint (light and dark), and set its native pixel size.</p>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (light theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('light', $event)" :disabled="mapUploading" />
|
||||
<img v-if="blueprintLight" :src="blueprintLight" class="wizard-map-thumb" alt="light blueprint" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Blueprint image (dark theme)</label>
|
||||
<input type="file" accept="image/*" @change="uploadBlueprint('dark', $event)" :disabled="mapUploading" />
|
||||
<img v-if="blueprintDark" :src="blueprintDark" class="wizard-map-thumb dark" alt="dark blueprint" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Map width (px)</label>
|
||||
<input v-model.number="form.map_width" type="number" class="form-control" placeholder="2000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Map height (px)</label>
|
||||
<input v-model.number="form.map_height" type="number" class="form-control" placeholder="1200" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Starter data -->
|
||||
<div v-else-if="current.key === 'data'">
|
||||
<h2>Starter data</h2>
|
||||
<p class="hint">Seed the data a new site needs. Both are idempotent - safe to run more than once.</p>
|
||||
<div class="starter-actions">
|
||||
<div>
|
||||
<button class="btn btn-secondary" :disabled="seeding" @click="seedReference">
|
||||
{{ seeding ? 'Working...' : 'Seed core reference data' }}
|
||||
</button>
|
||||
<p class="hint">Statuses, machine/location/relationship types, permissions, default settings. Run this if you installed without the CLI seed.</p>
|
||||
</div>
|
||||
<div>
|
||||
<button class="btn btn-secondary" :disabled="seeding" @click="seedStarter">
|
||||
{{ seeding ? 'Working...' : 'Add common vendors' }}
|
||||
</button>
|
||||
<p class="hint">Dell, HP, Lenovo, and other common hardware vendors.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="seedResult" class="seed-result">{{ seedResult }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Finish -->
|
||||
<div v-else-if="current.key === 'finish'">
|
||||
<h2>All set</h2>
|
||||
<p class="hint">You can change any of this later under Settings. Finish to go to the dashboard.</p>
|
||||
<div v-if="geenforceEnabled" class="finish-note">
|
||||
<strong>GE-Enforce is on.</strong> Two more steps before the fleet
|
||||
uses it: create a service token with the geenforce scopes under
|
||||
Settings > API Tokens, and set the on-share export root on the
|
||||
GE-Enforce page.
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer class="wizard-foot">
|
||||
<button v-if="step > 0" class="btn btn-secondary" @click="back">Back</button>
|
||||
<div class="foot-right">
|
||||
<button type="button" class="btn btn-text" @click="skipForNow">Skip for now</button>
|
||||
<button v-if="step < steps.length - 1" class="btn btn-primary" :disabled="saving" @click="next">
|
||||
{{ saving ? 'Saving...' : 'Next' }}
|
||||
</button>
|
||||
<button v-else class="btn btn-primary" :disabled="saving" @click="finish">
|
||||
{{ saving ? 'Finishing...' : 'Finish' }}
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { settingsApi, pluginsApi, setupApi } from '../api'
|
||||
import { useToast } from '../composables/toast'
|
||||
import { apiError } from '../utils/apiError'
|
||||
import { refreshSetupState, setupSkipped } from '../composables/setupState'
|
||||
|
||||
const router = useRouter()
|
||||
const toast = useToast()
|
||||
|
||||
const steps = [
|
||||
{ key: 'site', label: 'Site' },
|
||||
{ key: 'plugins', label: 'Features' },
|
||||
{ key: 'map', label: 'Floor Map' },
|
||||
{ key: 'data', label: 'Starter Data' },
|
||||
{ key: 'finish', label: 'Finish' },
|
||||
]
|
||||
const step = ref(0)
|
||||
const current = computed(() => steps[step.value])
|
||||
|
||||
const form = ref({
|
||||
facility_name: '', site_base_url: '',
|
||||
map_width: null, map_height: null,
|
||||
})
|
||||
const plugins = ref([])
|
||||
const pluginConfig = ref({}) // flat map: setting/field key -> value
|
||||
const pluginModes = ref({}) // mode_setting key -> 'selfhosted' | 'external'
|
||||
const saving = ref(false)
|
||||
const seeding = ref(false)
|
||||
const seedResult = ref('')
|
||||
const mapUploading = ref(false)
|
||||
const blueprintLight = ref('')
|
||||
const blueprintDark = ref('')
|
||||
const logoUploading = ref(false)
|
||||
const siteLogo = ref('')
|
||||
|
||||
async function uploadSiteLogo(event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
logoUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadBrandingLogo('site', file)
|
||||
siteLogo.value = data?.data?.value || ''
|
||||
toast.success('Logo uploaded')
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Upload failed'))
|
||||
} finally {
|
||||
logoUploading.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
async function uploadBlueprint(theme, event) {
|
||||
const file = event.target.files[0]
|
||||
if (!file) return
|
||||
mapUploading.value = true
|
||||
try {
|
||||
const { data } = await settingsApi.uploadMapBlueprint(theme, file)
|
||||
const url = data?.data?.value
|
||||
if (theme === 'light') blueprintLight.value = url
|
||||
else blueprintDark.value = url
|
||||
toast.success('Blueprint uploaded')
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Upload failed'))
|
||||
} finally {
|
||||
mapUploading.value = false
|
||||
event.target.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
function modeOf(plugin) {
|
||||
const key = plugin.provisioning_note?.mode_setting
|
||||
return key ? (pluginModes.value[key] || 'selfhosted') : null
|
||||
}
|
||||
|
||||
// GE-Enforce needs post-setup operational config (service token + share root)
|
||||
// the wizard does not collect; the Finish step points there when it is on.
|
||||
const geenforceEnabled = computed(() =>
|
||||
plugins.value.some(p => p.name === 'geenforce' && p.enabled))
|
||||
|
||||
// Enabled plugins whose external connection config should be collected right
|
||||
// now: those in external mode (or with config but no mode concept).
|
||||
const configurablePlugins = computed(() =>
|
||||
plugins.value.filter(p => p.enabled && (p.config_schema || []).length &&
|
||||
(!p.provisioning_note?.mode_setting || modeOf(p) === 'external')))
|
||||
|
||||
// .env lines for secret fields that have a value entered.
|
||||
const envLines = computed(() => {
|
||||
const lines = []
|
||||
for (const plugin of configurablePlugins.value) {
|
||||
for (const field of plugin.config_schema) {
|
||||
const value = pluginConfig.value[field.key]
|
||||
if (field.secret && field.envvar && value) {
|
||||
lines.push(`${field.envvar}=${value}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines.join('\n')
|
||||
})
|
||||
|
||||
// Which settings each step owns, so Next only saves what changed on that step.
|
||||
const stepSettings = {
|
||||
site: ['facility_name', 'site_base_url'],
|
||||
map: ['map_width', 'map_height'],
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Load current values for the settings the wizard edits.
|
||||
const keys = ['facility_name', 'site_base_url', 'map_width', 'map_height']
|
||||
for (const key of keys) {
|
||||
try {
|
||||
const response = await settingsApi.get(key)
|
||||
const value = response.data?.data?.value
|
||||
if (value !== undefined && value !== null && value !== '') form.value[key] = value
|
||||
} catch (err) { /* setting may not exist yet */ }
|
||||
}
|
||||
try {
|
||||
const response = await pluginsApi.list()
|
||||
plugins.value = response.data.data?.plugins || response.data.data || []
|
||||
} catch (err) { /* ignore */ }
|
||||
|
||||
// Preload directory mode (defaults self-hosted) + non-secret config fields.
|
||||
for (const plugin of plugins.value) {
|
||||
const modeKey = plugin.provisioning_note?.mode_setting
|
||||
if (modeKey && pluginModes.value[modeKey] === undefined) {
|
||||
try {
|
||||
const response = await settingsApi.get(modeKey)
|
||||
pluginModes.value[modeKey] = response.data?.data?.value || 'selfhosted'
|
||||
} catch (err) { pluginModes.value[modeKey] = 'selfhosted' }
|
||||
}
|
||||
for (const field of (plugin.config_schema || [])) {
|
||||
if (field.secret) continue
|
||||
try {
|
||||
const response = await settingsApi.get(field.key)
|
||||
const value = response.data?.data?.value
|
||||
if (value !== undefined && value !== null && value !== '') pluginConfig.value[field.key] = value
|
||||
} catch (err) { /* not set yet */ }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
async function saveStep() {
|
||||
// Plugins step: save each plugin's directory mode, plus non-secret external
|
||||
// connection config (secrets stay in .env).
|
||||
if (current.value.key === 'plugins') {
|
||||
for (const [modeKey, modeValue] of Object.entries(pluginModes.value)) {
|
||||
await settingsApi.update(modeKey, modeValue)
|
||||
}
|
||||
for (const plugin of configurablePlugins.value) {
|
||||
for (const field of plugin.config_schema) {
|
||||
if (field.secret) continue
|
||||
const value = pluginConfig.value[field.key]
|
||||
if (value === undefined || value === null || value === '') continue
|
||||
await settingsApi.update(field.key, String(value))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
const keys = stepSettings[current.value.key]
|
||||
if (!keys) return
|
||||
for (const key of keys) {
|
||||
const value = form.value[key]
|
||||
if (value === null || value === undefined) continue
|
||||
await settingsApi.update(key, String(value))
|
||||
}
|
||||
}
|
||||
|
||||
async function copyEnv() {
|
||||
try {
|
||||
await navigator.clipboard.writeText(envLines.value)
|
||||
toast.success('.env lines copied.')
|
||||
} catch (err) {
|
||||
toast.error('Copy failed - select and copy manually.')
|
||||
}
|
||||
}
|
||||
|
||||
async function next() {
|
||||
saving.value = true
|
||||
try {
|
||||
await saveStep()
|
||||
step.value++
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Could not save this step'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function back() { if (step.value > 0) step.value-- }
|
||||
|
||||
function skipForNow() {
|
||||
setupSkipped.value = true
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
async function togglePlugin(plugin, enabled) {
|
||||
try {
|
||||
await pluginsApi.setEnabled(plugin.name, enabled)
|
||||
plugin.enabled = enabled
|
||||
toast.success(`${plugin.name} ${enabled ? 'enabled' : 'disabled'} (restart to apply route changes).`)
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Could not change plugin'))
|
||||
}
|
||||
}
|
||||
|
||||
async function seedReference() {
|
||||
seeding.value = true
|
||||
try {
|
||||
const response = await setupApi.seedReference()
|
||||
seedResult.value = response.data?.message || 'Reference data seeded.'
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Could not seed reference data'))
|
||||
} finally {
|
||||
seeding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function seedStarter() {
|
||||
seeding.value = true
|
||||
try {
|
||||
const response = await setupApi.seedStarter()
|
||||
const data = response.data?.data || {}
|
||||
seedResult.value = data.addedcount ? `Added ${data.addedcount}: ${data.added.join(', ')}` : 'All common vendors already present.'
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Could not seed starter data'))
|
||||
} finally {
|
||||
seeding.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
saving.value = true
|
||||
try {
|
||||
await setupApi.complete()
|
||||
await refreshSetupState()
|
||||
toast.success('Setup complete.')
|
||||
router.push('/')
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Could not finish setup'))
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.setup-wizard { display: flex; justify-content: center; padding: 2rem 1rem; }
|
||||
.wizard-card { width: 100%; max-width: 640px; background: var(--bg-card); border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
|
||||
.wizard-head { padding: 1.5rem 1.75rem 1rem; border-bottom: 1px solid var(--border); }
|
||||
.wizard-head h1 { margin: 0; }
|
||||
.wizard-sub { color: var(--text-light); margin: 0.25rem 0 1rem; }
|
||||
.wizard-steps { list-style: none; display: flex; gap: 0.5rem; padding: 0; margin: 0; flex-wrap: wrap; }
|
||||
.wizard-steps li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.82rem; color: var(--text-light); }
|
||||
.wizard-steps li.active { color: var(--primary); font-weight: 600; }
|
||||
.wizard-steps li.done { color: var(--success); }
|
||||
.step-num { display: inline-flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: 50%; border: 1px solid currentColor; font-size: 0.72rem; }
|
||||
.wizard-body { padding: 1.5rem 1.75rem; min-height: 220px; }
|
||||
.wizard-body h2 { margin-top: 0; }
|
||||
.hint { color: var(--text-light); font-size: 0.88rem; }
|
||||
.form-group { margin-bottom: 1rem; }
|
||||
.form-group label { display: block; margin-bottom: 0.3rem; font-weight: 500; }
|
||||
.form-row { display: flex; gap: 1rem; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.plugin-list { display: flex; flex-direction: column; gap: 0.6rem; }
|
||||
.plugin-row { display: grid; grid-template-columns: auto auto 1fr; gap: 0.6rem; align-items: baseline; padding: 0.5rem 0.6rem; background: var(--bg); border-radius: 6px; }
|
||||
.plugin-name { font-weight: 600; }
|
||||
.plugin-desc { color: var(--text-light); font-size: 0.85rem; }
|
||||
.plugin-mode { margin: 0.4rem 0 0.2rem 1.9rem; }
|
||||
.mode-opt { display: block; font-size: 0.86rem; margin-bottom: 0.3rem; cursor: pointer; }
|
||||
.mode-opt input { margin-right: 0.4rem; }
|
||||
.plugin-mode .provision-note, .plugin-mode .config-block { margin-left: 0; }
|
||||
.provision-note { margin: 0.3rem 0 0.2rem 1.9rem; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--warning); border-radius: 4px; font-size: 0.82rem; }
|
||||
.finish-note { margin: 0.75rem 0 0; padding: 0.6rem 0.75rem; background: var(--bg); border-left: 3px solid var(--primary); border-radius: 4px; font-size: 0.85rem; }
|
||||
.provision-note p { margin: 0 0 0.35rem; }
|
||||
.provision-note p:last-child { margin-bottom: 0; }
|
||||
.provision-tables code, .provision-docs code { background: var(--bg-card); border: 1px solid var(--border); border-radius: 3px; padding: 0 0.3rem; margin-right: 0.3rem; font-size: 0.78rem; }
|
||||
.seed-result { margin-top: 0.75rem; color: var(--success); font-size: 0.88rem; }
|
||||
.starter-actions { display: flex; flex-direction: column; gap: 1.1rem; }
|
||||
.starter-actions .hint { margin: 0.35rem 0 0; }
|
||||
.config-block { margin-top: 1.25rem; padding-top: 1rem; border-top: 1px solid var(--border); }
|
||||
.config-title { margin: 0 0 0.75rem; font-size: 1rem; text-transform: capitalize; }
|
||||
.secret-tag { margin-left: 0.5rem; font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--warning); border: 1px solid var(--warning); border-radius: 4px; padding: 0 0.3rem; }
|
||||
.env-block { margin-top: 1rem; }
|
||||
.env-pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 0.75rem; overflow-x: auto; font-size: 0.82rem; margin: 0.5rem 0; }
|
||||
.wizard-map-thumb { display: block; margin-top: 0.5rem; max-height: 90px; border: 1px solid var(--border); border-radius: 4px; background: #fff; }
|
||||
.wizard-map-thumb.dark { background: #222; }
|
||||
.wizard-foot { display: flex; justify-content: space-between; align-items: center; padding: 1rem 1.75rem; border-top: 1px solid var(--border); }
|
||||
.foot-right { display: flex; align-items: center; gap: 0.75rem; }
|
||||
.btn-text { background: none; border: none; color: var(--text-light); text-decoration: none; }
|
||||
</style>
|
||||
762
frontend/src/views/ShopfloorDashboard.vue
Normal file
762
frontend/src/views/ShopfloorDashboard.vue
Normal file
@@ -0,0 +1,762 @@
|
||||
<template>
|
||||
<div class="shopfloor-dashboard">
|
||||
<header class="dashboard-header">
|
||||
<div class="logo-container">
|
||||
<img :src="siteLogo" alt="Site logo" class="logo" />
|
||||
</div>
|
||||
|
||||
<div class="header-center">
|
||||
<div class="location-title">{{ facilityName }}</div>
|
||||
<h1>Shopfloor Dashboard</h1>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<div class="clock">{{ currentTime }}</div>
|
||||
<select v-model="businessUnit" class="filter-select" @change="loadData">
|
||||
<option value="">All Business Units</option>
|
||||
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</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>
|
||||
<div class="recognition-carousel">
|
||||
<div
|
||||
v-for="(rec, idx) in recognitions"
|
||||
:key="`${rec.notificationid}-${rec.employeesso}`"
|
||||
class="recognition-card"
|
||||
:class="{ active: idx === currentRecognition }"
|
||||
>
|
||||
<div class="recognition-photo-container">
|
||||
<img
|
||||
v-if="rec.employeepicture"
|
||||
:src="rec.employeepicture"
|
||||
:alt="rec.employeename"
|
||||
class="recognition-photo"
|
||||
@error="handlePhotoError"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="siteLogo"
|
||||
alt="Site logo"
|
||||
class="recognition-photo ge-logo-fallback"
|
||||
/>
|
||||
</div>
|
||||
<div class="recognition-content">
|
||||
<div class="recognition-header">
|
||||
<div class="recognition-name">{{ rec.employeename }}</div>
|
||||
</div>
|
||||
<div class="recognition-message">{{ rec.notification }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</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="rec.employeepicture"
|
||||
:alt="rec.employeename"
|
||||
class="recert-photo"
|
||||
@error="handlePhotoError"
|
||||
/>
|
||||
<img
|
||||
v-else
|
||||
:src="siteLogo"
|
||||
alt="Site logo"
|
||||
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)">
|
||||
Current Notifications
|
||||
</div>
|
||||
<div class="events-list">
|
||||
<div
|
||||
v-for="n in currentNotifications"
|
||||
:key="n.notificationid"
|
||||
class="event-card"
|
||||
:class="{ resolved: n.resolved }"
|
||||
>
|
||||
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
|
||||
<div class="event-content">
|
||||
<div class="event-title">{{ n.notification }}</div>
|
||||
<div class="event-time">
|
||||
<template v-if="n.resolved">
|
||||
<strong>RESOLVED</strong>
|
||||
</template>
|
||||
<template v-else>
|
||||
<strong>{{ formatTime(n.starttime) }}</strong>
|
||||
<span v-if="n.endtime"> - {{ formatTime(n.endtime) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="n.ticketnumber">
|
||||
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
||||
{{ n.ticketnumber }}
|
||||
</a>
|
||||
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Upcoming Notifications -->
|
||||
<section v-if="upcomingNotifications.length" class="notifications-section">
|
||||
<div class="section-title upcoming">Upcoming</div>
|
||||
<div class="events-list">
|
||||
<div
|
||||
v-for="n in upcomingNotifications"
|
||||
:key="n.notificationid"
|
||||
class="event-card"
|
||||
>
|
||||
<div class="event-indicator" :style="{ backgroundColor: getTypeColor(n.typecolor) }"></div>
|
||||
<div class="event-content">
|
||||
<div class="event-title">{{ n.notification }}</div>
|
||||
<div class="event-time">
|
||||
<strong>{{ formatDateTime(n.starttime) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<template v-if="n.ticketnumber">
|
||||
<a v-if="getTicketUrl(n.ticketnumber)" :href="getTicketUrl(n.ticketnumber)" target="_blank" class="event-ticket">
|
||||
{{ n.ticketnumber }}
|
||||
</a>
|
||||
<span v-else class="event-ticket">{{ n.ticketnumber }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- No notifications -->
|
||||
<div v-if="!loading && !currentNotifications.length && !upcomingNotifications.length && !recognitions.length && !recertifications.length && !banners.length" class="no-events">
|
||||
No active notifications
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
</main>
|
||||
|
||||
<footer class="dashboard-footer">
|
||||
Auto-refreshes every 30 seconds
|
||||
</footer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import { notificationsApi, businessUnitsApi, dashboardDefaultsApi } from '@/api'
|
||||
import { getFacilityName, getSiteLogo, getServicenowUrls } from '@/utils/siteSettings'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const loading = ref(true)
|
||||
const facilityName = ref('ShopDB')
|
||||
const siteLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
// ServiceNow ticket-link config; loaded on mount. Empty/disabled = plain text.
|
||||
const servicenowConfig = ref({ enabled: true, incidentUrl: '', changeUrl: '' })
|
||||
const businessUnit = ref('')
|
||||
const businessUnits = ref([])
|
||||
const notifications = ref({ current: [], upcoming: [] })
|
||||
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']
|
||||
|
||||
const recognitions = computed(() =>
|
||||
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 => !SPECIAL_STYLES.includes(n.displaystyle))
|
||||
)
|
||||
|
||||
const upcomingNotifications = computed(() =>
|
||||
notifications.value.upcoming.filter(n => !SPECIAL_STYLES.includes(n.displaystyle))
|
||||
)
|
||||
|
||||
// Clock
|
||||
const currentTime = ref('')
|
||||
function updateClock() {
|
||||
const now = new Date()
|
||||
currentTime.value = now.toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
updateClock()
|
||||
setInterval(updateClock, 1000)
|
||||
|
||||
getFacilityName().then(name => { facilityName.value = name })
|
||||
getSiteLogo().then(logo => { siteLogo.value = logo })
|
||||
getServicenowUrls().then(config => { servicenowConfig.value = config })
|
||||
|
||||
// Load business units
|
||||
try {
|
||||
const response = await businessUnitsApi.list()
|
||||
businessUnits.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading business units:', err)
|
||||
}
|
||||
|
||||
// Auto-select this kiosk's business unit from its IP (visitor location),
|
||||
// unless one was already chosen. Falls back to "all" when the IP is unmapped.
|
||||
if (!businessUnit.value) {
|
||||
try {
|
||||
const response = await dashboardDefaultsApi.visitorLocation()
|
||||
if (response.data.data?.businessunitid) {
|
||||
businessUnit.value = response.data.data.businessunitid
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error resolving visitor location:', err)
|
||||
}
|
||||
}
|
||||
|
||||
await loadData()
|
||||
|
||||
// Auto-refresh every 30 seconds
|
||||
refreshInterval = setInterval(loadData, 30000)
|
||||
|
||||
// Rotate recognition carousel every 8 seconds
|
||||
recognitionInterval = setInterval(() => {
|
||||
if (recognitions.value.length > 1) {
|
||||
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() {
|
||||
try {
|
||||
const params = {}
|
||||
if (businessUnit.value) {
|
||||
params.businessunit = businessUnit.value
|
||||
}
|
||||
const response = await notificationsApi.getShopfloor(params)
|
||||
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 {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getTypeColor(typecolor) {
|
||||
// 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',
|
||||
secondary: '#94614f'
|
||||
}
|
||||
return aliases[typecolor] || typecolor || '#14abef'
|
||||
}
|
||||
|
||||
function getSectionClass(notifications) {
|
||||
// Use danger color if any active incidents
|
||||
const hasIncident = notifications.some(n => n.typecolor === 'danger' && !n.resolved)
|
||||
return hasIncident ? 'danger' : ''
|
||||
}
|
||||
|
||||
function formatTime(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleTimeString('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function formatDateTime(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString('en-US', {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
function getTicketUrl(ticketnumber) {
|
||||
// Return a URL only when ServiceNow is enabled and a matching template is set.
|
||||
// Null means "render the ticket number as plain text, no link".
|
||||
if (!ticketnumber) return null
|
||||
const config = servicenowConfig.value
|
||||
if (!config.enabled) return null
|
||||
if (ticketnumber.startsWith('GEINC') && config.incidentUrl) {
|
||||
return config.incidentUrl.replace('{ticket}', ticketnumber)
|
||||
}
|
||||
if (ticketnumber.startsWith('GECHG') && config.changeUrl) {
|
||||
return config.changeUrl.replace('{ticket}', ticketnumber)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function handlePhotoError(e) {
|
||||
e.target.src = siteLogo.value
|
||||
e.target.classList.add('ge-logo-fallback')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.shopfloor-dashboard {
|
||||
min-height: 100vh;
|
||||
background: #00003d;
|
||||
color: #fff;
|
||||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
align-items: center;
|
||||
gap: 30px;
|
||||
padding: 20px 40px;
|
||||
border-bottom: 3px solid #4181ff;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 90px;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.header-center {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.location-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #b8c4d8;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
.header-center h1 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.header-right {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.clock {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
color: #4181ff;
|
||||
font-variant-numeric: tabular-nums;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
padding: 10px 16px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
background: #1a1a5e;
|
||||
color: #fff;
|
||||
border: 2px solid #4181ff;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.dashboard-content {
|
||||
padding: 20px 40px;
|
||||
max-height: calc(100vh - 180px);
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
padding: 12px 20px;
|
||||
border-radius: 8px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-bottom: 15px;
|
||||
background: #4181ff;
|
||||
}
|
||||
|
||||
.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 {
|
||||
background: #f5365c;
|
||||
}
|
||||
|
||||
.section-title.upcoming {
|
||||
background: #6c757d;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
.recognition-carousel {
|
||||
position: relative;
|
||||
min-height: 170px;
|
||||
}
|
||||
|
||||
.recognition-card {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(135deg, #4a3a0a 0%, #2a2200 100%);
|
||||
border: 3px solid #ffc107;
|
||||
border-radius: 12px;
|
||||
padding: 20px 25px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 25px;
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.8s ease, transform 0.8s ease;
|
||||
}
|
||||
|
||||
.recognition-card.active {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.recognition-photo {
|
||||
width: 140px;
|
||||
height: 140px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
border: 4px solid #ffc107;
|
||||
background: #1a1a2e;
|
||||
}
|
||||
|
||||
.recognition-photo.ge-logo-fallback {
|
||||
object-fit: contain;
|
||||
padding: 20px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.recognition-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.recognition-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.recognition-star {
|
||||
font-size: 48px;
|
||||
color: #ffc107;
|
||||
margin-right: 20px;
|
||||
animation: starPulse 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
|
||||
@keyframes starPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.1); }
|
||||
}
|
||||
|
||||
.recognition-name {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.recognition-message {
|
||||
font-size: 24px;
|
||||
color: #ccc;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Event cards */
|
||||
.notifications-section {
|
||||
margin-bottom: 25px;
|
||||
}
|
||||
|
||||
.events-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.event-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
color: #00003d;
|
||||
border-radius: 8px;
|
||||
padding: 15px 20px;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.event-card.resolved {
|
||||
opacity: 0.6;
|
||||
background: #e9ecef;
|
||||
}
|
||||
|
||||
.event-indicator {
|
||||
width: 8px;
|
||||
height: 60px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.event-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.event-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.event-time {
|
||||
font-size: 18px;
|
||||
color: #666;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.event-time strong {
|
||||
color: #00003d;
|
||||
}
|
||||
|
||||
.event-ticket {
|
||||
font-size: 18px;
|
||||
font-weight: 700;
|
||||
background: #00003d;
|
||||
color: #fff;
|
||||
padding: 8px 16px;
|
||||
border-radius: 6px;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.event-ticket:hover {
|
||||
background: #4181ff;
|
||||
}
|
||||
|
||||
.no-events, .loading {
|
||||
text-align: center;
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
padding: 40px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dashboard-footer {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
text-align: center;
|
||||
padding: 12px;
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
182
frontend/src/views/TVDashboard.vue
Normal file
182
frontend/src/views/TVDashboard.vue
Normal file
@@ -0,0 +1,182 @@
|
||||
<template>
|
||||
<div class="tv-dashboard">
|
||||
<div class="slideshow-container">
|
||||
<div
|
||||
v-for="(slide, idx) in slides"
|
||||
:key="slide.filename"
|
||||
class="slide"
|
||||
:class="{ active: idx === currentSlide }"
|
||||
>
|
||||
<img :src="withBase(basePath + slide.filename)" :alt="slide.filename" />
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<h2>Display Error</h2>
|
||||
<p>{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="!slides.length && !error" class="error-message">
|
||||
<h2>No Slides</h2>
|
||||
<p>No slides configured for display</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="progress-bar" :style="progressStyle"></div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import api from '@/api'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
|
||||
const INTERVAL = 10 // seconds between slides
|
||||
|
||||
const slides = ref([])
|
||||
const basePath = ref('/static/slides/')
|
||||
const currentSlide = ref(0)
|
||||
const error = ref('')
|
||||
const progress = ref(0)
|
||||
|
||||
let slideTimer = null
|
||||
let progressTimer = null
|
||||
let refreshTimer = null
|
||||
|
||||
const progressStyle = computed(() => ({
|
||||
width: `${progress.value}%`,
|
||||
transition: progress.value === 0 ? 'none' : `width ${INTERVAL}s linear`
|
||||
}))
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSlides()
|
||||
|
||||
// Start slideshow if we have slides
|
||||
if (slides.value.length > 1) {
|
||||
startSlideshow()
|
||||
}
|
||||
|
||||
// Refresh slide list every 60 seconds
|
||||
refreshTimer = setInterval(fetchSlides, 60000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (slideTimer) clearTimeout(slideTimer)
|
||||
if (progressTimer) clearTimeout(progressTimer)
|
||||
if (refreshTimer) clearInterval(refreshTimer)
|
||||
})
|
||||
|
||||
async function fetchSlides() {
|
||||
try {
|
||||
// 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.slides?.length > 0) {
|
||||
slides.value = data.slides
|
||||
basePath.value = data.basepath || '/api/slides/img/lobby/'
|
||||
error.value = ''
|
||||
|
||||
// Restart slideshow if slides changed
|
||||
if (slides.value.length > 1 && !slideTimer) {
|
||||
startSlideshow()
|
||||
}
|
||||
} else {
|
||||
slides.value = []
|
||||
error.value = 'No slides configured'
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error fetching slides:', err)
|
||||
error.value = 'Unable to load slides'
|
||||
}
|
||||
}
|
||||
|
||||
function startSlideshow() {
|
||||
scheduleNextSlide()
|
||||
}
|
||||
|
||||
function scheduleNextSlide() {
|
||||
// Reset progress bar
|
||||
progress.value = 0
|
||||
|
||||
// Start progress animation after a small delay
|
||||
progressTimer = setTimeout(() => {
|
||||
progress.value = 100
|
||||
}, 50)
|
||||
|
||||
// Schedule next slide
|
||||
slideTimer = setTimeout(() => {
|
||||
nextSlide()
|
||||
scheduleNextSlide()
|
||||
}, INTERVAL * 1000)
|
||||
}
|
||||
|
||||
function nextSlide() {
|
||||
if (slides.value.length === 0) return
|
||||
currentSlide.value = (currentSlide.value + 1) % slides.value.length
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.tv-dashboard {
|
||||
background: #000;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slideshow-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 1s ease-in-out;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.slide.active {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.slide img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
height: 4px;
|
||||
background: #4181ff;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
color: #fff;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.error-message h2 {
|
||||
color: #ff4444;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
</style>
|
||||
366
frontend/src/views/applications/ApplicationDetail.vue
Normal file
366
frontend/src/views/applications/ApplicationDetail.vue
Normal file
@@ -0,0 +1,366 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Application Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/applications/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/applications" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="app">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="app.image">
|
||||
<img :src="`/images/applications/${app.image}`" :alt="app.appname" @error="handleImageError" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="placeholder-icon">📦</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ app.appname }}</h1>
|
||||
</div>
|
||||
<p class="hero-description" v-if="app.appdescription">{{ app.appdescription }}</p>
|
||||
<div class="hero-meta">
|
||||
<span v-if="app.isinstallable" class="badge badge-lg badge-info">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-lg badge-warning">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-lg badge-secondary">Printer App</span>
|
||||
<span v-if="app.ishidden" class="badge badge-lg badge-dark">Hidden</span>
|
||||
</div>
|
||||
<div class="hero-links" v-if="app.applicationlink || app.installpath || app.documentationpath">
|
||||
<a v-if="app.applicationlink" :href="app.applicationlink" target="_blank" class="hero-link">
|
||||
<span class="link-icon">🔗</span> Launch Application
|
||||
</a>
|
||||
<a v-if="app.installpath" :href="app.installpath" target="_blank" class="hero-link">
|
||||
<span class="link-icon">⬇</span> Download Files
|
||||
</a>
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" class="hero-link">
|
||||
<span class="link-icon">📄</span> Documentation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Support -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Support</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Support Team</span>
|
||||
<span class="info-value">
|
||||
<a v-if="app.teamurl" :href="app.teamurl" target="_blank">
|
||||
{{ app.supportteamname || '-' }}
|
||||
</a>
|
||||
<span v-else>{{ app.supportteamname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="app.contacts && app.contacts.length">
|
||||
<span class="info-label">Contacts</span>
|
||||
<span class="info-value">
|
||||
<span v-for="(contact, index) in app.contacts" :key="index" class="contact-line">
|
||||
{{ contact.name }}<span v-if="contact.sso" class="mono"> ({{ contact.sso }})</span>
|
||||
<template v-if="contact.sso && contactEmailDomain">
|
||||
<a class="contact-action" :href="`mailto:${contactEmail(contact)}`" title="Email">Email</a>
|
||||
<a class="contact-action" :href="`https://teams.microsoft.com/l/chat/0/0?users=${contactEmail(contact)}`" target="_blank" rel="noopener" title="Teams chat">Teams</a>
|
||||
</template>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Application Notes -->
|
||||
<div class="section-card" v-if="app.applicationnotes">
|
||||
<h3 class="section-title">Application Notes</h3>
|
||||
<div class="notes-text">{{ app.applicationnotes }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Versions -->
|
||||
<div class="section-card" v-if="versions.length > 0">
|
||||
<h3 class="section-title">Available Versions</h3>
|
||||
<div class="version-list">
|
||||
<div v-for="ver in versions" :key="ver.appversionid" class="version-item">
|
||||
<span class="version-number">v{{ ver.version }}</span>
|
||||
<span class="version-date" v-if="ver.releasedate">{{ formatDate(ver.releasedate) }}</span>
|
||||
<span class="version-notes" v-if="ver.notes">{{ ver.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Related Knowledge Base -->
|
||||
<div class="section-card" v-if="app.knowledgebase && app.knowledgebase.length">
|
||||
<h3 class="section-title">Knowledge Base ({{ app.knowledgebase.length }})</h3>
|
||||
<div class="kb-list">
|
||||
<a
|
||||
v-for="kb in app.knowledgebase"
|
||||
:key="kb.linkid"
|
||||
:href="kb.linkurl"
|
||||
target="_blank"
|
||||
class="kb-item"
|
||||
>
|
||||
<span class="kb-title">{{ kb.shortdescription }}</span>
|
||||
<span class="kb-keywords" v-if="kb.keywords">{{ kb.keywords }}</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Installed On PCs -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Installed On ({{ installedOn.length }} PCs)</h3>
|
||||
<div v-if="installedOn.length > 0" class="pc-list">
|
||||
<router-link
|
||||
v-for="install in installedOn"
|
||||
:key="install.id"
|
||||
:to="`/pcs/${install.computerid}`"
|
||||
class="pc-item"
|
||||
>
|
||||
<div class="pc-info">
|
||||
<span class="pc-name">{{ install.computer?.hostname || install.computer?.assetnumber || `PC #${install.computerid}` }}</span>
|
||||
<span class="pc-alias" v-if="install.computer?.assetnumber">{{ install.computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="pc-version" v-if="install.version">
|
||||
v{{ install.version }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-else class="empty-state">
|
||||
<p>Not installed on any PCs</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(app.createddate) }}</span>
|
||||
<span>Modified {{ formatDate(app.modifieddate) }}</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Application not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { applicationsApi } from '../../api'
|
||||
import { getContactEmailDomain } from '../../utils/siteSettings'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const app = ref(null)
|
||||
const versions = ref([])
|
||||
const installedOn = ref([])
|
||||
const contactEmailDomain = ref('')
|
||||
|
||||
// Build sso@domain for a contact. Assumes contact.sso and domain are set.
|
||||
function contactEmail(contact) {
|
||||
return `${contact.sso}@${contactEmailDomain.value}`
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
contactEmailDomain.value = await getContactEmailDomain()
|
||||
// Load application details
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
app.value = response.data.data
|
||||
|
||||
// Load versions
|
||||
try {
|
||||
const versionsRes = await applicationsApi.getVersions(route.params.id)
|
||||
versions.value = versionsRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
|
||||
// Load installed on which PCs
|
||||
try {
|
||||
const installedRes = await applicationsApi.getInstalledOn(route.params.id)
|
||||
installedOn.value = installedRes.data.data || []
|
||||
} catch (e) {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading application:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
|
||||
function handleImageError(e) {
|
||||
e.target.style.display = 'none'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Hero description (app-specific) */
|
||||
.hero-description {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Hero links (app-specific) */
|
||||
.hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.hero-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: var(--text);
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hero-link:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.link-icon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* Placeholder image */
|
||||
.hero-image.placeholder {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
font-size: 5rem;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Version List */
|
||||
.version-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.version-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.version-number {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.version-date {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.version-notes {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* PC List */
|
||||
.pc-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.pc-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.pc-item:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.pc-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.pc-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.pc-alias {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.pc-version {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
font-size: 1rem;
|
||||
color: var(--primary);
|
||||
background: var(--bg-card);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* Empty state */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2.5rem;
|
||||
color: var(--text-light);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* Support contacts stack one per line */
|
||||
.contact-line {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Notes styling - rendered as escaped plain text, preserve author line breaks */
|
||||
.notes-text {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
</style>
|
||||
279
frontend/src/views/applications/ApplicationForm.vue
Normal file
279
frontend/src/views/applications/ApplicationForm.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Application' : 'New Application' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveApplication">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="appname">Application Name *</label>
|
||||
<input
|
||||
id="appname"
|
||||
v-model="form.appname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="supportteamid">Support Team</label>
|
||||
<select
|
||||
id="supportteamid"
|
||||
v-model="form.supportteamid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select team...</option>
|
||||
<option
|
||||
v-for="team in supportTeams"
|
||||
:key="team.supportteamid"
|
||||
:value="team.supportteamid"
|
||||
>
|
||||
{{ team.teamname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="appdescription">Description</label>
|
||||
<textarea
|
||||
id="appdescription"
|
||||
v-model="form.appdescription"
|
||||
class="form-control"
|
||||
rows="2"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Application Flags</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.isinstallable" />
|
||||
Installable
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.islicenced" />
|
||||
Licensed
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.isprinter" />
|
||||
Printer App
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.isrequired" />
|
||||
Required on all PCs
|
||||
</label>
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.ishidden" />
|
||||
Hidden
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Links & Paths</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="applicationlink">Application Link</label>
|
||||
<input
|
||||
id="applicationlink"
|
||||
v-model="form.applicationlink"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="documentationpath">Documentation Path</label>
|
||||
<input
|
||||
id="documentationpath"
|
||||
v-model="form.documentationpath"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="URL or file path"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="installpath">Install Path</label>
|
||||
<input
|
||||
id="installpath"
|
||||
v-model="form.installpath"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Network path or URL to install files"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="image">Image Filename</label>
|
||||
<input
|
||||
id="image"
|
||||
v-model="form.image"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., myapp.png"
|
||||
/>
|
||||
<small class="form-hint">Image should be placed in /images/applications/</small>
|
||||
</div>
|
||||
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Notes</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="applicationnotes">Application Notes (HTML supported)</label>
|
||||
<textarea
|
||||
id="applicationnotes"
|
||||
v-model="form.applicationnotes"
|
||||
class="form-control"
|
||||
rows="6"
|
||||
placeholder="Enter notes... HTML tags like <BR>, <a>, <strong> are supported"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Application' }}
|
||||
</button>
|
||||
<router-link to="/applications" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { applicationsApi, supportteamsApi } from '../../api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
appname: '',
|
||||
appdescription: '',
|
||||
supportteamid: '',
|
||||
isinstallable: false,
|
||||
islicenced: false,
|
||||
isprinter: false,
|
||||
isrequired: false,
|
||||
ishidden: false,
|
||||
applicationlink: '',
|
||||
documentationpath: '',
|
||||
installpath: '',
|
||||
image: '',
|
||||
applicationnotes: ''
|
||||
})
|
||||
|
||||
const supportTeams = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load support teams
|
||||
const teamsRes = await supportteamsApi.list()
|
||||
supportTeams.value = teamsRes.data.data || []
|
||||
|
||||
// Load application if editing
|
||||
if (isEdit.value) {
|
||||
const response = await applicationsApi.get(route.params.id)
|
||||
const app = response.data.data
|
||||
|
||||
form.value = {
|
||||
appname: app.appname || '',
|
||||
appdescription: app.appdescription || '',
|
||||
supportteamid: app.supportteamid || '',
|
||||
isinstallable: app.isinstallable || false,
|
||||
islicenced: app.islicenced || false,
|
||||
isprinter: app.isprinter || false,
|
||||
isrequired: app.isrequired || false,
|
||||
ishidden: app.ishidden || false,
|
||||
applicationlink: app.applicationlink || '',
|
||||
documentationpath: app.documentationpath || '',
|
||||
installpath: app.installpath || '',
|
||||
image: app.image || '',
|
||||
applicationnotes: app.applicationnotes || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveApplication() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const appData = {
|
||||
appname: form.value.appname,
|
||||
appdescription: form.value.appdescription || null,
|
||||
supportteamid: form.value.supportteamid || null,
|
||||
isinstallable: form.value.isinstallable,
|
||||
islicenced: form.value.islicenced,
|
||||
isprinter: form.value.isprinter,
|
||||
isrequired: form.value.isrequired,
|
||||
ishidden: form.value.ishidden,
|
||||
applicationlink: form.value.applicationlink || null,
|
||||
documentationpath: form.value.documentationpath || null,
|
||||
installpath: form.value.installpath || null,
|
||||
image: form.value.image || null,
|
||||
applicationnotes: form.value.applicationnotes || null
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await applicationsApi.update(route.params.id, appData)
|
||||
} else {
|
||||
await applicationsApi.create(appData)
|
||||
}
|
||||
|
||||
router.push('/applications')
|
||||
} catch (err) {
|
||||
console.error('Error saving application:', err)
|
||||
error.value = apiError(err, 'Failed to save application')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
</style>
|
||||
213
frontend/src/views/applications/ApplicationsList.vue
Normal file
213
frontend/src/views/applications/ApplicationsList.vue
Normal file
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Applications</h2>
|
||||
<router-link to="/applications/new" class="btn btn-primary">Add Application</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search applications..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="filter" class="form-control" @change="loadApplications">
|
||||
<option value="installable">Installable Applications</option>
|
||||
<option value="all">All Applications</option>
|
||||
<option value="hidden">Hidden Applications</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 50px;">Files</th>
|
||||
<th style="width: 50px;">Docs</th>
|
||||
<th>Application Name</th>
|
||||
<th>Description</th>
|
||||
<th>Support Team</th>
|
||||
<th>Contacts</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="app in applications" :key="app.appid" class="clickable-row" @click="$router.push(`/applications/${app.appid}`)">
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.installpath" :href="app.installpath" target="_blank" title="Download Installation Files" class="icon-download">
|
||||
⬇
|
||||
</a>
|
||||
<a v-else-if="app.applicationlink" :href="app.applicationlink" target="_blank" title="Application Link" class="icon-link">
|
||||
🔗
|
||||
</a>
|
||||
</td>
|
||||
<td class="icon-cell">
|
||||
<a v-if="app.documentationpath" :href="app.documentationpath" target="_blank" title="View Documentation" class="icon-docs">
|
||||
📄
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
<router-link :to="`/applications/${app.appid}`">
|
||||
{{ app.appname }}
|
||||
</router-link>
|
||||
<span class="app-flags">
|
||||
<span v-if="app.isinstallable" class="badge badge-info badge-sm">Installable</span>
|
||||
<span v-if="app.islicenced" class="badge badge-warning badge-sm">Licensed</span>
|
||||
<span v-if="app.isprinter" class="badge badge-secondary badge-sm">Printer</span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="description">{{ app.appdescription || '-' }}</td>
|
||||
<td>{{ app.supportteamname || '-' }}</td>
|
||||
<td>{{ app.contacts && app.contacts.length ? app.contacts.map(c => c.name).join(', ') : '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/applications/${app.appid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="applications.length === 0">
|
||||
<td colspan="7" style="text-align: center; color: var(--text-light);">
|
||||
No applications found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { applicationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const applications = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadApplications })
|
||||
const filter = ref('installable')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadApplications()
|
||||
})
|
||||
|
||||
async function loadApplications() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value,
|
||||
search: search.value || undefined
|
||||
}
|
||||
|
||||
// Apply filter
|
||||
if (filter.value === 'installable') {
|
||||
params.installable = true
|
||||
} else if (filter.value === 'hidden') {
|
||||
params.hidden = true
|
||||
}
|
||||
|
||||
const response = await applicationsApi.list(params)
|
||||
applications.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading applications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadApplications()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadApplications()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadApplications()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Application list specific styles */
|
||||
.icon-cell {
|
||||
text-align: center;
|
||||
width: 50px;
|
||||
}
|
||||
|
||||
.icon-cell a {
|
||||
font-size: 1.25rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.icon-download {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.icon-link {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.icon-docs {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
.icon-cell a:hover {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.app-flags {
|
||||
display: inline-flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
margin-left: 0.5rem;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
}
|
||||
|
||||
.description {
|
||||
max-width: 200px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
348
frontend/src/views/employees/EmployeeDetail.vue
Normal file
348
frontend/src/views/employees/EmployeeDetail.vue
Normal file
@@ -0,0 +1,348 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<template v-else-if="employee">
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="employee.photourl">
|
||||
<img :src="employee.photourl" :alt="fullName" />
|
||||
</div>
|
||||
<div class="hero-image placeholder" v-else>
|
||||
<span class="initials">{{ initials }}</span>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<h1 class="hero-title">{{ fullName }}</h1>
|
||||
<div class="hero-meta">
|
||||
<span class="badge">SSO: {{ employee.SSO }}</span>
|
||||
<span v-if="employee.Team" class="badge badge-primary">{{ employee.Team }}</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<p v-if="employee.Role"><strong>Role:</strong> {{ employee.Role }}</p>
|
||||
</div>
|
||||
<div class="hero-actions">
|
||||
<router-link to="/" class="btn">Back to Dashboard</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recognitions -->
|
||||
<div class="section-card">
|
||||
<h2 class="section-title">
|
||||
Recognitions
|
||||
<span v-if="recognitions.length > 0" class="count-badge">{{ recognitions.length }}</span>
|
||||
</h2>
|
||||
<div v-if="recognitionsLoading" class="loading">Loading...</div>
|
||||
<div v-else-if="recognitions.length === 0" class="empty">
|
||||
No recognitions yet.
|
||||
</div>
|
||||
<div v-else class="recognitions-list">
|
||||
<div v-for="rec in displayedRecognitions" :key="rec.notificationid" class="recognition-card">
|
||||
<div class="recognition-header">
|
||||
<span class="badge" :style="{ backgroundColor: rec.typecolor || '#14abef' }">
|
||||
{{ rec.typename || 'Recognition' }}
|
||||
</span>
|
||||
<span class="recognition-date">{{ formatDate(rec.startdate) }}</span>
|
||||
</div>
|
||||
<div class="recognition-message">{{ rec.notification }}</div>
|
||||
</div>
|
||||
<button
|
||||
v-if="recognitions.length > recognitionsLimit && !showAllRecognitions"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllRecognitions = true"
|
||||
>
|
||||
Show {{ recognitions.length - recognitionsLimit }} more
|
||||
</button>
|
||||
<button
|
||||
v-if="showAllRecognitions && recognitions.length > recognitionsLimit"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllRecognitions = false"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Currently Checked Out USB Devices -->
|
||||
<div v-if="usbEnabled" class="section-card">
|
||||
<h2 class="section-title">Checked Out USB Devices</h2>
|
||||
<div v-if="usbLoading" class="loading">Loading...</div>
|
||||
<div v-else-if="usbDevices.length === 0" class="empty">
|
||||
No USB devices currently checked out.
|
||||
</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="checkout in usbDevices" :key="checkout.log_id">
|
||||
<td>
|
||||
<router-link :to="`/usb/${checkout.device_id}`">
|
||||
{{ checkout.device_id }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ formatDate(checkout.timestamp) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-small btn-success" @click="checkinDevice(checkout)">
|
||||
Check In
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- USB Checkout History -->
|
||||
<div v-if="usbEnabled" class="section-card">
|
||||
<h2 class="section-title">USB Checkout History</h2>
|
||||
<div v-if="historyLoading" class="loading">Loading...</div>
|
||||
<div v-else-if="checkoutHistory.length === 0" class="empty">
|
||||
No checkout history.
|
||||
</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Device</th>
|
||||
<th>Checked Out</th>
|
||||
<th>Device Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="record in displayedHistory" :key="record.log_id">
|
||||
<td>
|
||||
<router-link :to="`/usb/${record.device_id}`">
|
||||
{{ record.device_id }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ formatDate(record.timestamp) }}</td>
|
||||
<td>{{ record.device_status || '-' }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<button
|
||||
v-if="checkoutHistory.length > historyLimit && !showAllHistory"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllHistory = true"
|
||||
>
|
||||
Show {{ checkoutHistory.length - historyLimit }} more
|
||||
</button>
|
||||
<button
|
||||
v-if="showAllHistory && checkoutHistory.length > historyLimit"
|
||||
class="btn btn-secondary show-more-btn"
|
||||
@click="showAllHistory = false"
|
||||
>
|
||||
Show less
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { employeesApi, usbApi, notificationsApi } from '@/api'
|
||||
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
|
||||
import { useToast } from '../../composables/toast'
|
||||
const toast = useToast()
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const employee = ref(null)
|
||||
const recognitions = ref([])
|
||||
const usbDevices = ref([])
|
||||
const checkoutHistory = ref([])
|
||||
const usbEnabled = ref(true)
|
||||
const loading = ref(true)
|
||||
const recognitionsLoading = ref(true)
|
||||
const usbLoading = ref(true)
|
||||
const historyLoading = ref(true)
|
||||
const error = ref('')
|
||||
|
||||
const recognitionsLimit = 5
|
||||
const showAllRecognitions = ref(false)
|
||||
|
||||
const displayedRecognitions = computed(() => {
|
||||
if (showAllRecognitions.value) {
|
||||
return recognitions.value
|
||||
}
|
||||
return recognitions.value.slice(0, recognitionsLimit)
|
||||
})
|
||||
|
||||
const historyLimit = 10
|
||||
const showAllHistory = ref(false)
|
||||
|
||||
const displayedHistory = computed(() => {
|
||||
if (showAllHistory.value) {
|
||||
return checkoutHistory.value
|
||||
}
|
||||
return checkoutHistory.value.slice(0, historyLimit)
|
||||
})
|
||||
|
||||
const fullName = computed(() => {
|
||||
if (!employee.value) return ''
|
||||
return `${employee.value.First_Name?.trim() || ''} ${employee.value.Last_Name?.trim() || ''}`.trim()
|
||||
})
|
||||
|
||||
const initials = computed(() => {
|
||||
if (!employee.value) return '?'
|
||||
const first = employee.value.First_Name?.trim()?.[0] || ''
|
||||
const last = employee.value.Last_Name?.trim()?.[0] || ''
|
||||
return (first + last).toUpperCase() || '?'
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await loadEmployee()
|
||||
// Skip the USB panels entirely when the usb plugin is disabled - its
|
||||
// /api/usb routes 404 otherwise and spam the console.
|
||||
await loadEnabledPlugins()
|
||||
usbEnabled.value = isPluginEnabled('usb')
|
||||
const tasks = [loadRecognitions()]
|
||||
if (usbEnabled.value) tasks.push(loadUSBDevices(), loadCheckoutHistory())
|
||||
await Promise.all(tasks)
|
||||
})
|
||||
|
||||
async function loadEmployee() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await employeesApi.lookup(route.params.sso)
|
||||
employee.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading employee:', err)
|
||||
error.value = 'Employee not found'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadRecognitions() {
|
||||
recognitionsLoading.value = true
|
||||
try {
|
||||
const response = await notificationsApi.getEmployeeRecognitions(route.params.sso)
|
||||
recognitions.value = response.data.data?.recognitions || []
|
||||
} catch (err) {
|
||||
console.error('Error loading recognitions:', err)
|
||||
recognitions.value = []
|
||||
} finally {
|
||||
recognitionsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadUSBDevices() {
|
||||
usbLoading.value = true
|
||||
try {
|
||||
// active check-out log rows for this badge
|
||||
const response = await usbApi.getUserCheckouts(route.params.sso)
|
||||
usbDevices.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading USB devices:', err)
|
||||
} finally {
|
||||
usbLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCheckoutHistory() {
|
||||
historyLoading.value = true
|
||||
try {
|
||||
// all check-out log rows for this badge, newest first
|
||||
const response = await usbApi.getUserCheckouts(route.params.sso, false)
|
||||
checkoutHistory.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading checkout history:', err)
|
||||
checkoutHistory.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkinDevice(checkout) {
|
||||
if (!confirm(`Check in ${checkout.device_id}?`)) return
|
||||
|
||||
try {
|
||||
await usbApi.checkin(checkout.device_id, { badge: checkout.badge_number || route.params.sso })
|
||||
await loadUSBDevices()
|
||||
await loadCheckoutHistory()
|
||||
} catch (err) {
|
||||
console.error('Error checking in device:', err)
|
||||
toast.error('Failed to check in device')
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-image.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.initials {
|
||||
font-size: 3rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.recognitions-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.recognition-card {
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
border-left: 4px solid var(--primary);
|
||||
}
|
||||
|
||||
.recognition-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.recognition-date {
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.recognition-message {
|
||||
white-space: pre-wrap;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
padding: 0 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.show-more-btn {
|
||||
width: 100%;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
161
frontend/src/views/geenforce/EnforcementReports.vue
Normal file
161
frontend/src/views/geenforce/EnforcementReports.vue
Normal file
@@ -0,0 +1,161 @@
|
||||
<template>
|
||||
<div class="enforcement-reports">
|
||||
<div class="page-header">
|
||||
<h2>Enforcement Reports</h2>
|
||||
</div>
|
||||
<p class="setting-description">
|
||||
Latest GE-Enforce result reported by each PC. "Received" means the PC picked
|
||||
up the current published manifest; status shows self-heal and failures.
|
||||
</p>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="filters">
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="filterHost"
|
||||
placeholder="Filter hostname"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="form-control"
|
||||
v-model="filterScope"
|
||||
placeholder="Filter PC type"
|
||||
@keyup.enter="load"
|
||||
/>
|
||||
<button class="btn btn-primary" @click="load">Filter</button>
|
||||
<button class="btn btn-secondary" @click="clearFilters">Clear</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th><th>PC type</th><th>Received</th><th>Version</th>
|
||||
<th>Status</th><th>Installed</th><th>Skipped</th><th>Failed</th>
|
||||
<th>Last check-in</th><th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="report in reports" :key="report.reportid">
|
||||
<td>{{ report.hostname }}</td>
|
||||
<td>{{ report.scopename }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="report.receivedlatest ? 'badge-success' : 'badge-warning'">
|
||||
{{ report.receivedlatest ? 'yes' : 'behind' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="muted">{{ report.appliedversion ?? '-' }} / {{ report.latestversion ?? '-' }}</td>
|
||||
<td><span class="badge" :class="statusClass(report.status)">{{ report.status }}</span></td>
|
||||
<td>{{ report.installed }}</td>
|
||||
<td class="muted">{{ report.skipped }}</td>
|
||||
<td :class="{ 'fail-count': report.failed }">{{ report.failed }}</td>
|
||||
<td class="muted">{{ formatDate(report.lastcheckin || report.receivedat) }}</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-sm btn-secondary" @click="openDetail(report.reportid)">Detail</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="!reports.length"><td colspan="10" class="empty">No reports yet.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detail modal -->
|
||||
<div v-if="detail" class="modal-overlay" @click.self="detail = null">
|
||||
<div class="modal modal-report">
|
||||
<div class="modal-header">
|
||||
<h3>{{ detail.hostname }} - {{ detail.scopename }}</h3>
|
||||
<button class="modal-close" @click="detail = null">x</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="setting-description">
|
||||
Applied v{{ detail.appliedversion ?? '-' }} of v{{ detail.latestversion ?? '-' }};
|
||||
status {{ detail.status }}
|
||||
</p>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Entry</th><th>Action</th><th>Self-heal</th><th>Exit</th><th>Message</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="(result, index) in detail.results" :key="index">
|
||||
<td>{{ result.entryname }}</td>
|
||||
<td><span class="badge" :class="actionClass(result.action)">{{ result.action }}</span></td>
|
||||
<td>{{ result.selfhealed ? 'yes' : '' }}</td>
|
||||
<td class="muted">{{ result.exitcode ?? '' }}</td>
|
||||
<td class="muted">{{ result.message }}</td>
|
||||
</tr>
|
||||
<tr v-if="!detail.results.length"><td colspan="5" class="empty">No per-entry detail.</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="detail = null">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import api from '../../api'
|
||||
|
||||
const reports = ref([])
|
||||
const detail = ref(null)
|
||||
const error = ref('')
|
||||
const filterHost = ref('')
|
||||
const filterScope = ref('')
|
||||
|
||||
function payload(response) { return response.data.data }
|
||||
|
||||
async function load() {
|
||||
const params = {}
|
||||
if (filterHost.value) params.hostname = filterHost.value
|
||||
if (filterScope.value) params.scopename = filterScope.value
|
||||
try {
|
||||
reports.value = payload(await api.get('/geenforce/reports', { params }))
|
||||
error.value = ''
|
||||
} catch (e) { error.value = 'Failed to load reports' }
|
||||
}
|
||||
function clearFilters() { filterHost.value = ''; filterScope.value = ''; load() }
|
||||
async function openDetail(reportid) {
|
||||
try {
|
||||
detail.value = payload(await api.get(`/geenforce/reports/${reportid}`))
|
||||
} catch (e) { error.value = 'Failed to load report detail' }
|
||||
}
|
||||
|
||||
function statusClass(status) {
|
||||
return { ok: 'badge-success', selfhealed: 'badge-info', failed: 'badge-danger' }[status] || ''
|
||||
}
|
||||
function actionClass(action) {
|
||||
return { installed: 'badge-info', skipped: 'badge-success', failed: 'badge-danger',
|
||||
filtered: '' }[action] || ''
|
||||
}
|
||||
function formatDate(value) { return value ? new Date(value).toLocaleString() : '' }
|
||||
|
||||
load()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.enforcement-reports { max-width: 1100px; }
|
||||
.muted { color: var(--text-light); }
|
||||
.fail-count { color: var(--danger); font-weight: 600; }
|
||||
.empty { color: var(--text-light); text-align: center; padding: 1rem; }
|
||||
.modal-report { max-width: 640px; }
|
||||
.modal-close {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--text-light);
|
||||
font-size: 1.1rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
.modal-close:hover { color: var(--text); }
|
||||
</style>
|
||||
43
frontend/src/views/geenforce/GeEnforceLayout.vue
Normal file
43
frontend/src/views/geenforce/GeEnforceLayout.vue
Normal file
@@ -0,0 +1,43 @@
|
||||
<template>
|
||||
<div class="geenforce-section">
|
||||
<div class="section-header">
|
||||
<h1>GE-Enforce</h1>
|
||||
<p class="section-sub">Desired-state install manifests for imaging PC types, and the fleet's reported results.</p>
|
||||
</div>
|
||||
<nav class="section-tabs">
|
||||
<router-link to="/geenforce/manifests" class="tab">Manifests</router-link>
|
||||
<router-link to="/geenforce/reports" class="tab">Enforcement Reports</router-link>
|
||||
</nav>
|
||||
<router-view />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// Tabbed shell for the GE-Enforce section. Children render the manifest editor
|
||||
// and the fleet-compliance reports full-width (not squeezed into the settings rail).
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.geenforce-section { max-width: 1400px; }
|
||||
.section-header { margin-bottom: 0.5rem; }
|
||||
.section-header h1 { margin: 0; }
|
||||
.section-sub { color: var(--text-light); margin: 0.25rem 0 0; }
|
||||
.section-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin: 1rem 0 1.25rem;
|
||||
}
|
||||
.tab {
|
||||
padding: 0.5rem 0.9rem;
|
||||
text-decoration: none;
|
||||
color: var(--text-light);
|
||||
border-bottom: 2px solid transparent;
|
||||
font-weight: 500;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.router-link-active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
1051
frontend/src/views/geenforce/ManifestEditor.vue
Normal file
1051
frontend/src/views/geenforce/ManifestEditor.vue
Normal file
File diff suppressed because it is too large
Load Diff
183
frontend/src/views/geenforce/entryForm.js
Normal file
183
frontend/src/views/geenforce/entryForm.js
Normal file
@@ -0,0 +1,183 @@
|
||||
// Pure, framework-free helpers for the GE-Enforce manifest editor.
|
||||
//
|
||||
// ManifestEditor.vue imports these directly (the component no longer keeps its
|
||||
// own copies), so the unit tests in entryForm.spec.js exercise the shipped
|
||||
// code path. Change the editor logic HERE.
|
||||
//
|
||||
// Everything here is a plain function of its inputs. No Vue, no reactivity,
|
||||
// no network. That is the whole point - deterministic logic we can pin down.
|
||||
|
||||
export const ENTRY_TYPES = ['MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry']
|
||||
export const REG_TYPES = ['String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary']
|
||||
export const DETECTION_METHODS = ['Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
|
||||
'ValueMatches', 'pnputil', 'Always']
|
||||
export const INUSE_BEHAVIORS = ['Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot']
|
||||
|
||||
// Plain-language description of each detection method, shown under the Detection
|
||||
// method dropdown so a first-time site admin understands what "present" means
|
||||
// for the method they picked. Key '' is the no-detection case.
|
||||
export const DETECTION_METHOD_HINTS = {
|
||||
'': 'No detection rule: the action runs every cycle.',
|
||||
Registry: 'Already correct if the registry value at Detection path/name exists (and equals Detection value when one is set).',
|
||||
File: 'Already correct if the file at Detection path exists.',
|
||||
FileVersion: 'Already correct if the file at Detection path is at Detection value or newer. This target feeds the Compliance panel.',
|
||||
Hash: 'Already correct if the file at Detection path matches the SHA256 in Detection value. Re-copies when the file changed.',
|
||||
MarkerFile: 'Already correct if the marker file at Detection path exists. Installs once, then the marker suppresses reruns.',
|
||||
ValueMatches: 'Already correct if the registry value at Detection path/name equals Detection value exactly.',
|
||||
pnputil: 'Already correct if a driver matching Detection pattern is staged in the Windows driver store. For INF entries.',
|
||||
Always: 'Never counts as present, so the action runs every cycle. Same effect as no detection rule.',
|
||||
}
|
||||
|
||||
// Description for the currently selected detection method, or '' if unknown.
|
||||
export function detectionMethodHint(method) {
|
||||
return DETECTION_METHOD_HINTS[method || ''] || ''
|
||||
}
|
||||
|
||||
// One blank entry form, matching the shape ManifestEditor seeds for a new entry.
|
||||
export function blankEntry() {
|
||||
return { Type: 'MSI', DetectionMethod: '', RegType: 'String',
|
||||
inuseBehavior: '', inuseProcesses: [], appid: null,
|
||||
PreEnrollment: false, KillAfterDetection: false, PCTypesStrict: false }
|
||||
}
|
||||
|
||||
// Split a comma list into trimmed non-empty parts.
|
||||
export function splitList(value) {
|
||||
return (value || '').split(',').map(item => item.trim()).filter(Boolean)
|
||||
}
|
||||
|
||||
// Turn an edit-form object into the API body sent to create/update an entry.
|
||||
//
|
||||
// Rules that matter (and that the specs pin):
|
||||
// - appid is always present (null unlinks). It is shopdb metadata; the backend
|
||||
// keeps it OFF the manifest JSON, so it is NOT one of the manifest scalars.
|
||||
// - empty/undefined/null scalars are dropped.
|
||||
// - Registry DWord/QWord values become real numbers; other reg types stay strings.
|
||||
// - comma lists become arrays, and are dropped when empty.
|
||||
// - preinstall flags only appear when truthy.
|
||||
// - InUseCheck carries per-process Name, optional ExePath, optional numeric timeout.
|
||||
export function buildEntryPayload(form) {
|
||||
// appid is shopdb metadata, always sent (null unlinks); the backend keeps it
|
||||
// off the manifest JSON.
|
||||
const out = { Name: form.Name, Type: form.Type, appid: form.appid ?? null }
|
||||
const scalars = ['Installer', 'InstallArgs', 'Script', 'Args', 'Source',
|
||||
'Destination', 'RegPath', 'RegName', 'RegType', 'DetectionPath',
|
||||
'DetectionName', 'DetectionValue', 'DetectionPattern', '_CmmVersion',
|
||||
'LogFile', 'ApplyMode', 'UpdateWindow', '_comment']
|
||||
for (const key of scalars) {
|
||||
if (form[key] !== undefined && form[key] !== '' && form[key] !== null) out[key] = form[key]
|
||||
}
|
||||
if (form.DetectionMethod) out.DetectionMethod = form.DetectionMethod
|
||||
if (form.WaitTimeoutSec) out.WaitTimeoutSec = form.WaitTimeoutSec
|
||||
if (form.Type === 'Registry' && form.RegValue !== undefined && form.RegValue !== '') {
|
||||
out.RegValue = ['DWord', 'QWord'].includes(form.RegType) ? Number(form.RegValue) : form.RegValue
|
||||
}
|
||||
const pctypes = splitList(form.PCTypes)
|
||||
if (pctypes.length) out.PCTypes = pctypes
|
||||
const hostnames = splitList(form.TargetHostnames)
|
||||
if (hostnames.length) out.TargetHostnames = hostnames
|
||||
const machinenumbers = splitList(form.TargetMachineNumbers)
|
||||
if (machinenumbers.length) out.TargetMachineNumbers = machinenumbers
|
||||
for (const flag of ['PreEnrollment', 'KillAfterDetection', 'PCTypesStrict']) {
|
||||
if (form[flag]) out[flag] = true
|
||||
}
|
||||
if (form.inuseBehavior) {
|
||||
out.InUseCheck = {
|
||||
Behavior: form.inuseBehavior,
|
||||
Processes: (form.inuseProcesses || []).filter(process => process.name).map(process => {
|
||||
const processData = { Name: process.name }
|
||||
if (process.exepath) processData.ExePath = process.exepath
|
||||
if (process.timeout !== null && process.timeout !== '' && process.timeout !== undefined) {
|
||||
processData.GracefulCloseTimeoutSec = Number(process.timeout)
|
||||
}
|
||||
return processData
|
||||
}),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// Which entry types the type dropdown offers, given the scope phase. Preinstall
|
||||
// supports MSI/EXE only, but keeps a current out-of-set value visible.
|
||||
export function availableEntryTypes(isPreinstall, currentType) {
|
||||
if (!isPreinstall) return ENTRY_TYPES
|
||||
const allowed = ['MSI', 'EXE']
|
||||
return currentType && !allowed.includes(currentType) ? [...allowed, currentType] : allowed
|
||||
}
|
||||
|
||||
// Which detection methods the dropdown offers. Preinstall supports Registry/File
|
||||
// only, but keeps a current out-of-set value visible.
|
||||
export function availableDetectionMethods(isPreinstall, currentMethod) {
|
||||
if (!isPreinstall) return DETECTION_METHODS
|
||||
const allowed = ['Registry', 'File']
|
||||
return currentMethod && !allowed.includes(currentMethod) ? [...allowed, currentMethod] : allowed
|
||||
}
|
||||
|
||||
// Which targeting gates a scope actually surfaces by default (before "Show all").
|
||||
export function targetingGates(scope) {
|
||||
if (!scope) {
|
||||
return { pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false }
|
||||
}
|
||||
const name = (scope.scopename || '').toLowerCase()
|
||||
const entries = scope.entries || []
|
||||
const fleetwide = scope.iscommon || scope.phase === 'preinstall' || name === 'common'
|
||||
return {
|
||||
pctypes: fleetwide || entries.some(e => (e.PCTypes || []).length),
|
||||
cmmversion: /cmm/.test(name) || entries.some(e => e._CmmVersion),
|
||||
// Data-driven (a loose name match would wrongly flag 'nocollections').
|
||||
machinenumbers: entries.some(e => (e.TargetMachineNumbers || []).length),
|
||||
hostnames: entries.some(e => (e.TargetHostnames || []).length),
|
||||
}
|
||||
}
|
||||
|
||||
// The contextual hint under the Targeting header.
|
||||
export function targetingHint(scope) {
|
||||
if (!scope) return ''
|
||||
if (scope.iscommon || scope.scopename === 'common') {
|
||||
return 'Fleet-wide manifest: use PC types to target which types get this entry.'
|
||||
}
|
||||
if (scope.phase === 'preinstall') {
|
||||
return 'Preinstall manifest: use PC types to target; preinstall flags apply here.'
|
||||
}
|
||||
return `This manifest already runs only on ${scope.scopename} PCs. `
|
||||
+ 'Targeting below narrows within that (version, bay, or subtype).'
|
||||
}
|
||||
|
||||
// One-line summary of what a scope installs (installer entries only).
|
||||
export function scopeSummary(scope) {
|
||||
if (!scope || !scope.entries || !scope.entries.length) return ''
|
||||
const installers = scope.entries
|
||||
.filter(e => ['MSI', 'EXE', 'CMD', 'BAT'].includes(e.Type))
|
||||
.map(e => e.Name)
|
||||
const shown = installers.slice(0, 6).join(', ')
|
||||
const more = installers.length > 6 ? `, +${installers.length - 6} more` : ''
|
||||
const apps = installers.length ? `Installs ${shown}${more}. ` : ''
|
||||
return `${apps}${scope.entries.length} entries; runs after common.`
|
||||
}
|
||||
|
||||
// Verb table for describeEntry.
|
||||
export const ACTION_VERB = {
|
||||
MSI: 'Installs', EXE: 'Installs', CMD: 'Runs', BAT: 'Runs',
|
||||
PS1: 'Runs a script for', INF: 'Installs a driver for',
|
||||
File: 'Copies a file for', Registry: 'Sets a registry value for',
|
||||
}
|
||||
|
||||
// Plain-English one-liner: what an entry does, when it self-heals, who it hits.
|
||||
export function describeEntry(entry) {
|
||||
let text = `${ACTION_VERB[entry.Type] || 'Applies'} ${entry.Name}`
|
||||
const method = entry.DetectionMethod
|
||||
if (!method || method === 'Always') text += '; runs every cycle'
|
||||
else if (method === 'FileVersion') text += `; reinstalls unless version is ${entry.DetectionValue || 'set'}`
|
||||
else if (method === 'Hash') text += '; re-copies if the file changed'
|
||||
else if (method === 'MarkerFile') text += '; installs once'
|
||||
else text += '; reinstalls if not detected'
|
||||
if (entry._CmmVersion) text += `; CMM ${entry._CmmVersion} bays only`
|
||||
else if (entry.TargetMachineNumbers && entry.TargetMachineNumbers.length) {
|
||||
text += `; ${entry.TargetMachineNumbers.length} specific bay(s)`
|
||||
} else if (entry.TargetHostnames && entry.TargetHostnames.length) {
|
||||
text += '; specific hostname(s)'
|
||||
} else if (entry.PCTypes && entry.PCTypes.length) {
|
||||
text += `; ${entry.PCTypes.length} PC type(s)`
|
||||
}
|
||||
if (entry.appname) text += `; tracked: ${entry.appname}`
|
||||
return text
|
||||
}
|
||||
321
frontend/src/views/geenforce/entryForm.spec.js
Normal file
321
frontend/src/views/geenforce/entryForm.spec.js
Normal file
@@ -0,0 +1,321 @@
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import {
|
||||
blankEntry,
|
||||
splitList,
|
||||
buildEntryPayload,
|
||||
availableEntryTypes,
|
||||
availableDetectionMethods,
|
||||
targetingGates,
|
||||
targetingHint,
|
||||
scopeSummary,
|
||||
describeEntry,
|
||||
detectionMethodHint,
|
||||
ENTRY_TYPES,
|
||||
DETECTION_METHODS,
|
||||
} from './entryForm.js'
|
||||
|
||||
// These specs pin the deterministic logic that turns the manifest editor form
|
||||
// into an API body, and the various display helpers. This is the code most
|
||||
// likely to silently regress (numeric coercion, dropped-vs-kept fields, the
|
||||
// appid-must-never-be-a-manifest-scalar rule).
|
||||
|
||||
describe('splitList', () => {
|
||||
it('trims, drops empties, and returns an array', () => {
|
||||
expect(splitList('a, b ,, c ')).toEqual(['a', 'b', 'c'])
|
||||
})
|
||||
it('returns an empty array for null/empty', () => {
|
||||
expect(splitList('')).toEqual([])
|
||||
expect(splitList(null)).toEqual([])
|
||||
expect(splitList(undefined)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntryPayload - appid handling', () => {
|
||||
it('always emits appid as a top-level key, defaulting to null', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'MSI' })
|
||||
expect(out.appid).toBeNull()
|
||||
expect('appid' in out).toBe(true)
|
||||
})
|
||||
it('passes a linked appid through unchanged', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', appid: 41 })
|
||||
expect(out.appid).toBe(41)
|
||||
})
|
||||
it('never lets appid become a manifest scalar (it is metadata, not a manifest key)', () => {
|
||||
// appid lives at the top level as metadata; it must not appear nested in
|
||||
// any manifest sub-structure. Guard against a future refactor smuggling it
|
||||
// into e.g. InUseCheck or a detection block.
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'Registry', appid: 7, RegType: 'DWord', RegValue: '1',
|
||||
DetectionMethod: 'Registry', inuseBehavior: 'ForceClose',
|
||||
inuseProcesses: [{ name: 'p', exepath: '', timeout: null }],
|
||||
})
|
||||
// Only the top-level appid key carries it.
|
||||
const serialized = JSON.stringify({ ...out, appid: undefined })
|
||||
expect(serialized.includes('appid')).toBe(false)
|
||||
expect(serialized.toLowerCase().includes('"appid"')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntryPayload - scalar filtering', () => {
|
||||
it('drops empty-string, null, and undefined scalars', () => {
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'MSI', Installer: '', InstallArgs: null,
|
||||
Script: undefined, LogFile: 'setup.log',
|
||||
})
|
||||
expect('Installer' in out).toBe(false)
|
||||
expect('InstallArgs' in out).toBe(false)
|
||||
expect('Script' in out).toBe(false)
|
||||
expect(out.LogFile).toBe('setup.log')
|
||||
})
|
||||
it('keeps the underscore-prefixed scalars (_CmmVersion, _comment)', () => {
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'MSI', _CmmVersion: '2019', _comment: 'note',
|
||||
})
|
||||
expect(out._CmmVersion).toBe('2019')
|
||||
expect(out._comment).toBe('note')
|
||||
})
|
||||
it('only emits DetectionMethod when set', () => {
|
||||
expect('DetectionMethod' in buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: '' })).toBe(false)
|
||||
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', DetectionMethod: 'FileVersion' }).DetectionMethod).toBe('FileVersion')
|
||||
})
|
||||
it('drops a zero/falsy WaitTimeoutSec but keeps a real one', () => {
|
||||
expect('WaitTimeoutSec' in buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 0 })).toBe(false)
|
||||
expect(buildEntryPayload({ Name: 'x', Type: 'MSI', WaitTimeoutSec: 300 }).WaitTimeoutSec).toBe(300)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntryPayload - RegValue coercion', () => {
|
||||
it('coerces DWord to a Number', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '1' })
|
||||
expect(out.RegValue).toBe(1)
|
||||
expect(typeof out.RegValue).toBe('number')
|
||||
})
|
||||
it('coerces QWord to a Number', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'QWord', RegValue: '42' })
|
||||
expect(out.RegValue).toBe(42)
|
||||
})
|
||||
it('leaves String reg values as strings', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'String', RegValue: 'hello' })
|
||||
expect(out.RegValue).toBe('hello')
|
||||
expect(typeof out.RegValue).toBe('string')
|
||||
})
|
||||
it('does not emit RegValue for a non-Registry type', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', RegType: 'DWord', RegValue: '1' })
|
||||
expect('RegValue' in out).toBe(false)
|
||||
})
|
||||
it('drops an empty RegValue even for Registry', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'Registry', RegType: 'DWord', RegValue: '' })
|
||||
expect('RegValue' in out).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntryPayload - comma lists to arrays', () => {
|
||||
it('splits PCTypes / TargetHostnames / TargetMachineNumbers', () => {
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'MSI',
|
||||
PCTypes: 'gea-shopfloor-cmm, gea-shopfloor-collections',
|
||||
TargetHostnames: 'host-a, host-b',
|
||||
TargetMachineNumbers: '0101, 0102',
|
||||
})
|
||||
expect(out.PCTypes).toEqual(['gea-shopfloor-cmm', 'gea-shopfloor-collections'])
|
||||
expect(out.TargetHostnames).toEqual(['host-a', 'host-b'])
|
||||
expect(out.TargetMachineNumbers).toEqual(['0101', '0102'])
|
||||
})
|
||||
it('omits the array keys when the list is empty', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', PCTypes: '', TargetHostnames: ' ' })
|
||||
expect('PCTypes' in out).toBe(false)
|
||||
expect('TargetHostnames' in out).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntryPayload - preinstall flags', () => {
|
||||
it('emits only the truthy flags', () => {
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'MSI',
|
||||
PreEnrollment: true, KillAfterDetection: false, PCTypesStrict: true,
|
||||
})
|
||||
expect(out.PreEnrollment).toBe(true)
|
||||
expect(out.PCTypesStrict).toBe(true)
|
||||
expect('KillAfterDetection' in out).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntryPayload - InUseCheck processes', () => {
|
||||
it('carries Name, optional ExePath, and a numeric timeout per process', () => {
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'MSI', inuseBehavior: 'CloseAndReopen',
|
||||
inuseProcesses: [
|
||||
{ name: 'pcdmis', exepath: 'C:\\pcd\\pcdmis.exe', timeout: 30 },
|
||||
{ name: 'nodmis', exepath: '', timeout: null },
|
||||
],
|
||||
})
|
||||
expect(out.InUseCheck.Behavior).toBe('CloseAndReopen')
|
||||
expect(out.InUseCheck.Processes).toEqual([
|
||||
{ Name: 'pcdmis', ExePath: 'C:\\pcd\\pcdmis.exe', GracefulCloseTimeoutSec: 30 },
|
||||
{ Name: 'nodmis' },
|
||||
])
|
||||
})
|
||||
it('drops processes with no name', () => {
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'MSI', inuseBehavior: 'ForceClose',
|
||||
inuseProcesses: [{ name: '', exepath: 'x', timeout: 5 }, { name: 'keep', timeout: 5 }],
|
||||
})
|
||||
expect(out.InUseCheck.Processes).toEqual([{ Name: 'keep', GracefulCloseTimeoutSec: 5 }])
|
||||
})
|
||||
it('coerces a string timeout to a number', () => {
|
||||
const out = buildEntryPayload({
|
||||
Name: 'x', Type: 'MSI', inuseBehavior: 'Defer',
|
||||
inuseProcesses: [{ name: 'p', exepath: '', timeout: '15' }],
|
||||
})
|
||||
expect(out.InUseCheck.Processes[0].GracefulCloseTimeoutSec).toBe(15)
|
||||
})
|
||||
it('emits no InUseCheck when there is no behavior', () => {
|
||||
const out = buildEntryPayload({ Name: 'x', Type: 'MSI', inuseBehavior: '' })
|
||||
expect('InUseCheck' in out).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEntryPayload - a blank new entry round-trips', () => {
|
||||
it('produces a minimal body from blankEntry (name/type + null appid + default RegType)', () => {
|
||||
const out = buildEntryPayload({ ...blankEntry(), Name: 'Fresh' })
|
||||
// Type MSI, appid null. RegType is default String but non-Registry, so it
|
||||
// is still carried as a scalar (it is in the scalar list). Confirm the shape.
|
||||
expect(out.Name).toBe('Fresh')
|
||||
expect(out.Type).toBe('MSI')
|
||||
expect(out.appid).toBeNull()
|
||||
expect('InUseCheck' in out).toBe(false)
|
||||
expect('PCTypes' in out).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('availableEntryTypes', () => {
|
||||
it('returns the full list for a non-preinstall scope', () => {
|
||||
expect(availableEntryTypes(false, 'File')).toEqual(ENTRY_TYPES)
|
||||
})
|
||||
it('restricts to MSI/EXE for preinstall', () => {
|
||||
expect(availableEntryTypes(true, 'MSI')).toEqual(['MSI', 'EXE'])
|
||||
})
|
||||
it('keeps an out-of-set current value visible in preinstall', () => {
|
||||
expect(availableEntryTypes(true, 'Registry')).toEqual(['MSI', 'EXE', 'Registry'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('availableDetectionMethods', () => {
|
||||
it('returns the full list for a non-preinstall scope', () => {
|
||||
expect(availableDetectionMethods(false, 'Hash')).toEqual(DETECTION_METHODS)
|
||||
})
|
||||
it('restricts to Registry/File for preinstall', () => {
|
||||
expect(availableDetectionMethods(true, 'File')).toEqual(['Registry', 'File'])
|
||||
})
|
||||
it('keeps an out-of-set current value visible in preinstall', () => {
|
||||
expect(availableDetectionMethods(true, 'FileVersion')).toEqual(['Registry', 'File', 'FileVersion'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('targetingGates', () => {
|
||||
it('defaults to pctypes-only when there is no scope', () => {
|
||||
expect(targetingGates(null)).toEqual({
|
||||
pctypes: true, cmmversion: false, machinenumbers: false, hostnames: false,
|
||||
})
|
||||
})
|
||||
it('shows pctypes for a fleetwide/common/preinstall scope', () => {
|
||||
expect(targetingGates({ scopename: 'common', entries: [] }).pctypes).toBe(true)
|
||||
expect(targetingGates({ scopename: 'x', phase: 'preinstall', entries: [] }).pctypes).toBe(true)
|
||||
expect(targetingGates({ scopename: 'x', iscommon: true, entries: [] }).pctypes).toBe(true)
|
||||
})
|
||||
it('shows the cmm gate for a cmm-named scope', () => {
|
||||
expect(targetingGates({ scopename: 'gea-shopfloor-cmm', entries: [] }).cmmversion).toBe(true)
|
||||
})
|
||||
it('does not name-match machinenumbers (data-driven only)', () => {
|
||||
// 'nocollections' must not trip a loose name match; machinenumbers is purely
|
||||
// data-driven off the entries.
|
||||
const gates = targetingGates({ scopename: 'nocollections', entries: [] })
|
||||
expect(gates.machinenumbers).toBe(false)
|
||||
})
|
||||
it('surfaces gates from entry data', () => {
|
||||
const gates = targetingGates({
|
||||
scopename: 'per-type', entries: [
|
||||
{ PCTypes: ['a'] }, { TargetMachineNumbers: ['0101'] }, { TargetHostnames: ['h'] },
|
||||
],
|
||||
})
|
||||
expect(gates.pctypes).toBe(true)
|
||||
expect(gates.machinenumbers).toBe(true)
|
||||
expect(gates.hostnames).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('targetingHint', () => {
|
||||
it('is empty with no scope', () => {
|
||||
expect(targetingHint(null)).toBe('')
|
||||
})
|
||||
it('describes a fleet-wide common manifest', () => {
|
||||
expect(targetingHint({ scopename: 'common' })).toMatch(/Fleet-wide/)
|
||||
})
|
||||
it('describes a preinstall manifest', () => {
|
||||
expect(targetingHint({ scopename: 'x', phase: 'preinstall' })).toMatch(/Preinstall/)
|
||||
})
|
||||
it('names a per-type scope', () => {
|
||||
expect(targetingHint({ scopename: 'gea-shopfloor-cmm' })).toMatch(/gea-shopfloor-cmm/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeSummary', () => {
|
||||
it('is empty with no entries', () => {
|
||||
expect(scopeSummary({ entries: [] })).toBe('')
|
||||
expect(scopeSummary(null)).toBe('')
|
||||
})
|
||||
it('lists installer names and the entry count', () => {
|
||||
const summary = scopeSummary({
|
||||
entries: [
|
||||
{ Type: 'MSI', Name: 'eDNC' },
|
||||
{ Type: 'Registry', Name: 'reg' },
|
||||
],
|
||||
})
|
||||
expect(summary).toBe('Installs eDNC. 2 entries; runs after common.')
|
||||
})
|
||||
it('truncates past six installers with a +N more', () => {
|
||||
const entries = Array.from({ length: 8 }, (_, i) => ({ Type: 'MSI', Name: `app${i}` }))
|
||||
const summary = scopeSummary({ entries })
|
||||
expect(summary).toMatch(/\+2 more/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('describeEntry', () => {
|
||||
it('describes an MSI with FileVersion detection using the expected version', () => {
|
||||
const text = describeEntry({ Type: 'MSI', Name: 'PC-DMIS', DetectionMethod: 'FileVersion', DetectionValue: '2019 R1' })
|
||||
expect(text).toBe('Installs PC-DMIS; reinstalls unless version is 2019 R1')
|
||||
})
|
||||
it('says runs every cycle for Always / no detection', () => {
|
||||
expect(describeEntry({ Type: 'CMD', Name: 'x' })).toBe('Runs x; runs every cycle')
|
||||
expect(describeEntry({ Type: 'CMD', Name: 'x', DetectionMethod: 'Always' })).toBe('Runs x; runs every cycle')
|
||||
})
|
||||
it('appends a CMM gate note', () => {
|
||||
const text = describeEntry({ Type: 'MSI', Name: 'x', _CmmVersion: '2016' })
|
||||
expect(text).toMatch(/CMM 2016 bays only/)
|
||||
})
|
||||
it('appends the tracked app name when present', () => {
|
||||
const text = describeEntry({ Type: 'MSI', Name: 'x', appname: 'PC-DMIS' })
|
||||
expect(text).toMatch(/tracked: PC-DMIS/)
|
||||
})
|
||||
it('falls back to Applies for an unknown type', () => {
|
||||
expect(describeEntry({ Type: 'Weird', Name: 'x' })).toMatch(/^Applies x/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectionMethodHint', () => {
|
||||
it('has a non-empty hint for every detection method', () => {
|
||||
for (const method of DETECTION_METHODS) {
|
||||
expect(detectionMethodHint(method).length).toBeGreaterThan(0)
|
||||
}
|
||||
})
|
||||
it('describes the no-detection case for empty/undefined', () => {
|
||||
expect(detectionMethodHint('')).toMatch(/every cycle/)
|
||||
expect(detectionMethodHint(undefined)).toMatch(/every cycle/)
|
||||
})
|
||||
it('ties FileVersion to the compliance panel', () => {
|
||||
expect(detectionMethodHint('FileVersion')).toMatch(/Compliance/)
|
||||
})
|
||||
it('returns empty string for an unknown method', () => {
|
||||
expect(detectionMethodHint('Nonsense')).toBe('')
|
||||
})
|
||||
})
|
||||
212
frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue
Normal file
212
frontend/src/views/knowledgebase/KnowledgeBaseDetail.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Knowledge Base Article</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/knowledgebase/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/knowledgebase" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="article">
|
||||
<div class="card article-card">
|
||||
<table class="info-table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th>Description:</th>
|
||||
<td>{{ article.shortdescription }}</td>
|
||||
</tr>
|
||||
<tr v-if="article.application">
|
||||
<th>Topic:</th>
|
||||
<td>
|
||||
<router-link :to="`/applications/${article.application.appid}`">
|
||||
{{ article.application.appname }}
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.linkurl">
|
||||
<th>URL:</th>
|
||||
<td>
|
||||
<a href="#" @click.prevent="openArticle">
|
||||
{{ article.linkurl }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="article.keywords">
|
||||
<th>Keywords:</th>
|
||||
<td>{{ article.keywords }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Clicks:</th>
|
||||
<td>{{ article.clicks }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Last Updated:</th>
|
||||
<td>{{ formatDate(article.lastupdated) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<hr />
|
||||
|
||||
<div class="actions-center">
|
||||
<a
|
||||
v-if="article.linkurl"
|
||||
href="#"
|
||||
class="btn btn-primary btn-lg"
|
||||
@click.prevent="openArticle"
|
||||
>
|
||||
<span class="btn-icon">↗</span> Open Article
|
||||
</a>
|
||||
<div v-else class="alert alert-warning">
|
||||
This article does not have a URL link defined. Please edit the article to add one.
|
||||
</div>
|
||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-secondary btn-lg">
|
||||
<span class="btn-icon">✎</span> Edit
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Article not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { knowledgebaseApi } from '../../api'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const loading = ref(true)
|
||||
const article = ref(null)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await knowledgebaseApi.get(route.params.id)
|
||||
article.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading article:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
|
||||
async function openArticle() {
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(article.value.linkid)
|
||||
article.value.clicks = (article.value.clicks || 0) + 1
|
||||
// Open the URL after tracking
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
// Still open even if tracking fails
|
||||
if (article.value.linkurl) {
|
||||
window.open(article.value.linkurl, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.detail-page {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.info-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.info-table th,
|
||||
.info-table td {
|
||||
padding: 0.75rem;
|
||||
text-align: left;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.info-table th {
|
||||
width: 150px;
|
||||
font-weight: 600;
|
||||
color: var(--text-light, #666);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.info-table td a {
|
||||
color: var(--primary, #1976d2);
|
||||
text-decoration: none;
|
||||
word-break: break-all;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
|
||||
.info-table td a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.info-table td {
|
||||
word-break: break-word;
|
||||
overflow-wrap: break-word;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
hr {
|
||||
margin: 1.5rem 0;
|
||||
border: none;
|
||||
border-top: 1px solid var(--border-color, #e5e5e5);
|
||||
}
|
||||
|
||||
.actions-center {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.alert {
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.alert-warning {
|
||||
background: #fff3cd;
|
||||
color: #856404;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.alert-warning {
|
||||
background: #3d3200;
|
||||
color: #ffc107;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
167
frontend/src/views/knowledgebase/KnowledgeBaseForm.vue
Normal file
167
frontend/src/views/knowledgebase/KnowledgeBaseForm.vue
Normal file
@@ -0,0 +1,167 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Article' : 'Add Knowledge Base Article' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveArticle">
|
||||
<div class="form-group">
|
||||
<label for="shortdescription">Description *</label>
|
||||
<input
|
||||
id="shortdescription"
|
||||
v-model="form.shortdescription"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="500"
|
||||
placeholder="Brief description of the article"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="linkurl">URL *</label>
|
||||
<input
|
||||
id="linkurl"
|
||||
v-model="form.linkurl"
|
||||
type="url"
|
||||
class="form-control"
|
||||
required
|
||||
maxlength="2000"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="keywords">Keywords</label>
|
||||
<input
|
||||
id="keywords"
|
||||
v-model="form.keywords"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="500"
|
||||
placeholder="Space-separated keywords"
|
||||
/>
|
||||
<small class="form-hint">Keywords help with search - separate with spaces</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="appid">Topic (Application)</label>
|
||||
<select
|
||||
id="appid"
|
||||
v-model="form.appid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">-- Select Topic (Optional) --</option>
|
||||
<option
|
||||
v-for="app in applications"
|
||||
:key="app.appid"
|
||||
:value="app.appid"
|
||||
>
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">Select the application/topic this article relates to</small>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Update Article' : 'Add Article') }}
|
||||
</button>
|
||||
<router-link to="/knowledgebase" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { knowledgebaseApi, applicationsApi } from '../../api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
shortdescription: '',
|
||||
linkurl: '',
|
||||
keywords: '',
|
||||
appid: ''
|
||||
})
|
||||
|
||||
const applications = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load applications for topic dropdown
|
||||
const appsRes = await applicationsApi.list({ perpage: 1000 })
|
||||
applications.value = appsRes.data.data || []
|
||||
|
||||
// Load article if editing
|
||||
if (isEdit.value) {
|
||||
const response = await knowledgebaseApi.get(route.params.id)
|
||||
const article = response.data.data
|
||||
|
||||
form.value = {
|
||||
shortdescription: article.shortdescription || '',
|
||||
linkurl: article.linkurl || '',
|
||||
keywords: article.keywords || '',
|
||||
appid: article.application?.appid || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveArticle() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const articleData = {
|
||||
shortdescription: form.value.shortdescription,
|
||||
linkurl: form.value.linkurl,
|
||||
keywords: form.value.keywords || null,
|
||||
appid: form.value.appid || null
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await knowledgebaseApi.update(route.params.id, articleData)
|
||||
} else {
|
||||
await knowledgebaseApi.create(articleData)
|
||||
}
|
||||
|
||||
router.push('/knowledgebase')
|
||||
} catch (err) {
|
||||
console.error('Error saving article:', err)
|
||||
error.value = apiError(err, 'Failed to save article')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
</style>
|
||||
263
frontend/src/views/knowledgebase/KnowledgeBaseList.vue
Normal file
263
frontend/src/views/knowledgebase/KnowledgeBaseList.vue
Normal file
@@ -0,0 +1,263 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<div class="header-left">
|
||||
<h2>Knowledge Base</h2>
|
||||
<span v-if="stats" class="stats-badge">
|
||||
{{ stats.totalarticles }} articles | {{ stats.totalclicks.toLocaleString() }} total clicks
|
||||
</span>
|
||||
</div>
|
||||
<router-link to="/knowledgebase/new" class="btn btn-primary">Add Article</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search articles..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="topicFilter" class="form-control" @change="loadArticles">
|
||||
<option value="">All Topics</option>
|
||||
<option
|
||||
v-for="app in topics"
|
||||
:key="app.appid"
|
||||
:value="app.appid"
|
||||
>
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="sortable" @click="toggleSort('topic')">
|
||||
Topic
|
||||
<span v-if="sort === 'topic'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||
</th>
|
||||
<th class="sortable" @click="toggleSort('description')">
|
||||
Description
|
||||
<span v-if="sort === 'description'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||
</th>
|
||||
<th class="sortable" style="width: 100px;" @click="toggleSort('clicks')">
|
||||
Clicks
|
||||
<span v-if="sort === 'clicks'" class="sort-arrow">{{ order === 'asc' ? '▲' : '▼' }}</span>
|
||||
</th>
|
||||
<th style="width: 120px;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="article in articles" :key="article.linkid">
|
||||
<td>
|
||||
<router-link
|
||||
v-if="article.application"
|
||||
:to="`/applications/${article.application.appid}`"
|
||||
>
|
||||
{{ article.application.appname }}
|
||||
</router-link>
|
||||
<span v-else class="text-muted">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<a
|
||||
href="#"
|
||||
class="article-link"
|
||||
@click.prevent="openArticle(article)"
|
||||
:title="article.linkurl"
|
||||
>
|
||||
{{ truncate(article.shortdescription, 95) }}
|
||||
</a>
|
||||
</td>
|
||||
<td style="text-align: center; font-weight: 500;">{{ article.clicks }}</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/knowledgebase/${article.linkid}`" class="btn btn-sm btn-secondary">
|
||||
View
|
||||
</router-link>
|
||||
<router-link :to="`/knowledgebase/${article.linkid}/edit`" class="btn btn-sm btn-secondary">
|
||||
Edit
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="articles.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">
|
||||
No articles found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { knowledgebaseApi, applicationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const loading = ref(true)
|
||||
const articles = ref([])
|
||||
const topics = ref([])
|
||||
const stats = ref(null)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadArticles })
|
||||
const perPage = ref(20)
|
||||
const totalPages = ref(1)
|
||||
const topicFilter = ref('')
|
||||
const sort = ref('clicks')
|
||||
const order = ref('desc')
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadArticles(),
|
||||
loadTopics(),
|
||||
loadStats()
|
||||
])
|
||||
})
|
||||
|
||||
async function loadArticles() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value,
|
||||
sort: sort.value,
|
||||
order: order.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (topicFilter.value) params.appid = topicFilter.value
|
||||
|
||||
const response = await knowledgebaseApi.list(params)
|
||||
articles.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading articles:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTopics() {
|
||||
try {
|
||||
const response = await applicationsApi.list({ perpage: 1000 })
|
||||
topics.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading topics:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStats() {
|
||||
try {
|
||||
const response = await knowledgebaseApi.getStats()
|
||||
stats.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading stats:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadArticles()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function toggleSort(column) {
|
||||
if (sort.value === column) {
|
||||
order.value = order.value === 'desc' ? 'asc' : 'desc'
|
||||
} else {
|
||||
sort.value = column
|
||||
order.value = 'desc'
|
||||
}
|
||||
loadArticles()
|
||||
}
|
||||
|
||||
function truncate(text, length) {
|
||||
if (!text) return ''
|
||||
if (text.length <= length) return text
|
||||
return text.substring(0, length) + '...'
|
||||
}
|
||||
|
||||
async function openArticle(article) {
|
||||
try {
|
||||
await knowledgebaseApi.trackClick(article.linkid)
|
||||
article.clicks = (article.clicks || 0) + 1
|
||||
if (stats.value) {
|
||||
stats.value.totalclicks++
|
||||
}
|
||||
if (article.linkurl) {
|
||||
window.open(article.linkurl, '_blank')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error tracking click:', error)
|
||||
if (article.linkurl) {
|
||||
window.open(article.linkurl, '_blank')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Knowledge Base specific styles only */
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.header-left h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stats-badge {
|
||||
background: var(--bg);
|
||||
color: var(--link);
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 6px;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.article-link {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.article-link:hover {
|
||||
color: var(--link);
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
321
frontend/src/views/machines/MachineDetail.vue
Normal file
321
frontend/src/views/machines/MachineDetail.vue
Normal file
@@ -0,0 +1,321 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Machine Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/machine-badge/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||
Print Badge
|
||||
</router-link>
|
||||
<router-link :to="`/print/asset-label/machine/${machine?.machine?.machineid}`" class="btn btn-secondary" v-if="machine" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/machines/${machine?.machine?.machineid}/edit`" class="btn btn-primary" v-if="machine">
|
||||
Edit
|
||||
</router-link>
|
||||
<router-link to="/machines" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="machine">
|
||||
<!-- Dual-bay sibling banner: a Dualpath pair is one physical machine -->
|
||||
<div v-if="machine.dualpathpartner" class="dualpath-banner">
|
||||
Dual-bay machine - sibling bay:
|
||||
<router-link
|
||||
v-if="machine.dualpathpartner.machineid"
|
||||
:to="`/machines/${machine.dualpathpartner.machineid}`"
|
||||
>{{ machine.dualpathpartner.assetnumber }}</router-link>
|
||||
<span v-else>{{ machine.dualpathpartner.assetnumber }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="machine.machine?.imageurl">
|
||||
<img :src="machine.machine.imageurl" :alt="machine.machine.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ machine.assetnumber }}</h1>
|
||||
<span v-if="machine.name" class="hero-alias">{{ machine.name }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-primary">
|
||||
{{ machine.assettypename || 'Machine' }}
|
||||
</span>
|
||||
<span class="badge badge-lg" :class="getStatusClass(machine.statusname)">
|
||||
{{ machine.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="machine.machine?.machinetypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ machine.machine.machinetypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.machine?.vendorname">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ machine.machine.vendorname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.machine?.modelname">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ machine.machine.modelname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="machine.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ machine.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ machine.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ machine.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('gaugelabreference', 'machine') && machine.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ machine.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('maintenancereference', 'machine') && machine.maintenancereference">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ machine.maintenancereference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ machine.serialnumber }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Hardware</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ machine.machine?.machinetypename || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ machine.machine?.vendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ machine.machine?.modelname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controller Section (for CNC machines) -->
|
||||
<div class="section-card" v-if="machine.machine?.controllervendorname || machine.machine?.controllermodelname">
|
||||
<h3 class="section-title">Controller</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ machine.machine?.controllervendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ machine.machine?.controllermodelname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="machine.mapx != null && machine.mapy != null"
|
||||
:left="machine.mapx"
|
||||
:top="machine.mapy"
|
||||
:machineName="machine.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ machine.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ machine.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ machine.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Machine Configuration -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Configuration</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Requires Manual Config</span>
|
||||
<span class="info-value">
|
||||
<span class="feature-tag" :class="{ active: machine.machine?.requiresmanualconfig }">
|
||||
{{ machine.machine?.requiresmanualconfig ? 'Yes' : 'No' }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Maintenance Section -->
|
||||
<div class="section-card" v-if="machine.machine?.lastmaintenancedate || machine.machine?.nextmaintenancedate">
|
||||
<h3 class="section-title">Maintenance</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="machine.machine?.lastmaintenancedate">
|
||||
<span class="info-label">Last Maintenance</span>
|
||||
<span class="info-value">{{ formatDate(machine.machine.lastmaintenancedate) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.machine?.nextmaintenancedate">
|
||||
<span class="info-label">Next Maintenance</span>
|
||||
<span class="info-value">{{ formatDate(machine.machine.nextmaintenancedate) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="machine.machine?.maintenanceintervaldays">
|
||||
<span class="info-label">Interval</span>
|
||||
<span class="info-value">{{ machine.machine.maintenanceintervaldays }} days</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="machine.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="machine.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (dualpath, controls, ...) -->
|
||||
<AssetRelationships v-if="machine.assetid" :assetid="machine.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="machine.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ machine.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(machine.createddate) }}<template v-if="machine.createdby"> by {{ machine.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(machine.modifieddate) }}<template v-if="machine.modifiedby"> by {{ machine.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Machine not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { machinesApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import AssetRelationships from '../../components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => machine.value?.assetid)
|
||||
|
||||
// relationships render via the shared AssetRelationships card
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
machine.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading machine:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
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' || s === 'spare') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'disposed') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.dualpath-banner {
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
border: 1px solid var(--border);
|
||||
border-left: 4px solid var(--primary);
|
||||
color: var(--text);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.dualpath-banner a {
|
||||
color: var(--link);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
706
frontend/src/views/machines/MachineForm.vue
Normal file
706
frontend/src/views/machines/MachineForm.vue
Normal file
@@ -0,0 +1,706 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Machine' : 'New Machine' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveMachine">
|
||||
<!-- Identity Section -->
|
||||
<h3 class="form-section-title">Identity</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number *</label>
|
||||
<input
|
||||
id="assetnumber"
|
||||
v-model="form.assetnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="name">Name / Alias</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
<small class="form-help">Layperson-friendly label</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('gaugelabreference', 'machine') || isEnabled('maintenancereference', 'machine')">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'machine')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input
|
||||
id="gaugelabreference"
|
||||
v-model="form.gaugelabreference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="isEnabled('maintenancereference', 'machine')">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input
|
||||
id="maintenancereference"
|
||||
v-model="form.maintenancereference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
<small class="form-help">Maintenance system asset reference (if tracked)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select
|
||||
id="statusid"
|
||||
v-model="form.statusid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
<option
|
||||
v-for="s in statuses"
|
||||
:key="s.statusid"
|
||||
:value="s.statusid"
|
||||
>
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Machine Hardware Section -->
|
||||
<h3 class="form-section-title">Machine Hardware</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">Machine Type</label>
|
||||
<select
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="mt in machineTypes"
|
||||
:key="mt.machinetypeid"
|
||||
:value="mt.machinetypeid"
|
||||
>
|
||||
{{ mt.machinetype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help" v-if="form.vendorid">Filtered by selected vendor</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group"></div>
|
||||
</div>
|
||||
|
||||
<!-- Controller Section (for CNC machines) -->
|
||||
<h3 class="form-section-title">Controller</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="controllervendorid">Controller Vendor</label>
|
||||
<select
|
||||
id="controllervendorid"
|
||||
v-model="form.controllervendorid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">e.g., Fanuc, Siemens, Allen-Bradley</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="controllermodelid">Controller Model</label>
|
||||
<select
|
||||
id="controllermodelid"
|
||||
v-model="form.controllermodelid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option
|
||||
v-for="m in filteredControllerModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help" v-if="form.controllervendorid">Filtered by controller vendor</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Section -->
|
||||
<h3 class="form-section-title">Location & Organization</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select
|
||||
id="locationid"
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select
|
||||
id="businessunitid"
|
||||
v-model="form.businessunitid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select business unit...</option>
|
||||
<option
|
||||
v-for="bu in businessunits"
|
||||
:key="bu.businessunitid"
|
||||
:value="bu.businessunitid"
|
||||
>
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Location Picker -->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</template>
|
||||
</Modal>
|
||||
|
||||
<!-- Configuration Section -->
|
||||
<h3 class="form-section-title">Configuration</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.requiresmanualconfig" />
|
||||
Requires Manual Config
|
||||
</label>
|
||||
<small class="form-help">Machine a tech must configure by hand (e.g. driven by multiple PCs)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Controlling PC Selection -->
|
||||
<h3 class="form-section-title">Connected PC</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="controllingpc">Controlling PC</label>
|
||||
<select
|
||||
id="controllingpc"
|
||||
v-model="controllingPcId"
|
||||
class="form-control"
|
||||
>
|
||||
<option :value="null">None (standalone)</option>
|
||||
<option
|
||||
v-for="pc in pcs"
|
||||
:key="pc.assetid"
|
||||
:value="pc.assetid"
|
||||
>
|
||||
{{ pc.assetnumber }}{{ pc.name ? ` (${pc.name})` : '' }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">Select the PC that controls this machine</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="controllingPcId">
|
||||
<label for="connectiontype">Connection Type</label>
|
||||
<select
|
||||
id="connectiontype"
|
||||
v-model="relationshipTypeId"
|
||||
class="form-control"
|
||||
>
|
||||
<option
|
||||
v-for="rt in relationshipTypes"
|
||||
:key="rt.relationshiptypeid"
|
||||
:value="rt.relationshiptypeid"
|
||||
>
|
||||
{{ rt.relationshiptype }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-help">How the PC connects to this machine</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<h3 class="form-section-title">Notes</h3>
|
||||
<div class="form-group">
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
placeholder="Additional notes..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Site-defined custom fields for machines -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Machine' }}
|
||||
</button>
|
||||
<router-link to="/machines" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, relationshipTypesApi } 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'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Seeded asset-type id for machines (see /api/assets/types).
|
||||
const MACHINE_ASSETTYPEID = 1
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
serialnumber: '',
|
||||
statusid: 1,
|
||||
machinetypeid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
controllervendorid: '',
|
||||
controllermodelid: '',
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
requiresmanualconfig: false,
|
||||
islocationonly: false,
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null
|
||||
})
|
||||
|
||||
const machineTypes = ref([])
|
||||
const statuses = ref([])
|
||||
const vendors = ref([])
|
||||
const locations = ref([])
|
||||
const models = ref([])
|
||||
const businessunits = ref([])
|
||||
const pcs = ref([])
|
||||
const relationshipTypes = ref([])
|
||||
const controllingPcId = ref(null)
|
||||
const relationshipTypeId = ref(null)
|
||||
const existingRelationshipId = ref(null)
|
||||
const currentMachine = ref(null)
|
||||
|
||||
// Filter models by selected machine vendor
|
||||
const filteredModels = computed(() => {
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
})
|
||||
|
||||
// Filter models by selected controller vendor
|
||||
const filteredControllerModels = computed(() => {
|
||||
if (!form.value.controllervendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.controllervendorid)
|
||||
})
|
||||
|
||||
// Clear model selection when vendor changes
|
||||
watch(() => form.value.vendorid, (newVal, oldVal) => {
|
||||
if (oldVal && newVal !== oldVal) {
|
||||
const currentModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
|
||||
if (currentModel && currentModel.vendorid !== newVal) {
|
||||
form.value.modelnumberid = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Clear controller model when controller vendor changes
|
||||
watch(() => form.value.controllervendorid, (newVal, oldVal) => {
|
||||
if (oldVal && newVal !== oldVal) {
|
||||
const currentModel = models.value.find(m => m.modelnumberid === form.value.controllermodelid)
|
||||
if (currentModel && currentModel.vendorid !== newVal) {
|
||||
form.value.controllermodelid = ''
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data in parallel
|
||||
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
|
||||
machinesApi.types.list(),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 500 }),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
modelsApi.listAll(), // backend caps perpage at 100; page through all
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
computersApi.list({ perpage: 500 }),
|
||||
assetsApi.types.list() // Used for relationship types, will fix below
|
||||
])
|
||||
|
||||
machineTypes.value = typesRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
locations.value = locRes.data.data || []
|
||||
models.value = allModels
|
||||
businessunits.value = buRes.data.data || []
|
||||
pcs.value = pcsRes.data.data || []
|
||||
|
||||
// Load relationship types separately
|
||||
try {
|
||||
const relRes = await relationshipTypesApi.list()
|
||||
relationshipTypes.value = relRes.data.data || []
|
||||
} catch (e) {
|
||||
// Fallback - use hardcoded Controls type
|
||||
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]
|
||||
}
|
||||
|
||||
// Set default relationship type to "Controls" if available (name is data, compare folded)
|
||||
const controlsType = relationshipTypes.value.find(t => (t.relationshiptype || '').toLowerCase() === 'controls')
|
||||
if (controlsType) {
|
||||
relationshipTypeId.value = controlsType.relationshiptypeid
|
||||
}
|
||||
|
||||
// Load machine if editing
|
||||
if (isEdit.value) {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
const data = response.data.data
|
||||
currentAssetId.value = data.assetid || null
|
||||
currentMachine.value = data
|
||||
|
||||
form.value = {
|
||||
assetnumber: data.assetnumber || '',
|
||||
name: data.name || '',
|
||||
gaugelabreference: data.gaugelabreference || '',
|
||||
maintenancereference: data.maintenancereference || '',
|
||||
serialnumber: data.serialnumber || '',
|
||||
statusid: data.statusid || 1,
|
||||
machinetypeid: data.machine?.machinetypeid || '',
|
||||
vendorid: data.machine?.vendorid || '',
|
||||
modelnumberid: data.machine?.modelnumberid || '',
|
||||
controllervendorid: data.machine?.controllervendorid || '',
|
||||
controllermodelid: data.machine?.controllermodelid || '',
|
||||
locationid: data.locationid || '',
|
||||
businessunitid: data.businessunitid || '',
|
||||
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
|
||||
islocationonly: data.machine?.islocationonly || false,
|
||||
notes: data.notes || '',
|
||||
mapx: data.mapx ?? null,
|
||||
mapy: data.mapy ?? null
|
||||
}
|
||||
|
||||
// Load existing relationships to find controlling PC
|
||||
if (data.assetid) {
|
||||
try {
|
||||
const relResponse = await assetsApi.getRelationships(data.assetid)
|
||||
const relationships = relResponse.data.data || { incoming: [], outgoing: [] }
|
||||
|
||||
// Check incoming relationships for a controlling PC
|
||||
for (const rel of relationships.incoming || []) {
|
||||
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
|
||||
controllingPcId.value = rel.sourceasset.assetid
|
||||
relationshipTypeId.value = rel.relationshiptypeid
|
||||
existingRelationshipId.value = rel.assetrelationshipid
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function saveMachine() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const data = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
statusid: form.value.statusid || 1,
|
||||
machinetypeid: form.value.machinetypeid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
controllervendorid: form.value.controllervendorid || null,
|
||||
controllermodelid: form.value.controllermodelid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
requiresmanualconfig: form.value.requiresmanualconfig,
|
||||
islocationonly: form.value.islocationonly,
|
||||
notes: form.value.notes || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
|
||||
let savedMachine
|
||||
let assetId
|
||||
|
||||
if (isEdit.value) {
|
||||
const response = await machinesApi.update(route.params.id, data)
|
||||
savedMachine = response.data.data
|
||||
assetId = savedMachine.assetid
|
||||
} else {
|
||||
const response = await machinesApi.create(data)
|
||||
savedMachine = response.data.data
|
||||
assetId = savedMachine.assetid
|
||||
}
|
||||
|
||||
// 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/${savedMachine.machine?.machineid || route.params.id}`)
|
||||
} catch (err) {
|
||||
console.error('Error saving machine:', err)
|
||||
error.value = apiError(err, 'Failed to save machine')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveRelationship(assetId) {
|
||||
// If no PC selected and no existing relationship, nothing to do
|
||||
if (!controllingPcId.value && !existingRelationshipId.value) {
|
||||
return
|
||||
}
|
||||
|
||||
// If clearing the relationship
|
||||
if (!controllingPcId.value && existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
return
|
||||
}
|
||||
|
||||
// If PC is selected
|
||||
if (controllingPcId.value) {
|
||||
// If there's an existing relationship, delete it first
|
||||
if (existingRelationshipId.value) {
|
||||
await assetsApi.deleteRelationship(existingRelationshipId.value)
|
||||
}
|
||||
|
||||
// Create new relationship (PC controls Machine, so PC is source, Machine is target)
|
||||
await assetsApi.createRelationship({
|
||||
sourceassetid: controllingPcId.value,
|
||||
targetassetid: assetId,
|
||||
relationshiptypeid: relationshipTypeId.value
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section-title:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 1.1rem;
|
||||
height: 1.1rem;
|
||||
}
|
||||
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
height: calc(90vh - 140px);
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.shopfloor-map) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.map-container) {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
147
frontend/src/views/machines/MachinesList.vue
Normal file
147
frontend/src/views/machines/MachinesList.vue
Normal file
@@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Machines</h2>
|
||||
<router-link to="/print/asset-label-batch/machine" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/machines/new" class="btn btn-primary">Add Machine</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search machines..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Machine #</th>
|
||||
<th>Name</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Vendor</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in machines" :key="item.assetid" class="clickable-row" @click="$router.push(`/machines/${item.machine?.machineid || item.assetid}`)">
|
||||
<td>
|
||||
{{ item.assetnumber }}<template v-if="item.dualpathpartner"> / {{ item.dualpathpartner.assetnumber }}</template>
|
||||
</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.machine?.machinetypename || '-' }}</td>
|
||||
<td>{{ item.machine?.vendorname || '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/machines/${item.machine?.machineid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="machines.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No machines found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { machinesApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const machines = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadMachines })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadMachines()
|
||||
})
|
||||
|
||||
async function loadMachines() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await machinesApi.list(params)
|
||||
machines.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading machines:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadMachines()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadMachines()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadMachines()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
210
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal file
210
frontend/src/views/measuringtools/MeasuringToolDetail.vue
Normal file
@@ -0,0 +1,210 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tool Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/measuring_tool/${tool?.measuringtool?.measuringtoolid}`" class="btn btn-secondary" v-if="tool" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/measuringtools/${tool?.measuringtool?.measuringtoolid}/edit`" class="btn btn-primary" v-if="tool">
|
||||
Edit
|
||||
</router-link>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="tool">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ tool.assetnumber }}</h1>
|
||||
<span v-if="tool.name" class="hero-alias">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-primary">
|
||||
{{ tool.assettypename || 'Measuring Tool' }}
|
||||
</span>
|
||||
<span class="badge badge-lg" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="tool.measuringtool?.measuringtooltypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ tool.measuringtool.measuringtooltypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="tool.measuringtool?.nextcalibrationdate">
|
||||
<span class="hero-detail-label">Next Calibration</span>
|
||||
<span class="hero-detail-value">{{ formatDate(tool.measuringtool.nextcalibrationdate) }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="tool.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ tool.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number (Gage Tag)</span>
|
||||
<span class="info-value">{{ tool.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ tool.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.gaugelabreference && isEnabled('gaugelabreference', 'measuring_tool')">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ tool.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.maintenancereference && isEnabled('maintenancereference', 'measuring_tool')">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ tool.maintenancereference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ tool.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Type</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.measuringtooltypename || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Calibration</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Status</span>
|
||||
<span class="info-value">
|
||||
<span class="badge" :style="colorStyle(tool.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(tool.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Calibration</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.lastcalibrationdate ? formatDate(tool.measuringtool.lastcalibrationdate) : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Next Calibration</span>
|
||||
<span class="info-value">{{ tool.measuringtool?.nextcalibrationdate ? formatDate(tool.measuringtool.nextcalibrationdate) : '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.measuringtool?.calibrationintervaldays">
|
||||
<span class="info-label">Interval</span>
|
||||
<span class="info-value">{{ tool.measuringtool.calibrationintervaldays }} days</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="tool.measuringtool?.calibrationprovider">
|
||||
<span class="info-label">Provider</span>
|
||||
<span class="info-value">{{ tool.measuringtool.calibrationprovider }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">{{ tool.locationname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ tool.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="tool.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="tool.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (partof, connectedto, ...) -->
|
||||
<AssetRelationships v-if="tool.assetid" :assetid="tool.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="tool.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ tool.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDateTime(tool.createddate) }}<template v-if="tool.createdby"> by {{ tool.createdby }}</template></span>
|
||||
<span>Modified {{ formatDateTime(tool.modifieddate) }}<template v-if="tool.modifiedby"> by {{ tool.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Measuring tool not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import AssetRelationships from '../../components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const tool = ref(null)
|
||||
const { warranties } = useWarrantyBadge(() => tool.value?.assetid)
|
||||
|
||||
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
|
||||
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
|
||||
function formatDate(d) { if (!d) return '-'; return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
function formatDateTime(d) { if (!d) return '-'; return new Date(d).toLocaleString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// by-asset route resolves the tool from a core asset id (search/cross-link
|
||||
// rows carry assetid, not the extension id); detail route keys on the id.
|
||||
const response = route.params.assetid
|
||||
? await measuringtoolsApi.getByAsset(route.params.assetid)
|
||||
: await measuringtoolsApi.get(route.params.id)
|
||||
tool.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tool:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
295
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal file
295
frontend/src/views/measuringtools/MeasuringToolForm.vue
Normal file
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Measuring Tool' : 'New Measuring Tool' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveTool">
|
||||
<!-- Identity Section -->
|
||||
<h3 class="form-section-title">Identity</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number (Gage Tag) *</label>
|
||||
<input id="assetnumber" v-model="form.assetnumber" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name / Alias</label>
|
||||
<input id="name" v-model="form.name" type="text" class="form-control" />
|
||||
<small class="form-help">Layperson-friendly label</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number (vendor)</label>
|
||||
<input id="serialnumber" v-model="form.serialnumber" type="text" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'measuring_tool')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input id="gaugelabreference" v-model="form.gaugelabreference" type="text" class="form-control" />
|
||||
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('maintenancereference', 'measuring_tool')">
|
||||
<div class="form-group">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input id="maintenancereference" v-model="form.maintenancereference" type="text" class="form-control" />
|
||||
<small class="form-help">Maintenance system asset reference (if tracked)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="measuringtooltypeid">Type</label>
|
||||
<select id="measuringtooltypeid" v-model="form.measuringtooltypeid" class="form-control">
|
||||
<option value="">Select type...</option>
|
||||
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select id="statusid" v-model="form.statusid" class="form-control">
|
||||
<option value="">Select status...</option>
|
||||
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">{{ s.status }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Calibration Section -->
|
||||
<h3 class="form-section-title">Calibration</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="lastcalibrationdate">Last Calibration</label>
|
||||
<input id="lastcalibrationdate" v-model="form.lastcalibrationdate" type="date" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="nextcalibrationdate">Next Calibration</label>
|
||||
<input id="nextcalibrationdate" v-model="form.nextcalibrationdate" type="date" class="form-control" />
|
||||
<small class="form-help">Drives the derived calibration status (overdue / due soon / current)</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="calibrationintervaldays">Interval (days)</label>
|
||||
<input id="calibrationintervaldays" v-model.number="form.calibrationintervaldays" type="number" min="0" class="form-control" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="calibrationprovider">Calibration Provider</label>
|
||||
<input id="calibrationprovider" v-model="form.calibrationprovider" type="text" class="form-control" />
|
||||
<small class="form-help">Lab or in-house team that performs the calibration</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location Section -->
|
||||
<h3 class="form-section-title">Location & Organization</h3>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select id="locationid" v-model="form.locationid" class="form-control">
|
||||
<option value="">Select location...</option>
|
||||
<option v-for="l in locations" :key="l.locationid" :value="l.locationid">{{ l.locationname }}</option>
|
||||
</select>
|
||||
<small class="form-help">Operation locations (e.g. "0615 Blisk Inspection") hold measuring tools</small>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select id="businessunitid" v-model="form.businessunitid" class="form-control">
|
||||
<option value="">Select business unit...</option>
|
||||
<option v-for="bu in businessunits" :key="bu.businessunitid" :value="bu.businessunitid">{{ bu.businessunit }}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Notes Section -->
|
||||
<h3 class="form-section-title">Notes</h3>
|
||||
<div class="form-group">
|
||||
<textarea id="notes" v-model="form.notes" class="form-control" rows="3" placeholder="Additional notes..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Site-defined custom fields for measuring tools -->
|
||||
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="assettypeid" :assetid="currentAssetId" />
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Measuring Tool' }}
|
||||
</button>
|
||||
<router-link to="/measuringtools" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { measuringtoolsApi, assetsApi, locationsApi, businessunitsApi } from '../../api'
|
||||
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const isEdit = computed(() => !!route.params.id)
|
||||
|
||||
// Resolved dynamically from /api/assets/types (no hardcoded id) so custom
|
||||
// fields load for the measuring_tool asset type.
|
||||
const assettypeid = ref(null)
|
||||
const customFieldsRef = ref(null)
|
||||
const currentAssetId = ref(null)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const form = ref({
|
||||
assetnumber: '',
|
||||
name: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
serialnumber: '',
|
||||
statusid: '',
|
||||
measuringtooltypeid: '',
|
||||
lastcalibrationdate: '',
|
||||
nextcalibrationdate: '',
|
||||
calibrationintervaldays: null,
|
||||
calibrationprovider: '',
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
notes: '',
|
||||
})
|
||||
|
||||
const types = ref([])
|
||||
const statuses = ref([])
|
||||
const locations = ref([])
|
||||
const businessunits = ref([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [typesResponse, statusResponse, locResponse, buResponse, assetTypesResponse] = await Promise.all([
|
||||
measuringtoolsApi.types.list({ perpage: 200 }),
|
||||
assetsApi.statuses.list(),
|
||||
locationsApi.list({ perpage: 500 }),
|
||||
businessunitsApi.list({ perpage: 500 }),
|
||||
assetsApi.types.list(),
|
||||
])
|
||||
types.value = typesResponse.data.data || []
|
||||
statuses.value = statusResponse.data.data || []
|
||||
locations.value = locResponse.data.data || []
|
||||
businessunits.value = buResponse.data.data || []
|
||||
|
||||
const assetType = (assetTypesResponse.data.data || []).find(a => a.assettype === 'measuring_tool')
|
||||
assettypeid.value = assetType ? assetType.assettypeid : null
|
||||
|
||||
if (isEdit.value) {
|
||||
const response = await measuringtoolsApi.get(route.params.id)
|
||||
const data = response.data.data
|
||||
currentAssetId.value = data.assetid || null
|
||||
const ext = data.measuringtool || {}
|
||||
form.value = {
|
||||
assetnumber: data.assetnumber || '',
|
||||
name: data.name || '',
|
||||
gaugelabreference: data.gaugelabreference || '',
|
||||
maintenancereference: data.maintenancereference || '',
|
||||
serialnumber: data.serialnumber || '',
|
||||
statusid: data.statusid || '',
|
||||
measuringtooltypeid: ext.measuringtooltypeid || '',
|
||||
lastcalibrationdate: ext.lastcalibrationdate || '',
|
||||
nextcalibrationdate: ext.nextcalibrationdate || '',
|
||||
calibrationintervaldays: ext.calibrationintervaldays ?? null,
|
||||
calibrationprovider: ext.calibrationprovider || '',
|
||||
locationid: data.locationid || '',
|
||||
businessunitid: data.businessunitid || '',
|
||||
notes: data.notes || '',
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveTool() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
const data = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
statusid: form.value.statusid || null,
|
||||
measuringtooltypeid: form.value.measuringtooltypeid || null,
|
||||
lastcalibrationdate: form.value.lastcalibrationdate || null,
|
||||
nextcalibrationdate: form.value.nextcalibrationdate || null,
|
||||
calibrationintervaldays: form.value.calibrationintervaldays ?? null,
|
||||
calibrationprovider: form.value.calibrationprovider || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
notes: form.value.notes || null,
|
||||
}
|
||||
|
||||
let saved
|
||||
if (isEdit.value) {
|
||||
const response = await measuringtoolsApi.update(route.params.id, data)
|
||||
saved = response.data.data
|
||||
} else {
|
||||
const response = await measuringtoolsApi.create(data)
|
||||
saved = response.data.data
|
||||
}
|
||||
|
||||
const assetId = saved.assetid
|
||||
if (assetId && customFieldsRef.value) {
|
||||
try {
|
||||
await customFieldsRef.value.save(assetId)
|
||||
} catch (customFieldsErr) {
|
||||
console.error('Error saving custom fields:', customFieldsErr)
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`/measuringtools/${saved.measuringtool?.measuringtoolid || route.params.id}`)
|
||||
} catch (err) {
|
||||
console.error('Error saving measuring tool:', err)
|
||||
error.value = apiError(err, 'Failed to save measuring tool')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-section-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 1.5rem 0 0.75rem 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.form-section-title:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
</style>
|
||||
173
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal file
173
frontend/src/views/measuringtools/MeasuringToolsList.vue
Normal file
@@ -0,0 +1,173 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Measuring Tools</h2>
|
||||
<router-link to="/print/asset-label-batch/measuring_tool" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/measuringtools/new" class="btn btn-primary">Add Measuring Tool</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search measuring tools..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="typeid" class="form-control" @change="reload">
|
||||
<option value="">All types</option>
|
||||
<option v-for="t in types" :key="t.measuringtooltypeid" :value="t.measuringtooltypeid">
|
||||
{{ t.name }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="calibrationstatus" class="form-control" @change="reload">
|
||||
<option value="">All calibration statuses</option>
|
||||
<option value="overdue">Overdue</option>
|
||||
<option value="duesoon">Due soon</option>
|
||||
<option value="current">Current</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset #</th>
|
||||
<th>Name</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Next Calibration</th>
|
||||
<th>Calibration</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in tools" :key="item.assetid" class="clickable-row" @click="$router.push(`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`)">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.name || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.measuringtool?.measuringtooltypename || '-' }}</td>
|
||||
<td>{{ item.measuringtool?.nextcalibrationdate ? formatDate(item.measuringtool.nextcalibrationdate) : '-' }}</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(item.measuringtool?.calibrationstatuscolor)">
|
||||
{{ statusLabel(item.measuringtool?.calibrationstatus) }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/measuringtools/${item.measuringtool?.measuringtoolid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="tools.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No measuring tools found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { measuringtoolsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const tools = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadTools })
|
||||
const typeid = ref('')
|
||||
const calibrationstatus = ref('')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
const STATUS_LABELS = { overdue: 'Overdue', duesoon: 'Due Soon', current: 'Current', unknown: 'Unknown' }
|
||||
function statusLabel(status) { return STATUS_LABELS[status] || 'Unknown' }
|
||||
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const typesResponse = await measuringtoolsApi.types.list({ perpage: 200 })
|
||||
types.value = typesResponse.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading types:', err)
|
||||
}
|
||||
loadTools()
|
||||
})
|
||||
|
||||
async function loadTools() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = { page: page.value, perpage: perPage.value }
|
||||
if (search.value) params.search = search.value
|
||||
if (typeid.value) params.typeid = typeid.value
|
||||
if (calibrationstatus.value) params.calibrationstatus = calibrationstatus.value
|
||||
|
||||
const response = await measuringtoolsApi.list(params)
|
||||
tools.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (err) {
|
||||
console.error('Error loading measuring tools:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function reload() {
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadTools()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadTools()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadTools()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
</style>
|
||||
380
frontend/src/views/network/NetworkDeviceDetail.vue
Normal file
380
frontend/src/views/network/NetworkDeviceDetail.vue
Normal file
@@ -0,0 +1,380 @@
|
||||
<template>
|
||||
<div class="detail-page" v-if="device">
|
||||
<div class="hero-card">
|
||||
<div class="hero-image">
|
||||
<img v-if="device.networkdevice?.imageurl" :src="device.networkdevice.imageurl" alt="Model photo" />
|
||||
<div class="device-icon" v-else>
|
||||
<span class="icon"><component :is="getDeviceIcon()" :size="24" /></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title-row">
|
||||
<h1 class="hero-title">{{ device.networkdevice?.hostname || device.name || device.assetnumber }}</h1>
|
||||
<router-link
|
||||
v-if="authStore.isAuthenticated"
|
||||
:to="`/network/${deviceId}/edit`"
|
||||
class="btn btn-secondary"
|
||||
>
|
||||
Edit
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge" :style="colorStyle(device.statuscolor)">
|
||||
{{ device.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="device.networkdevice?.networkdevicetypename" class="meta-item">
|
||||
{{ device.networkdevice.networkdevicetypename }}
|
||||
</span>
|
||||
<span v-if="device.networkdevice?.vendorname" class="meta-item">
|
||||
{{ device.networkdevice.vendorname }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="detail-item" v-if="device.assetnumber">
|
||||
<span class="label">Asset #</span>
|
||||
<span class="value">{{ device.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="device.serialnumber">
|
||||
<span class="label">Serial</span>
|
||||
<span class="value mono">{{ device.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="device.locationname">
|
||||
<span class="label">Location</span>
|
||||
<span class="value">{{ device.locationname }}</span>
|
||||
</div>
|
||||
<div class="detail-item" v-if="device.businessunitname">
|
||||
<span class="label">Business Unit</span>
|
||||
<span class="value">{{ device.businessunitname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-features" v-if="device.networkdevice">
|
||||
<span v-if="device.networkdevice.ispoe" class="feature-badge poe">PoE</span>
|
||||
<span v-if="device.networkdevice.ismanaged" class="feature-badge managed">Managed</span>
|
||||
<span v-if="device.networkdevice.portcount" class="feature-badge ports">{{ device.networkdevice.portcount }} Ports</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<!-- Identity -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ device.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ device.name || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ device.serialnumber || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('gaugelabreference', 'network_device') && device.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ device.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('maintenancereference', 'network_device') && device.maintenancereference">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ device.maintenancereference }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ device.networkdevice?.vendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Device Type</span>
|
||||
<span class="info-value">{{ device.networkdevice?.networkdevicetypename || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Network Information (type-specific) -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Network Information</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ device.networkdevice?.hostname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Firmware Version</span>
|
||||
<span class="info-value">{{ device.networkdevice?.firmwareversion || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Port Count</span>
|
||||
<span class="info-value">{{ device.networkdevice?.portcount || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Rack Unit</span>
|
||||
<span class="info-value">{{ device.networkdevice?.rackunit || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">PoE Capable</span>
|
||||
<span class="info-value">{{ device.networkdevice?.ispoe ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Managed Device</span>
|
||||
<span class="info-value">{{ device.networkdevice?.ismanaged ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">{{ device.locationname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ device.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="device.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="device.assetid" :items="warranties" />
|
||||
|
||||
<!-- Relationships -->
|
||||
<AssetRelationships
|
||||
v-if="device.assetid"
|
||||
:assetid="device.assetid"
|
||||
/>
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="device.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<div class="notes-content">{{ device.notes }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="action-bar" v-if="authStore.isAuthenticated">
|
||||
<router-link :to="`/print/asset-label/network_device/${deviceId}`" class="btn btn-secondary" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/network/${deviceId}/edit`" class="btn btn-primary">
|
||||
Edit Device
|
||||
</router-link>
|
||||
<button @click="confirmDelete" class="btn btn-danger">
|
||||
Delete Device
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(device.createddate) }}<template v-if="device.createdby"> by {{ device.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(device.modifieddate) }}<template v-if="device.modifiedby"> by {{ device.modifiedby }}</template></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loading" class="loading-container">
|
||||
<div class="loading">Loading device...</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-container">
|
||||
<p>Device not found</p>
|
||||
<router-link to="/network" class="btn btn-secondary">Back to Network Devices</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
import { useToast } from '../../composables/toast'
|
||||
const toast = useToast()
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const deviceId = route.params.id
|
||||
const device = ref(null)
|
||||
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => device.value?.assetid)
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDevice()
|
||||
})
|
||||
|
||||
async function loadDevice() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await networkApi.get(deviceId)
|
||||
device.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading device:', error)
|
||||
device.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function getDeviceIcon() {
|
||||
const type = device.value?.networkdevice?.networkdevicetypename?.toLowerCase() || ''
|
||||
if (type.includes('switch')) return Network
|
||||
if (type.includes('router')) return Router
|
||||
if (type.includes('firewall')) return Shield
|
||||
if (type.includes('access point') || type.includes('ap')) return Wifi
|
||||
if (type.includes('camera')) return Camera
|
||||
if (type.includes('server')) return Server
|
||||
if (type.includes('idf') || type.includes('closet')) return Rack
|
||||
return Globe
|
||||
}
|
||||
|
||||
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' || s === 'maintenance') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return date.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
async function confirmDelete() {
|
||||
if (confirm(`Are you sure you want to delete ${device.value.networkdevice?.hostname || device.value.assetnumber}?`)) {
|
||||
try {
|
||||
await networkApi.delete(deviceId)
|
||||
router.push('/network')
|
||||
} catch (error) {
|
||||
console.error('Error deleting device:', error)
|
||||
toast.error('Failed to delete device')
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--primary-dark) 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.device-icon .icon {
|
||||
font-size: 4rem;
|
||||
}
|
||||
|
||||
.hero-features {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feature-badge {
|
||||
display: inline-block;
|
||||
padding: 0.35rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.feature-badge.poe {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.feature-badge.managed {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.feature-badge.ports {
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.notes-content {
|
||||
white-space: pre-wrap;
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.loading-container,
|
||||
.error-container {
|
||||
text-align: center;
|
||||
padding: 3rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-badge.poe {
|
||||
background: #1e3a29;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.feature-badge.managed {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
552
frontend/src/views/network/NetworkDeviceForm.vue
Normal file
552
frontend/src/views/network/NetworkDeviceForm.vue
Normal file
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Network Device' : 'Add Network Device' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card form-card">
|
||||
<form @submit.prevent="submitForm">
|
||||
<!-- Asset Information -->
|
||||
<fieldset>
|
||||
<legend>Asset Information</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="assetnumber">Asset Number *</label>
|
||||
<input
|
||||
id="assetnumber"
|
||||
v-model="form.assetnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="isEdit"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="name">Name</label>
|
||||
<input
|
||||
id="name"
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select id="statusid" v-model="form.statusid" class="form-control">
|
||||
<option value="">Select Status</option>
|
||||
<option v-for="s in statuses" :key="s.statusid" :value="s.statusid">
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('gaugelabreference', 'network_device') || isEnabled('maintenancereference', 'network_device')">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'network_device')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input
|
||||
id="gaugelabreference"
|
||||
v-model="form.gaugelabreference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group" v-if="isEnabled('maintenancereference', 'network_device')">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input
|
||||
id="maintenancereference"
|
||||
v-model="form.maintenancereference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select id="locationid" v-model="form.locationid" class="form-control">
|
||||
<option value="">Select Location</option>
|
||||
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select id="businessunitid" v-model="form.businessunitid" class="form-control">
|
||||
<option value="">Select Business Unit</option>
|
||||
<option v-for="bu in businessUnits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Network Device Information -->
|
||||
<fieldset>
|
||||
<legend>Network Device Details</legend>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., sw-bldg1-floor2"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="networkdevicetypeid">Device Type</label>
|
||||
<select id="networkdevicetypeid" v-model="form.networkdevicetypeid" class="form-control">
|
||||
<option value="">Select Type</option>
|
||||
<option v-for="t in deviceTypes" :key="t.networkdevicetypeid" :value="t.networkdevicetypeid">
|
||||
{{ t.networkdevicetype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select id="vendorid" v-model="form.vendorid" class="form-control">
|
||||
<option value="">Select Vendor</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="firmwareversion">Firmware Version</label>
|
||||
<input
|
||||
id="firmwareversion"
|
||||
v-model="form.firmwareversion"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="portcount">Port Count</label>
|
||||
<input
|
||||
id="portcount"
|
||||
v-model.number="form.portcount"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="rackunit">Rack Unit</label>
|
||||
<input
|
||||
id="rackunit"
|
||||
v-model="form.rackunit"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., U1, U5-U8"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row checkboxes">
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.ispoe" />
|
||||
<span>PoE Capable</span>
|
||||
</label>
|
||||
<small>Device supports Power over Ethernet</small>
|
||||
</div>
|
||||
<div class="form-group checkbox-group">
|
||||
<label>
|
||||
<input type="checkbox" v-model="form.ismanaged" />
|
||||
<span>Managed Device</span>
|
||||
</label>
|
||||
<small>Device has SNMP, web interface, or CLI management</small>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Map Position (optional) -->
|
||||
<fieldset>
|
||||
<legend>Map Position (Optional)</legend>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="mapx">Map X Position</label>
|
||||
<input
|
||||
id="mapx"
|
||||
v-model.number="form.mapx"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="mapy">Map Y Position</label>
|
||||
<input
|
||||
id="mapy"
|
||||
v-model.number="form.mapy"
|
||||
type="number"
|
||||
class="form-control"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<!-- Notes -->
|
||||
<fieldset>
|
||||
<legend>Notes</legend>
|
||||
<div class="form-group">
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
placeholder="Additional notes about this device..."
|
||||
></textarea>
|
||||
</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>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Save Changes' : 'Create Device') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import {
|
||||
networkApi,
|
||||
vendorsApi,
|
||||
locationsApi,
|
||||
assetsApi,
|
||||
businessunitsApi
|
||||
} from '../../api'
|
||||
import CustomFieldsInputs from '../../components/CustomFieldsInputs.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
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: '',
|
||||
serialnumber: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
statusid: '',
|
||||
locationid: '',
|
||||
businessunitid: '',
|
||||
hostname: '',
|
||||
networkdevicetypeid: '',
|
||||
vendorid: '',
|
||||
firmwareversion: '',
|
||||
portcount: null,
|
||||
rackunit: '',
|
||||
ispoe: false,
|
||||
ismanaged: false,
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
notes: ''
|
||||
})
|
||||
|
||||
const deviceTypes = ref([])
|
||||
const vendors = ref([])
|
||||
const locations = ref([])
|
||||
const statuses = ref([])
|
||||
const businessUnits = ref([])
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadDeviceTypes(),
|
||||
loadVendors(),
|
||||
loadLocations(),
|
||||
loadStatuses(),
|
||||
loadBusinessUnits()
|
||||
])
|
||||
|
||||
if (isEdit.value) {
|
||||
await loadDevice()
|
||||
}
|
||||
})
|
||||
|
||||
async function loadDeviceTypes() {
|
||||
try {
|
||||
const response = await networkApi.types.list({ perpage: 100 })
|
||||
deviceTypes.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading device types:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendors() {
|
||||
try {
|
||||
const response = await vendorsApi.list({ perpage: 100 })
|
||||
vendors.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading vendors:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading locations:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadStatuses() {
|
||||
try {
|
||||
const response = await assetsApi.statuses.list()
|
||||
statuses.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading statuses:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadBusinessUnits() {
|
||||
try {
|
||||
const response = await businessunitsApi.list({ perpage: 100 })
|
||||
businessUnits.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading business units:', err)
|
||||
}
|
||||
}
|
||||
|
||||
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 || ''
|
||||
form.value.name = data.name || ''
|
||||
form.value.serialnumber = data.serialnumber || ''
|
||||
form.value.gaugelabreference = data.gaugelabreference || ''
|
||||
form.value.maintenancereference = data.maintenancereference || ''
|
||||
form.value.statusid = data.statusid || ''
|
||||
form.value.locationid = data.locationid || ''
|
||||
form.value.businessunitid = data.businessunitid || ''
|
||||
form.value.mapx = data.mapx
|
||||
form.value.mapy = data.mapy
|
||||
form.value.notes = data.notes || ''
|
||||
|
||||
// Network device specific
|
||||
if (data.networkdevice) {
|
||||
form.value.hostname = data.networkdevice.hostname || ''
|
||||
form.value.networkdevicetypeid = data.networkdevice.networkdevicetypeid || ''
|
||||
form.value.vendorid = data.networkdevice.vendorid || ''
|
||||
form.value.firmwareversion = data.networkdevice.firmwareversion || ''
|
||||
form.value.portcount = data.networkdevice.portcount
|
||||
form.value.rackunit = data.networkdevice.rackunit || ''
|
||||
form.value.ispoe = data.networkdevice.ispoe || false
|
||||
form.value.ismanaged = data.networkdevice.ismanaged || false
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading device:', err)
|
||||
error.value = 'Failed to load device'
|
||||
}
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
assetnumber: form.value.assetnumber,
|
||||
name: form.value.name || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
statusid: form.value.statusid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
businessunitid: form.value.businessunitid || null,
|
||||
hostname: form.value.hostname || null,
|
||||
networkdevicetypeid: form.value.networkdevicetypeid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
firmwareversion: form.value.firmwareversion || null,
|
||||
portcount: form.value.portcount || null,
|
||||
rackunit: form.value.rackunit || null,
|
||||
ispoe: form.value.ispoe,
|
||||
ismanaged: form.value.ismanaged,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy,
|
||||
notes: form.value.notes || null
|
||||
}
|
||||
|
||||
let assetId = currentAssetId.value
|
||||
let redirectId = deviceId
|
||||
if (isEdit.value) {
|
||||
const response = await networkApi.update(deviceId, payload)
|
||||
assetId = assetId || response.data?.data?.assetid
|
||||
} else {
|
||||
const response = await networkApi.create(payload)
|
||||
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 = apiError(err, 'Failed to save device')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
if (isEdit.value) {
|
||||
router.push(`/network/${deviceId}`)
|
||||
} else {
|
||||
router.push('/network')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-card {
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
fieldset {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
legend {
|
||||
font-weight: 600;
|
||||
padding: 0 0.5rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
margin-bottom: 0.375rem;
|
||||
font-weight: 500;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.form-group small {
|
||||
margin-top: 0.25rem;
|
||||
color: var(--text-light);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.checkboxes {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkbox-group input[type="checkbox"] {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.checkbox-group span {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
margin-top: 1.5rem;
|
||||
padding-top: 1.5rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
347
frontend/src/views/network/NetworkDevicesList.vue
Normal file
347
frontend/src/views/network/NetworkDevicesList.vue
Normal file
@@ -0,0 +1,347 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Network Devices</h2>
|
||||
<router-link to="/print/asset-label-batch/network_device" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/network/new" class="btn btn-primary">Add Device</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Type Tabs -->
|
||||
<div class="type-tabs">
|
||||
<button
|
||||
:class="{ active: selectedType === null }"
|
||||
@click="selectType(null)"
|
||||
>
|
||||
All ({{ totalCount }})
|
||||
</button>
|
||||
<button
|
||||
v-for="t in deviceTypes"
|
||||
:key="t.networkdevicetypeid"
|
||||
:class="{ active: selectedType === t.networkdevicetypeid }"
|
||||
@click="selectType(t.networkdevicetypeid)"
|
||||
>
|
||||
{{ t.networkdevicetype }} ({{ t.count || 0 }})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search by hostname, asset #, serial..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="vendorFilter" class="form-control" @change="loadDevices">
|
||||
<option value="">All Vendors</option>
|
||||
<option v-for="v in vendors" :key="v.vendorid" :value="v.vendorid">
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="locationFilter" class="form-control" @change="loadDevices">
|
||||
<option value="">All Locations</option>
|
||||
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Vendor</th>
|
||||
<th>Features</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="device in devices" :key="device.networkdevice?.networkdeviceid || device.assetid" class="clickable-row" @click="$router.push(`/network/${device.networkdevice?.networkdeviceid || device.assetid}`)">
|
||||
<td>{{ device.assetnumber }}</td>
|
||||
<td class="mono">{{ device.networkdevice?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ device.serialnumber || '-' }}</td>
|
||||
<td>{{ device.networkdevice?.networkdevicetypename || '-' }}</td>
|
||||
<td>{{ device.networkdevice?.vendorname || '-' }}</td>
|
||||
<td class="features">
|
||||
<span v-if="device.networkdevice?.ispoe" class="feature-tag poe">PoE</span>
|
||||
<span v-if="device.networkdevice?.ismanaged" class="feature-tag managed">Managed</span>
|
||||
<span v-if="device.networkdevice?.portcount" class="feature-tag ports">{{ device.networkdevice.portcount }} ports</span>
|
||||
<span v-if="!device.networkdevice?.ispoe && !device.networkdevice?.ismanaged && !device.networkdevice?.portcount">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="colorStyle(device.statuscolor)">
|
||||
{{ device.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ device.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/network/${device.networkdevice?.networkdeviceid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="devices.length === 0">
|
||||
<td colspan="9" style="text-align: center; color: var(--text-light);">
|
||||
No network devices found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { networkApi, vendorsApi, locationsApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const devices = ref([])
|
||||
const deviceTypes = ref([])
|
||||
const vendors = ref([])
|
||||
const locations = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadDevices })
|
||||
const selectedType = ref(null)
|
||||
const vendorFilter = ref('')
|
||||
const locationFilter = ref('')
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(25)
|
||||
const totalCount = ref(0)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([
|
||||
loadDeviceTypes(),
|
||||
loadVendors(),
|
||||
loadLocations()
|
||||
])
|
||||
await loadDevices()
|
||||
})
|
||||
|
||||
async function loadDeviceTypes() {
|
||||
try {
|
||||
const response = await networkApi.types.list({ perpage: 100 })
|
||||
deviceTypes.value = response.data.data || []
|
||||
// Get counts for each type
|
||||
await updateTypeCounts()
|
||||
} catch (error) {
|
||||
console.error('Error loading device types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function updateTypeCounts() {
|
||||
// Get summary for type counts
|
||||
try {
|
||||
const response = await networkApi.dashboardSummary()
|
||||
const byType = response.data.data?.bytype || response.data.data?.by_type || []
|
||||
totalCount.value = response.data.data?.total || 0
|
||||
|
||||
// Map counts to types
|
||||
deviceTypes.value = deviceTypes.value.map(t => {
|
||||
const found = byType.find(bt => bt.type === t.networkdevicetype)
|
||||
return { ...t, count: found?.count || 0 }
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Error loading type counts:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadVendors() {
|
||||
try {
|
||||
const response = await vendorsApi.list({ perpage: 100 })
|
||||
vendors.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading vendors:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadLocations() {
|
||||
try {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
locations.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading locations:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDevices() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
if (selectedType.value) params.typeid = selectedType.value
|
||||
if (vendorFilter.value) params.vendorid = vendorFilter.value
|
||||
if (locationFilter.value) params.locationid = locationFilter.value
|
||||
|
||||
const response = await networkApi.list(params)
|
||||
devices.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading network devices:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectType(typeId) {
|
||||
selectedType.value = typeId
|
||||
setPage(1)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadDevices()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
if (p >= 1 && p <= totalPages.value) {
|
||||
setPage(p)
|
||||
loadDevices()
|
||||
}
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadDevices()
|
||||
}
|
||||
|
||||
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' || s === 'maintenance') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'decommissioned') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.type-tabs {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.type-tabs button {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.type-tabs button:hover {
|
||||
background: var(--bg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.type-tabs button.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.filters .form-control {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.filters select.form-control {
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.features {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
padding: 0.2rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border-radius: 4px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag.poe {
|
||||
background: #d4edda;
|
||||
color: #155724;
|
||||
}
|
||||
|
||||
.feature-tag.managed {
|
||||
background: #e3f2fd;
|
||||
color: #1565c0;
|
||||
}
|
||||
|
||||
.feature-tag.ports {
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.poe {
|
||||
background: #1e3a29;
|
||||
color: #4ade80;
|
||||
}
|
||||
|
||||
.feature-tag.managed {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
72
frontend/src/views/network/NetworkHub.vue
Normal file
72
frontend/src/views/network/NetworkHub.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Network</h1>
|
||||
</div>
|
||||
<div class="hub-tabs">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="hub-tab"
|
||||
:class="{ active: active === tab.key }"
|
||||
@click="setTab(tab.key)"
|
||||
>{{ tab.label }}</button>
|
||||
</div>
|
||||
<component :is="current" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import NetworkDevicesList from './NetworkDevicesList.vue'
|
||||
import SubnetsBrowse from './SubnetsBrowse.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
// VLANs are a layer under a subnet (each Networks row shows its VLAN); VLAN
|
||||
// naming lives in Settings, so the hub is just Devices + Networks.
|
||||
const tabs = [
|
||||
{ key: 'devices', label: 'Devices', comp: NetworkDevicesList },
|
||||
{ key: 'networks', label: 'Networks', comp: SubnetsBrowse },
|
||||
]
|
||||
|
||||
const active = ref(tabs.some(t => t.key === route.query.tab) ? route.query.tab : 'devices')
|
||||
const current = computed(() => (tabs.find(t => t.key === active.value) || tabs[0]).comp)
|
||||
|
||||
function setTab(key) {
|
||||
active.value = key
|
||||
router.replace({ query: { ...route.query, tab: key } })
|
||||
}
|
||||
|
||||
watch(() => route.query.tab, (value) => {
|
||||
if (value && value !== active.value && tabs.some(t => t.key === value)) {
|
||||
active.value = value
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hub-tabs {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.hub-tab {
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 2px solid transparent;
|
||||
padding: 0.6rem 1rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
.hub-tab:hover { color: var(--text); }
|
||||
.hub-tab.active {
|
||||
color: var(--primary);
|
||||
border-bottom-color: var(--primary);
|
||||
}
|
||||
</style>
|
||||
88
frontend/src/views/network/SubnetDetail.vue
Normal file
88
frontend/src/views/network/SubnetDetail.vue
Normal file
@@ -0,0 +1,88 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="!subnet" class="empty-state"><p>Network not found.</p></div>
|
||||
<template v-else>
|
||||
<div class="page-header">
|
||||
<h1>{{ subnet.name || subnet.cidr }}</h1>
|
||||
<router-link to="/networks" class="btn btn-secondary">Back to Networks</router-link>
|
||||
</div>
|
||||
|
||||
<div class="content-grid">
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row"><span class="info-label">CIDR</span><span class="info-value mono">{{ subnet.cidr }}</span></div>
|
||||
<div class="info-row"><span class="info-label">Network Address</span><span class="info-value mono">{{ subnet.networkaddress || '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">Type</span><span class="info-value">{{ subnet.subnettype || '-' }}</span></div>
|
||||
<div class="info-row"><span class="info-label">VLAN</span><span class="info-value">{{ subnet.vlannumber || subnet.vlanid || '-' }}</span></div>
|
||||
<div class="info-row" v-if="subnet.gatewayip"><span class="info-label">Gateway</span><span class="info-value mono">{{ subnet.gatewayip }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section-card" v-if="subnet.description">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<div class="notes-content">{{ subnet.description }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="content-column">
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Devices on this network ({{ devices.length }})</h3>
|
||||
<div v-if="devices.length === 0" class="empty-state"><p>No devices found in this range.</p></div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr><th>Device</th><th>IP</th><th>Type</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="dev in devices" :key="dev.assetid">
|
||||
<td>
|
||||
<router-link v-if="dev.url" :to="dev.url">{{ dev.name || dev.assetnumber }}</router-link>
|
||||
<span v-else>{{ dev.name || dev.assetnumber }}</span>
|
||||
</td>
|
||||
<td class="mono">{{ dev.ipaddress }}</td>
|
||||
<td>{{ typeLabel(dev.assettype) }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { networkApi } from '@/api'
|
||||
|
||||
const route = useRoute()
|
||||
const subnet = ref(null)
|
||||
const loading = ref(true)
|
||||
|
||||
const devices = computed(() => subnet.value?.devices || [])
|
||||
|
||||
const TYPE_LABELS = {
|
||||
computer: 'PC', network_device: 'Network Device', printer: 'Printer',
|
||||
machine: 'Machine', measuring_tool: 'Measuring Tool',
|
||||
}
|
||||
function typeLabel(type) {
|
||||
return TYPE_LABELS[type] || type || '-'
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await networkApi.subnets.get(route.params.id)
|
||||
subnet.value = response.data?.data || response.data
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
75
frontend/src/views/network/SubnetsBrowse.vue
Normal file
75
frontend/src/views/network/SubnetsBrowse.vue
Normal file
@@ -0,0 +1,75 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="filters">
|
||||
<input v-model="search" type="text" placeholder="Search networks..." class="form-control" />
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="filtered.length === 0" class="empty-state"><p>No networks found.</p></div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>CIDR</th>
|
||||
<th>Type</th>
|
||||
<th>VLAN</th>
|
||||
<th>Notes</th>
|
||||
<th class="actions"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="subnet in filtered" :key="subnet.subnetid" class="clickable-row"
|
||||
@click="$router.push(`/networks/${subnet.subnetid}`)">
|
||||
<td>{{ subnet.name || '-' }}</td>
|
||||
<td class="mono">{{ subnet.cidr }}</td>
|
||||
<td>{{ subnet.subnettype || '-' }}</td>
|
||||
<td>{{ subnet.vlannumber || subnet.vlanid || '-' }}</td>
|
||||
<td class="cell-truncate" :title="subnet.description">{{ subnet.description || '-' }}</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/networks/${subnet.subnetid}`" class="btn btn-sm btn-secondary" @click.stop>View</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { networkApi } from '@/api'
|
||||
|
||||
const subnets = ref([])
|
||||
const loading = ref(true)
|
||||
const search = ref('')
|
||||
|
||||
const filtered = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
if (!term) return subnets.value
|
||||
return subnets.value.filter(s =>
|
||||
(s.name || '').toLowerCase().includes(term) ||
|
||||
(s.cidr || '').toLowerCase().includes(term) ||
|
||||
(s.subnettype || '').toLowerCase().includes(term) ||
|
||||
(s.description || '').toLowerCase().includes(term))
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await networkApi.subnets.list({ per_page: 1000 })
|
||||
subnets.value = response.data?.data || response.data || []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.clickable-row { cursor: pointer; }
|
||||
.clickable-row:hover { background: var(--bg); }
|
||||
</style>
|
||||
642
frontend/src/views/notifications/NotificationForm.vue
Normal file
642
frontend/src/views/notifications/NotificationForm.vue
Normal file
@@ -0,0 +1,642 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Notification' : (isDetail ? 'Notification Details' : 'New Notification') }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="saveNotification">
|
||||
<div class="form-group">
|
||||
<label for="notificationtypeid">Type *</label>
|
||||
<select
|
||||
id="notificationtypeid"
|
||||
v-model="form.notificationtypeid"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="isDetail"
|
||||
@change="onTypeChange"
|
||||
>
|
||||
<option value="">-- Select Type --</option>
|
||||
<option
|
||||
v-for="type in types"
|
||||
:key="type.notificationtypeid"
|
||||
:value="type.notificationtypeid"
|
||||
>
|
||||
{{ type.typename }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">Classification type for this notification</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notification">{{ messageLabel }} *</label>
|
||||
<textarea
|
||||
id="notification"
|
||||
v-model="form.notification"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
required
|
||||
:disabled="isDetail"
|
||||
:placeholder="messagePlaceholder"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Employee Search - for Recognition and Recertification -->
|
||||
<div v-if="isEmployeeType" class="form-group">
|
||||
<label>Employee(s) *</label>
|
||||
<div class="employee-search-container">
|
||||
<input
|
||||
v-model="employeeSearch"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search by name..."
|
||||
:disabled="isDetail"
|
||||
@input="searchEmployees"
|
||||
@keydown.enter.prevent="addCustomEmployee"
|
||||
/>
|
||||
<div v-if="employeeResults.length" class="employee-dropdown">
|
||||
<div
|
||||
v-for="emp in employeeResults"
|
||||
:key="emp.SSO"
|
||||
class="employee-option"
|
||||
@click="selectEmployee(emp)"
|
||||
>
|
||||
<span class="emp-name">{{ emp.First_Name }} {{ emp.Last_Name }}</span>
|
||||
<span class="emp-sso">{{ emp.SSO }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<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">
|
||||
<div
|
||||
v-for="(emp, idx) in selectedEmployees"
|
||||
:key="idx"
|
||||
class="selected-employee"
|
||||
>
|
||||
<span>{{ emp.name }}</span>
|
||||
<button v-if="!isDetail" type="button" class="btn-remove" @click="removeEmployee(idx)">×</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEmployeeType" class="form-group">
|
||||
<label for="businessunitid">Business Unit</label>
|
||||
<select
|
||||
id="businessunitid"
|
||||
v-model="form.businessunitid"
|
||||
class="form-control"
|
||||
:disabled="isDetail"
|
||||
>
|
||||
<option value="">-- All Business Units --</option>
|
||||
<option
|
||||
v-for="bu in businessUnits"
|
||||
:key="bu.businessunitid"
|
||||
:value="bu.businessunitid"
|
||||
>
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">Leave blank to apply to all</small>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEmployeeType" class="form-group">
|
||||
<label for="appid">Related Application</label>
|
||||
<select
|
||||
id="appid"
|
||||
v-model="form.appid"
|
||||
class="form-control"
|
||||
:disabled="isDetail"
|
||||
>
|
||||
<option value="">-- No Application --</option>
|
||||
<option
|
||||
v-for="app in applications"
|
||||
:key="app.appid"
|
||||
:value="app.appid"
|
||||
>
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<small class="form-hint">Link to a specific application (e.g., for software updates)</small>
|
||||
</div>
|
||||
|
||||
<div v-if="!isEmployeeType" class="form-group">
|
||||
<label for="ticketnumber">Ticket Number</label>
|
||||
<input
|
||||
id="ticketnumber"
|
||||
v-model="form.ticketnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
maxlength="50"
|
||||
:disabled="isDetail"
|
||||
placeholder="GEINC123456 or GECHG123456"
|
||||
/>
|
||||
<small class="form-hint">Optional ServiceNow ticket number</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="link">More Info URL</label>
|
||||
<input
|
||||
id="link"
|
||||
v-model="form.link"
|
||||
type="url"
|
||||
class="form-control"
|
||||
maxlength="500"
|
||||
:disabled="isDetail"
|
||||
placeholder="https://..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Time fields - Hidden for Recognition (auto-set) -->
|
||||
<div v-if="!isEmployeeType" class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="starttime">Start Time *</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="starttime"
|
||||
v-model="form.starttime"
|
||||
type="datetime-local"
|
||||
class="form-control"
|
||||
required
|
||||
:disabled="isDetail"
|
||||
/>
|
||||
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('starttime')">
|
||||
Now
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-hint">When notification becomes visible</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="endtime">End Time</label>
|
||||
<div class="input-group">
|
||||
<input
|
||||
id="endtime"
|
||||
v-model="form.endtime"
|
||||
type="datetime-local"
|
||||
class="form-control"
|
||||
:disabled="isDetail"
|
||||
/>
|
||||
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="setNow('endtime')">
|
||||
Now
|
||||
</button>
|
||||
<button v-if="!isDetail" type="button" class="btn btn-secondary" @click="form.endtime = ''">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
<small class="form-hint">Leave blank for indefinite</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row checkbox-row">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.isactive"
|
||||
:disabled="isDetail"
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<small class="form-hint">Uncheck to save as draft</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="form.isshopfloor"
|
||||
:disabled="isDetail"
|
||||
/>
|
||||
Show on Shopfloor Dashboard
|
||||
</label>
|
||||
<small class="form-hint">Display on TV dashboard</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<template v-if="isDetail">
|
||||
<router-link :to="`/notifications/${route.params.id}/edit`" class="btn btn-primary">
|
||||
Edit
|
||||
</router-link>
|
||||
<router-link to="/notifications" class="btn btn-secondary">Back</router-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : (isEdit ? 'Update' : 'Create') }}
|
||||
</button>
|
||||
<router-link to="/notifications" class="btn btn-secondary">Cancel</router-link>
|
||||
</template>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { notificationsApi, applicationsApi, businessUnitsApi, employeesApi } from '@/api'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const isEdit = computed(() => route.name === 'notification-edit')
|
||||
const isDetail = computed(() => route.name === 'notification-detail')
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const types = ref([])
|
||||
const businessUnits = ref([])
|
||||
const applications = ref([])
|
||||
|
||||
// Employee search
|
||||
const employeeSearch = ref('')
|
||||
const employeeResults = ref([])
|
||||
const selectedEmployees = ref([])
|
||||
|
||||
const form = ref({
|
||||
notification: '',
|
||||
notificationtypeid: '',
|
||||
businessunitid: '',
|
||||
appid: '',
|
||||
ticketnumber: '',
|
||||
link: '',
|
||||
starttime: '',
|
||||
endtime: '',
|
||||
isactive: true,
|
||||
isshopfloor: false,
|
||||
employeesso: ''
|
||||
})
|
||||
|
||||
function selectedTypeName() {
|
||||
const selectedType = types.value.find(t => t.notificationtypeid === parseInt(form.value.notificationtypeid))
|
||||
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 {
|
||||
// Load dropdown data in parallel
|
||||
const [typesRes, buRes, appsRes] = await Promise.all([
|
||||
notificationsApi.types.list(),
|
||||
businessUnitsApi.list().catch(() => ({ data: { data: [] } })),
|
||||
applicationsApi.list({ perpage: 500 }).catch(() => ({ data: { data: [] } }))
|
||||
])
|
||||
|
||||
types.value = typesRes.data.data || []
|
||||
businessUnits.value = buRes.data?.data || []
|
||||
applications.value = appsRes.data?.data || []
|
||||
|
||||
// Load notification if editing or viewing
|
||||
if (route.params.id) {
|
||||
const response = await notificationsApi.get(route.params.id)
|
||||
const n = response.data.data
|
||||
|
||||
form.value = {
|
||||
notification: n.notification || '',
|
||||
notificationtypeid: n.notificationtypeid || '',
|
||||
businessunitid: n.businessunitid || '',
|
||||
appid: n.appid || '',
|
||||
ticketnumber: n.ticketnumber || '',
|
||||
link: n.link || '',
|
||||
starttime: formatDateForInput(n.starttime),
|
||||
endtime: formatDateForInput(n.endtime),
|
||||
isactive: n.isactive !== false,
|
||||
isshopfloor: n.isshopfloor || false,
|
||||
employeesso: n.employeesso || ''
|
||||
}
|
||||
|
||||
// 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(',')
|
||||
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
|
||||
form.value.starttime = formatDateForInput(new Date().toISOString())
|
||||
// Default to Recognition type
|
||||
const recognitionType = types.value.find(t => t.typename?.toLowerCase() === 'recognition')
|
||||
if (recognitionType) {
|
||||
form.value.notificationtypeid = recognitionType.notificationtypeid
|
||||
onTypeChange()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function formatDateForInput(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return date.toISOString().slice(0, 16)
|
||||
}
|
||||
|
||||
function setNow(field) {
|
||||
form.value[field] = formatDateForInput(new Date().toISOString())
|
||||
}
|
||||
|
||||
function onTypeChange() {
|
||||
// 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 = ''
|
||||
}
|
||||
}
|
||||
|
||||
// Employee search
|
||||
let searchTimeout = null
|
||||
async function searchEmployees() {
|
||||
if (searchTimeout) clearTimeout(searchTimeout)
|
||||
|
||||
const query = employeeSearch.value.trim()
|
||||
if (query.length < 2) {
|
||||
employeeResults.value = []
|
||||
return
|
||||
}
|
||||
|
||||
searchTimeout = setTimeout(async () => {
|
||||
try {
|
||||
const response = await employeesApi.search(query)
|
||||
employeeResults.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Employee search error:', err)
|
||||
employeeResults.value = []
|
||||
}
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function selectEmployee(emp) {
|
||||
// Check if already selected
|
||||
if (selectedEmployees.value.some(e => e.sso === String(emp.SSO))) {
|
||||
return
|
||||
}
|
||||
|
||||
selectedEmployees.value.push({
|
||||
sso: String(emp.SSO),
|
||||
name: `${emp.First_Name} ${emp.Last_Name}`.trim()
|
||||
})
|
||||
|
||||
employeeSearch.value = ''
|
||||
employeeResults.value = []
|
||||
updateEmployeeSso()
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
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 = []
|
||||
updateEmployeeSso()
|
||||
}
|
||||
|
||||
function removeEmployee(idx) {
|
||||
selectedEmployees.value.splice(idx, 1)
|
||||
updateEmployeeSso()
|
||||
}
|
||||
|
||||
function updateEmployeeSso() {
|
||||
form.value.employeesso = selectedEmployees.value.map(e => e.sso).join(',')
|
||||
}
|
||||
|
||||
async function saveNotification() {
|
||||
error.value = ''
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
const data = {
|
||||
notification: form.value.notification,
|
||||
notificationtypeid: parseInt(form.value.notificationtypeid) || null,
|
||||
businessunitid: parseInt(form.value.businessunitid) || null,
|
||||
appid: parseInt(form.value.appid) || null,
|
||||
ticketnumber: form.value.ticketnumber || null,
|
||||
link: form.value.link || null,
|
||||
starttime: form.value.starttime ? new Date(form.value.starttime).toISOString() : null,
|
||||
endtime: form.value.endtime ? new Date(form.value.endtime).toISOString() : null,
|
||||
isactive: form.value.isactive,
|
||||
isshopfloor: form.value.isshopfloor,
|
||||
employeesso: form.value.employeesso || null
|
||||
}
|
||||
|
||||
// For employee-photo types, also send employeename
|
||||
if (isEmployeeType.value && selectedEmployees.value.length > 0) {
|
||||
data.employeename = selectedEmployees.value.map(e => e.name).join(', ')
|
||||
}
|
||||
|
||||
if (isEdit.value) {
|
||||
await notificationsApi.update(route.params.id, data)
|
||||
} else {
|
||||
await notificationsApi.create(data)
|
||||
}
|
||||
|
||||
router.push('/notifications')
|
||||
} catch (err) {
|
||||
console.error('Error saving notification:', err)
|
||||
error.value = apiError(err, 'Failed to save notification')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.input-group {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.input-group .form-control {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.input-group .btn {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.checkbox-row .form-group {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.checkbox-row label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
/* Employee search styles */
|
||||
.employee-search-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.employee-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg-card-solid, #1a1a1a);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 0.25rem;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.employee-option {
|
||||
padding: 0.5rem 0.75rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.employee-option:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.emp-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.emp-sso {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.selected-employees {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.selected-employee {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.checkbox-row {
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
350
frontend/src/views/notifications/NotificationTypesList.vue
Normal file
350
frontend/src/views/notifications/NotificationTypesList.vue
Normal file
@@ -0,0 +1,350 @@
|
||||
<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'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
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 = apiError(err, '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>
|
||||
175
frontend/src/views/notifications/NotificationsList.vue
Normal file
175
frontend/src/views/notifications/NotificationsList.vue
Normal file
@@ -0,0 +1,175 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Notifications</h1>
|
||||
<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">
|
||||
<input
|
||||
v-model="searchQuery"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search notifications..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
<select v-model="selectedType" class="form-control" @change="loadNotifications">
|
||||
<option value="">All Types</option>
|
||||
<option v-for="type in types" :key="type.notificationtypeid" :value="type.notificationtypeid">
|
||||
{{ type.typename }}
|
||||
</option>
|
||||
</select>
|
||||
<select v-model="currentFilter" class="form-control" @change="loadNotifications">
|
||||
<option value="">All</option>
|
||||
<option value="current">Current Only</option>
|
||||
<option value="pinned">Pinned Only</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<div v-else-if="notifications.length === 0" class="empty">
|
||||
No notifications found.
|
||||
</div>
|
||||
<div v-else class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Type</th>
|
||||
<th>Start Date</th>
|
||||
<th>End Date</th>
|
||||
<th>Status</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="notification in notifications" :key="notification.notificationid">
|
||||
<td>
|
||||
<router-link :to="`/notifications/${notification.notificationid}/edit`">
|
||||
{{ notification.title }}
|
||||
</router-link>
|
||||
<span v-if="notification.ispinned" class="badge badge-primary" title="Pinned">Pinned</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :style="{ backgroundColor: notification.typecolor }">
|
||||
{{ notification.typename }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ formatDate(notification.startdate) }}</td>
|
||||
<td>{{ notification.enddate ? formatDate(notification.enddate) : 'No end' }}</td>
|
||||
<td>
|
||||
<span :class="['badge', notification.iscurrent ? 'badge-success' : 'badge-secondary']">
|
||||
{{ notification.iscurrent ? 'Active' : 'Inactive' }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<router-link :to="`/notifications/${notification.notificationid}/edit`" class="btn btn-small">
|
||||
Edit
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { notificationsApi } from '@/api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const notifications = ref([])
|
||||
const types = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search: searchQuery, setPage, setSearch } = useListQuery({ onChange: loadNotifications })
|
||||
const selectedType = ref('')
|
||||
const currentFilter = ref('')
|
||||
const perPage = ref(20)
|
||||
const total = ref(0)
|
||||
const totalPages = ref(1)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(async () => {
|
||||
await loadTypes()
|
||||
await loadNotifications()
|
||||
})
|
||||
|
||||
async function loadTypes() {
|
||||
try {
|
||||
const response = await notificationsApi.types.list()
|
||||
types.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading types:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadNotifications() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
|
||||
if (searchQuery.value) {
|
||||
params.search = searchQuery.value
|
||||
}
|
||||
if (selectedType.value) {
|
||||
params.typeid = selectedType.value
|
||||
}
|
||||
if (currentFilter.value === 'current') {
|
||||
params.current = 'true'
|
||||
} else if (currentFilter.value === 'pinned') {
|
||||
params.pinned = 'true'
|
||||
}
|
||||
|
||||
const response = await notificationsApi.list(params)
|
||||
notifications.value = response.data.data
|
||||
total.value = response.data.meta?.pagination?.total || notifications.value.length
|
||||
totalPages.value = Math.ceil(total.value / perPage.value)
|
||||
} catch (error) {
|
||||
console.error('Error loading notifications:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(searchQuery.value)
|
||||
loadNotifications()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadNotifications()
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return ''
|
||||
return new Date(dateStr).toLocaleDateString()
|
||||
}
|
||||
</script>
|
||||
426
frontend/src/views/pcs/PCDetail.vue
Normal file
426
frontend/src/views/pcs/PCDetail.vue
Normal file
@@ -0,0 +1,426 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Computer Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/asset-label/computer/${$route.params.id}`" class="btn btn-secondary" target="_blank">Print Label</router-link>
|
||||
<router-link :to="`/pcs/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="computer">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="computer.computer?.imageurl">
|
||||
<img :src="computer.computer.imageurl" :alt="computer.computer.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ computer.assetnumber }}</h1>
|
||||
<span v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname" class="hero-alias">{{ computer.computer.hostname }}</span>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-info">Computer</span>
|
||||
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
||||
{{ computer.statusname || 'Unknown' }}
|
||||
</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="colorStyle(heroWarranty.statuscolor)"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${formatWarrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ formatWarrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="computer.computer?.computertypename">
|
||||
<span class="hero-detail-label">Type</span>
|
||||
<span class="hero-detail-value">{{ computer.computer.computertypename }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="computer.computer?.osname">
|
||||
<span class="hero-detail-label">OS</span>
|
||||
<span class="hero-detail-value">{{ computer.computer.osname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="computer.locationname">
|
||||
<span class="hero-detail-label">Location</span>
|
||||
<span class="hero-detail-value">{{ computer.locationname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value">{{ computer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="computer.name">
|
||||
<span class="info-label">Name</span>
|
||||
<span class="info-value">{{ computer.name }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('fqdn', 'computer') && computer.computer?.hostname">
|
||||
<span class="info-label">Hostname</span>
|
||||
<span class="info-value mono">{{ computer.computer.hostname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="computer.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ computer.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('gaugelabreference', 'computer') && computer.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ computer.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('maintenancereference', 'computer') && computer.maintenancereference">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ computer.maintenancereference }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Hardware Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Hardware</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Remote Access -->
|
||||
<div class="section-card">
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
</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>
|
||||
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="computer.mapx != null && computer.mapy != null"
|
||||
:left="computer.mapx"
|
||||
:top="computer.mapy"
|
||||
:machineName="computer.assetnumber"
|
||||
>
|
||||
<span class="location-link">{{ computer.locationname || 'On Map' }}</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>{{ computer.locationname || '-' }}</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ computer.businessunitname || '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Installed Applications -->
|
||||
<div class="section-card" v-if="installedApps.length > 0">
|
||||
<h3 class="section-title">Installed Applications</h3>
|
||||
<div class="app-list">
|
||||
<router-link
|
||||
v-for="app in installedApps"
|
||||
:key="app.id"
|
||||
:to="`/applications/${app.appid}`"
|
||||
class="app-item"
|
||||
>
|
||||
<div class="app-info">
|
||||
<span class="app-name">{{ app.appname }}</span>
|
||||
<span class="app-version" v-if="app.installedversion">v{{ app.installedversion }}</span>
|
||||
</div>
|
||||
<div class="app-desc" v-if="app.appdescription">
|
||||
{{ app.appdescription }}
|
||||
</div>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="computer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="computer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (controls, defaultprinter, ...) -->
|
||||
<AssetRelationships v-if="computer.assetid" :assetid="computer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="computer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ computer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(computer.createddate) }}<template v-if="computer.createdby"> by {{ computer.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(computer.modifieddate) }}<template v-if="computer.modifiedby"> by {{ computer.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Computer not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computersApi, applicationsApi } from '../../api'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import AssetRelationships from '../../components/AssetRelationships.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const computer = ref(null)
|
||||
const installedApps = ref([])
|
||||
|
||||
// Warranty fetch + hero badge (shared across asset detail pages).
|
||||
const { warranties, heroWarranty, warrantyDate: formatWarrantyDate } = useWarrantyBadge(() => computer.value?.assetid)
|
||||
|
||||
// relationships render via the shared AssetRelationships card
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await computersApi.get(route.params.id)
|
||||
computer.value = response.data.data
|
||||
|
||||
// Load installed applications
|
||||
try {
|
||||
const appsResponse = await applicationsApi.getMachineApps(route.params.id)
|
||||
installedApps.value = appsResponse.data.data || []
|
||||
} catch (appError) {
|
||||
}
|
||||
// Warranties load via useWarrantyBadge (watches computer.assetid).
|
||||
} catch (error) {
|
||||
console.error('Error loading computer:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
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'
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<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;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-item {
|
||||
display: block;
|
||||
padding: 1rem;
|
||||
background: var(--bg);
|
||||
border-radius: 8px;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.app-item:hover {
|
||||
background: var(--border);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.app-info {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.app-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.app-version {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.app-desc {
|
||||
margin-top: 0.375rem;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Network details */
|
||||
.network-details {
|
||||
margin-top: 0.5rem;
|
||||
display: flex;
|
||||
gap: 1.25rem;
|
||||
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>
|
||||
609
frontend/src/views/pcs/PCForm.vue
Normal file
609
frontend/src/views/pcs/PCForm.vue
Normal file
@@ -0,0 +1,609 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit PC' : 'New PC' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="savePC">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinenumber">PC Number *</label>
|
||||
<input
|
||||
id="machinenumber"
|
||||
v-model="form.machinenumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
@input="onPcNumberInput"
|
||||
/>
|
||||
<small class="form-hint">Defaults to the serial number; editable</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alias">Alias</label>
|
||||
<input
|
||||
id="alias"
|
||||
v-model="form.alias"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group" v-if="isEnabled('fqdn', 'computer')">
|
||||
<label for="hostname">Hostname</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('gaugelabreference', 'computer') || isEnabled('maintenancereference', 'computer')">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'computer')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input
|
||||
id="gaugelabreference"
|
||||
v-model="form.gaugelabreference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="isEnabled('maintenancereference', 'computer')">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input
|
||||
id="maintenancereference"
|
||||
v-model="form.maintenancereference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">PC Type *</label>
|
||||
<select
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
required
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="pt in pcTypes"
|
||||
:key="pt.computertypeid"
|
||||
:value="pt.computertypeid"
|
||||
>
|
||||
{{ pt.computertype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="osid">Operating System</label>
|
||||
<select
|
||||
id="osid"
|
||||
v-model="form.osid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select OS...</option>
|
||||
<option
|
||||
v-for="os in operatingsystems"
|
||||
:key="os.osid"
|
||||
:value="os.osid"
|
||||
>
|
||||
{{ os.osname }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select
|
||||
id="statusid"
|
||||
v-model="form.statusid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
<option
|
||||
v-for="s in statuses"
|
||||
:key="s.statusid"
|
||||
:value="s.statusid"
|
||||
>
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select
|
||||
id="locationid"
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select model...</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
|
||||
Select vendor or PC type to filter models
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC-specific fields -->
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Network Settings</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ipaddress">IP Address</label>
|
||||
<input
|
||||
id="ipaddress"
|
||||
v-model="form.ipaddress"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="loggedinuser">Logged In User</label>
|
||||
<input
|
||||
id="loggedinuser"
|
||||
v-model="form.loggedinuser"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Map Location Picker -->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</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;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save PC' }}
|
||||
</button>
|
||||
<router-link to="/pcs" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
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'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const route = useRoute()
|
||||
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)
|
||||
function onPcNumberInput() {
|
||||
manualPcNumber.value = true
|
||||
}
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
machinenumber: '',
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
machinetypeid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
locationid: '',
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
accessmethods: [],
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
ipaddress: ''
|
||||
})
|
||||
|
||||
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([])
|
||||
const operatingsystems = ref([])
|
||||
|
||||
// Default PC Number to serial while the user hasn't typed their own (new PC only)
|
||||
watch(() => form.value.serialnumber, (serial) => {
|
||||
if (!isEdit.value && !manualPcNumber.value && serial) {
|
||||
form.value.machinenumber = serial
|
||||
}
|
||||
})
|
||||
|
||||
// Filter models by selected vendor and PC type
|
||||
const filteredModels = computed(() => {
|
||||
// filter by vendor only (PC type now maps to computertypeid, a different id
|
||||
// space than a model's machinetypeid)
|
||||
if (!form.value.vendorid) return models.value
|
||||
return models.value.filter(m => m.vendorid === form.value.vendorid)
|
||||
})
|
||||
|
||||
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, 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 }),
|
||||
computersApi.protocols.list()
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
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]
|
||||
|
||||
form.value = {
|
||||
machinenumber: pc.assetnumber || '',
|
||||
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
|
||||
hostname: ext.hostname || '',
|
||||
serialnumber: pc.serialnumber || '',
|
||||
gaugelabreference: pc.gaugelabreference || '',
|
||||
maintenancereference: pc.maintenancereference || '',
|
||||
machinetypeid: ext.computertypeid || '',
|
||||
statusid: pc.statusid || '',
|
||||
vendorid: ext.vendorid || '',
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: pc.locationid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||
protocolid: a.protocolid,
|
||||
portoverride: a.portoverride ?? null
|
||||
})),
|
||||
notes: pc.notes || '',
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
ipaddress: primaryComm?.ipaddress || ''
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function savePC() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// One payload for the computers plugin (asset core + computer extension +
|
||||
// primary IP). "PC Number" is the business identifier (assetnumber).
|
||||
const payload = {
|
||||
assetnumber: form.value.machinenumber,
|
||||
hostname: form.value.hostname || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
computertypeid: form.value.machinetypeid || null,
|
||||
statusid: form.value.statusid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
accessmethods: form.value.accessmethods,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
// only set display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
let assetId = currentAssetId.value
|
||||
if (isEdit.value) {
|
||||
const response = await computersApi.update(route.params.id, payload)
|
||||
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
|
||||
} else {
|
||||
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')
|
||||
} catch (err) {
|
||||
console.error('Error saving PC:', err)
|
||||
error.value = apiError(err, 'Failed to save PC')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</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;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
height: calc(90vh - 140px);
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.shopfloor-map) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.map-container) {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
</style>
|
||||
211
frontend/src/views/pcs/PCsList.vue
Normal file
211
frontend/src/views/pcs/PCsList.vue
Normal file
@@ -0,0 +1,211 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Computers</h2>
|
||||
<router-link to="/print/asset-label-batch/computer" class="btn btn-secondary" target="_blank">Print Labels</router-link>
|
||||
<router-link to="/pcs/new" class="btn btn-primary">Add Computer</router-link>
|
||||
</div>
|
||||
|
||||
<!-- Filters -->
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search computers..."
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Asset Tag</th>
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Remote Access</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in computers" :key="item.assetid" class="clickable-row" @click="$router.push(`/pcs/${item.computer?.computerid || item.assetid}`)">
|
||||
<td>{{ item.assetnumber }}</td>
|
||||
<td>{{ item.computer?.hostname || '-' }}</td>
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.computer?.computertypename || '-' }}</td>
|
||||
<td class="features">
|
||||
<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" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ item.locationname || '-' }}</td>
|
||||
<td class="actions" @click.stop>
|
||||
<router-link
|
||||
:to="`/pcs/${item.computer?.computerid || item.assetid}`"
|
||||
class="btn btn-secondary btn-sm"
|
||||
>
|
||||
View
|
||||
</router-link>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="computers.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">
|
||||
No computers found
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<PaginationBar
|
||||
:page="page"
|
||||
:totalPages="totalPages"
|
||||
:perPage="perPage"
|
||||
@update:page="goToPage"
|
||||
@update:perPage="changePerPage"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useListQuery } from '@/composables/listQuery'
|
||||
|
||||
const computers = ref([])
|
||||
const loading = ref(true)
|
||||
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadComputers })
|
||||
const totalPages = ref(1)
|
||||
const perPage = ref(20)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
onMounted(() => {
|
||||
loadComputers()
|
||||
})
|
||||
|
||||
async function loadComputers() {
|
||||
loading.value = true
|
||||
try {
|
||||
const params = {
|
||||
page: page.value,
|
||||
perpage: perPage.value
|
||||
}
|
||||
if (search.value) params.search = search.value
|
||||
|
||||
const response = await computersApi.list(params)
|
||||
computers.value = response.data.data || []
|
||||
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
|
||||
} catch (error) {
|
||||
console.error('Error loading computers:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function debouncedSearch() {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
setSearch(search.value)
|
||||
loadComputers()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function goToPage(p) {
|
||||
setPage(p)
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
function changePerPage(newPerPage) {
|
||||
perPage.value = newPerPage
|
||||
setPage(1)
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
</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;
|
||||
}
|
||||
|
||||
/* keep the cell as a table-cell; flex on a <td> strips table-cell layout and
|
||||
offsets the row. lay tags out inline instead. */
|
||||
.features {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.feature-tag {
|
||||
display: inline-block;
|
||||
margin-right: 0.375rem;
|
||||
padding: 0.3rem 0.625rem;
|
||||
font-size: 0.875rem;
|
||||
border-radius: 5px;
|
||||
background: var(--bg);
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.feature-tag:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
/* the global .actions rule is inline-flex, which also breaks table-cell
|
||||
alignment when applied straight on a <td>; pin it back to a cell here. */
|
||||
td.actions {
|
||||
display: table-cell;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.feature-tag.active {
|
||||
background: #e3f2fd;
|
||||
color: #1976d2;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.feature-tag.active {
|
||||
background: #1e3a5f;
|
||||
color: #60a5fa;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
425
frontend/src/views/print/AssetLabel.vue
Normal file
425
frontend/src/views/print/AssetLabel.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Controls (never printed) -->
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Print Asset Label</h3>
|
||||
<div v-if="loading" class="loading-msg">Loading...</div>
|
||||
<div v-else-if="!asset" class="error-msg">Asset not found</div>
|
||||
<template v-else>
|
||||
<div class="control-row">
|
||||
<label>Style
|
||||
<select v-model="style">
|
||||
<option value="card">Card (badge)</option>
|
||||
<option value="plain">Plain (code only)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Code type
|
||||
<select v-model="codetype">
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Encodes
|
||||
<select v-model="encodes">
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<p v-if="encodes === 'location' && !asset.locationcode" class="control-note">
|
||||
This tool has no location; the label falls back to the asset page.
|
||||
</p>
|
||||
<div class="control-row">
|
||||
<label>Output
|
||||
<select v-model="outputMode">
|
||||
<option value="single">Single label (standalone)</option>
|
||||
<option value="uline-sheet">Place on ULINE 6-up sheet</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<!-- Cell picker: pick which cell of a partially-used ULINE sheet gets -->
|
||||
<!-- this one label. 2x3 grid mirrors the physical cell positions. -->
|
||||
<div v-if="outputMode === 'uline-sheet'" class="cell-picker">
|
||||
<span class="cell-picker-label">Sheet cell</span>
|
||||
<div class="cell-grid">
|
||||
<button
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
type="button"
|
||||
class="cell-btn"
|
||||
:class="{ active: cellNum === sheetCell }"
|
||||
@click="sheetCell = cellNum"
|
||||
>{{ cellNum }}</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="print-btn" @click="print">Print</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable area: standalone single label (unchanged default). -->
|
||||
<div v-if="asset && outputMode === 'single'" class="label-sheet">
|
||||
<div class="asset-label" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- One label placed at the chosen cell of a ULINE 6-up sheet, other five -->
|
||||
<!-- blank, so it lands on the right spot of a partially-used sheet. Cell -->
|
||||
<!-- layout/CSS replicated from AssetLabelBatch (left untouched) to keep -->
|
||||
<!-- batch's verified rendering isolated. Only one branch renders at a time -->
|
||||
<!-- so the shared barcodeEl ref resolves to whichever label is live. -->
|
||||
<div v-else-if="asset" class="sheets-container">
|
||||
<div class="print-sheet">
|
||||
<div class="sheet-label no-print">ULINE 6-up sheet - cell {{ sheetCell }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label"
|
||||
:class="[`pos-${cellNum}`, cellNum === sheetCell ? 'active' : 'inactive']"
|
||||
>
|
||||
<div v-if="cellNum === sheetCell" class="asset-label in-cell" :class="style">
|
||||
<template v-if="style === 'card'">
|
||||
<div class="label-title">{{ cardTitle }}</div>
|
||||
<img v-if="imageUrl" class="label-image" :src="imageUrl" :alt="cardTitle" />
|
||||
<div class="label-fields">
|
||||
<div v-for="field in identityFields" :key="field.label" class="label-field">
|
||||
<span class="field-label">{{ field.label }}</span>
|
||||
<span class="field-value">{{ field.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<div class="code-area">
|
||||
<template v-if="codeText">
|
||||
<img v-if="codetype === 'qr' && qrImage" class="code-qr" :src="qrImage" alt="QR" />
|
||||
<svg v-show="codetype === 'barcode'" ref="barcodeEl" class="code-barcode"></svg>
|
||||
<div class="code-caption">{{ caption }}</div>
|
||||
</template>
|
||||
<div v-else class="code-missing">No {{ encodeLabel }} recorded for this asset.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import {
|
||||
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
|
||||
effectiveEncodes as effEncodes, resolveCodeText as resolveText, captionFor,
|
||||
} from './assetLabel'
|
||||
|
||||
const route = useRoute()
|
||||
const assettype = route.params.assettype
|
||||
const config = TYPE_CONFIG[assettype] || null
|
||||
|
||||
const loading = ref(true)
|
||||
const asset = ref(null)
|
||||
const barcodeEl = ref(null)
|
||||
const qrImage = ref('')
|
||||
// The resolved string the code encodes; empty when the chosen field has no
|
||||
// value (e.g. serial number on an asset with none) so the UI can explain it.
|
||||
const codeText = ref('')
|
||||
|
||||
const style = ref('card')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
// Output target: 'single' standalone label, or 'uline-sheet' placing this one
|
||||
// label at sheetCell (1-6) of a ULINE 6-up sheet.
|
||||
const outputMode = ref('single')
|
||||
const sheetCell = ref(1)
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
const extension = computed(() => (asset.value && config) ? asset.value[config.extkey] : null)
|
||||
|
||||
const cardTitle = computed(() => {
|
||||
if (!asset.value) return ''
|
||||
const ext = extension.value || {}
|
||||
return ext.modelname
|
||||
|| ext.measuringtooltypename
|
||||
|| asset.value.assettypename
|
||||
|| (config ? config.label : 'Asset')
|
||||
})
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
const ext = extension.value
|
||||
return (ext && ext.imageurl) ? ext.imageurl : null
|
||||
})
|
||||
|
||||
// Small identity table shown on the card.
|
||||
const identityFields = computed(() => {
|
||||
if (!asset.value) return []
|
||||
const rows = []
|
||||
if (asset.value.assetnumber) rows.push({ label: 'Asset #', value: asset.value.assetnumber })
|
||||
if (asset.value.serialnumber) rows.push({ label: 'Serial', value: asset.value.serialnumber })
|
||||
if (asset.value.name) rows.push({ label: 'Name', value: asset.value.name })
|
||||
if (asset.value.locationname) rows.push({ label: 'Location', value: asset.value.locationname })
|
||||
return rows
|
||||
})
|
||||
|
||||
// Human caption printed under the code.
|
||||
const caption = computed(() => captionFor(asset.value, encodes.value))
|
||||
|
||||
// Human phrase for the current encode mode, used in the "nothing to encode"
|
||||
// message.
|
||||
const ENCODE_LABELS = {
|
||||
assetnumber: 'asset number', serialnumber: 'serial number',
|
||||
location: 'inspection location', assetpage: 'page link', custom: 'target',
|
||||
}
|
||||
const encodeLabel = computed(() =>
|
||||
ENCODE_LABELS[effEncodes(encodes.value, asset.value)] || 'value')
|
||||
|
||||
async function renderCode() {
|
||||
const text = await resolveText(assettype, asset.value, encodes.value)
|
||||
codeText.value = text
|
||||
if (!text) { qrImage.value = ''; return }
|
||||
if (codetype.value === 'qr') {
|
||||
qrImage.value = await renderQrDataUrl(text)
|
||||
} else {
|
||||
await nextTick()
|
||||
if (!barcodeEl.value) return
|
||||
try {
|
||||
JsBarcode(barcodeEl.value, text, {
|
||||
format: 'CODE128', displayValue: false, width: 2, height: 70, margin: 0,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Barcode error:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config) { loading.value = false; return }
|
||||
style.value = (await getSetting('label_default_style', 'card')) === 'plain' ? 'plain' : 'card'
|
||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||
encodes.value = await resolveDefaultEncodes(assettype)
|
||||
try {
|
||||
const response = await config.api.get(route.params.id)
|
||||
asset.value = response.data.data
|
||||
} catch (err) {
|
||||
console.error('Error loading asset:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
renderCode()
|
||||
}
|
||||
})
|
||||
|
||||
// outputMode/sheetCell re-mount the live label element, so redraw the barcode
|
||||
// into the newly rendered svg (the qr path is just an <img> src, unaffected).
|
||||
watch([style, codetype, encodes, outputMode, sheetCell], renderCode)
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Standalone label is a small stock; the ULINE sheet is a letter page. Named */
|
||||
/* pages let each render its own size (only one branch renders at a time). */
|
||||
@page { size: 2.13in 3.38in; margin: 0; }
|
||||
@page uline { size: letter; margin: 0; }
|
||||
.print-sheet { page: uline; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
max-width: 40rem;
|
||||
}
|
||||
.controls h3 { margin-top: 0; }
|
||||
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 12px; }
|
||||
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
.control-note { color: var(--warning); font-size: 0.8125rem; margin: 0 0 12px; }
|
||||
|
||||
/* Cell picker: 2x3 grid mirroring the physical ULINE cell positions. */
|
||||
.cell-picker { margin-bottom: 12px; }
|
||||
.cell-picker-label { display: block; font-size: 0.875rem; margin-bottom: 4px; }
|
||||
.cell-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 3rem);
|
||||
grid-auto-rows: 3rem;
|
||||
gap: 6px;
|
||||
}
|
||||
.cell-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.cell-btn:hover { border-color: var(--primary); }
|
||||
.cell-btn.active {
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.print-btn:hover { background: var(--primary-dark); }
|
||||
|
||||
.loading-msg, .error-msg { padding: 1rem 0; color: var(--text-light); }
|
||||
|
||||
.label-sheet { display: flex; justify-content: center; padding: 20px 0; }
|
||||
|
||||
.asset-label {
|
||||
width: 2.13in;
|
||||
min-height: 3.38in;
|
||||
background: white;
|
||||
color: #000;
|
||||
border: 1px solid #ccc;
|
||||
box-sizing: border-box;
|
||||
padding: 0.15in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
.asset-label.plain { justify-content: center; min-height: 2in; }
|
||||
|
||||
.label-title {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-image {
|
||||
max-width: 1.6in;
|
||||
max-height: 1.2in;
|
||||
object-fit: contain;
|
||||
margin-bottom: 0.08in;
|
||||
}
|
||||
.label-fields { width: 100%; margin-bottom: 0.08in; }
|
||||
.label-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
font-size: 8pt;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.field-label { color: #555; }
|
||||
.field-value { font-weight: bold; text-align: right; word-break: break-all; }
|
||||
|
||||
.code-area {
|
||||
margin-top: auto;
|
||||
text-align: center;
|
||||
width: 100%;
|
||||
}
|
||||
.code-qr { width: 1.5in; height: 1.5in; }
|
||||
.code-barcode { width: 1.8in; height: 0.9in; }
|
||||
.code-caption {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
margin-top: 0.02in;
|
||||
}
|
||||
.code-missing {
|
||||
font-size: 9pt;
|
||||
color: #999;
|
||||
padding: 0.3in 0.1in;
|
||||
}
|
||||
|
||||
/* ULINE 6-up sheet (replicated from AssetLabelBatch; batch left untouched). */
|
||||
.sheets-container { display: flex; justify-content: center; padding: 20px 0; }
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.label.inactive { border: 1px dashed #ccc; }
|
||||
.label.active { border: 2px solid var(--primary); }
|
||||
|
||||
/* ULINE S-5627 / 6-up 3in cell positions on a letter sheet (match batch). */
|
||||
.pos-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
/* The label shrunk to fit a 3in cell (standalone stock is taller than 3in). */
|
||||
.label .asset-label.in-cell {
|
||||
width: auto;
|
||||
min-height: 0;
|
||||
border: none;
|
||||
padding: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.label .asset-label.in-cell .label-title { font-size: 10pt; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-image { max-width: 1.2in; max-height: 0.7in; margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .label-fields { margin-bottom: 0.04in; }
|
||||
.label .asset-label.in-cell .code-area { margin-top: 0.05in; }
|
||||
.label .asset-label.in-cell .code-qr { width: 1.1in; height: 1.1in; }
|
||||
.label .asset-label.in-cell .code-barcode { width: 2.2in; height: 0.7in; }
|
||||
.label .asset-label.in-cell .code-caption { font-size: 10pt; }
|
||||
|
||||
@media print {
|
||||
.no-print { display: none !important; }
|
||||
.label-sheet { padding: 0; }
|
||||
.asset-label { border: none; }
|
||||
.sheets-container { padding: 0; }
|
||||
.print-sheet { border: none; }
|
||||
.label { border: none !important; }
|
||||
.label.inactive { visibility: hidden; }
|
||||
body, .asset-label, .code-qr, .code-barcode {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
501
frontend/src/views/print/AssetLabelBatch.vue
Normal file
501
frontend/src/views/print/AssetLabelBatch.vue
Normal file
@@ -0,0 +1,501 @@
|
||||
<template>
|
||||
<div>
|
||||
<!-- Controls (never printed) -->
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print {{ config ? config.label : 'Asset' }} Labels</h3>
|
||||
<p v-if="!config" class="error-msg">Unknown asset type.</p>
|
||||
<template v-else>
|
||||
<div class="control-row">
|
||||
<label>Format
|
||||
<select v-model="format">
|
||||
<option value="uline6">ULINE 6-up (3 in x 3 in, 6 per page)</option>
|
||||
<option value="mini72">Mini 72-up (12 per cell, 72 per page)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Code type
|
||||
<select v-model="codetype">
|
||||
<option value="qr">QR code</option>
|
||||
<option value="barcode">Barcode (CODE128)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Encodes
|
||||
<select v-model="encodes">
|
||||
<option value="assetpage">Asset page (link)</option>
|
||||
<option value="assetnumber">Asset number</option>
|
||||
<option value="serialnumber">Serial number</option>
|
||||
<option v-if="hasLocation" value="location">Inspection location code</option>
|
||||
<option value="custom">Custom target (settings template)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Start at cell
|
||||
<select v-model="startCell">
|
||||
<option value="1">1 - Top Left</option>
|
||||
<option value="2">2 - Top Right</option>
|
||||
<option value="3">3 - Middle Left</option>
|
||||
<option value="4">4 - Middle Right</option>
|
||||
<option value="5">5 - Bottom Left</option>
|
||||
<option value="6">6 - Bottom Right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loadingAssets" class="loading-msg">Loading assets...</div>
|
||||
<div v-else-if="assets.length === 0" class="loading-msg">No assets found.</div>
|
||||
<div v-else class="asset-grid">
|
||||
<div
|
||||
v-for="asset in assets"
|
||||
:key="asset.assetid"
|
||||
class="asset-item"
|
||||
:class="{ selected: isSelected(asset) }"
|
||||
@click="toggle(asset)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(asset)" @click.stop />
|
||||
<label>
|
||||
<strong>{{ asset.assetnumber || '-' }}</strong>
|
||||
<div class="asset-sub">{{ asset.name || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selected.length }}</span>
|
||||
(<span class="pages">{{ pageCount }}</span> page{{ pageCount === 1 ? '' : 's' }})
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selected.length === 0" @click="print">Print Labels</button>
|
||||
<button class="secondary-btn" @click="selectAll">Select All</button>
|
||||
<button class="secondary-btn" @click="clearSelection">Clear</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printable sheets -->
|
||||
<div class="sheets-container">
|
||||
<!-- ULINE 6-up: one asset per 3in cell -->
|
||||
<template v-if="format === 'uline6'">
|
||||
<div v-for="(page, pageIdx) in uline6Pages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label"
|
||||
:class="[`pos-${cellNum}`, page[cellNum - 1] ? 'active' : 'inactive']"
|
||||
>
|
||||
<template v-if="page[cellNum - 1]">
|
||||
<div class="model-name">{{ titleFor(page[cellNum - 1]) }}</div>
|
||||
<div class="qr-container">
|
||||
<img
|
||||
v-if="codetype === 'qr'"
|
||||
class="qr-img"
|
||||
:src="qrMap[page[cellNum - 1].assetid] || ''"
|
||||
/>
|
||||
<svg
|
||||
v-else
|
||||
:ref="el => setBarcodeRef(el, page[cellNum - 1].assetid, 'u')"
|
||||
class="barcode-svg"
|
||||
></svg>
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ captionMap[page[cellNum - 1].assetid] }}</div>
|
||||
<div v-if="page[cellNum - 1].name" class="info-row">{{ page[cellNum - 1].name }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Mini 72-up: 6 cells x 12 mini labels -->
|
||||
<template v-else>
|
||||
<div v-for="(page, pageIdx) in mini72Pages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label-cell"
|
||||
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
|
||||
>
|
||||
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
|
||||
<div
|
||||
v-for="(item, miniIdx) in page[cellNum - 1].items"
|
||||
:key="miniIdx"
|
||||
class="mini-label"
|
||||
:class="item ? 'filled' : 'empty'"
|
||||
>
|
||||
<template v-if="item">
|
||||
<img v-if="codetype === 'qr'" class="mini-qr" :src="qrMap[item.assetid] || ''" />
|
||||
<svg v-else :ref="el => setBarcodeRef(el, item.assetid, 'm')" class="mini-barcode"></svg>
|
||||
<div class="serial-text">{{ captionMap[item.assetid] }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-cell-text">Empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import {
|
||||
TYPE_CONFIG, hasLocationType, resolveDefaultEncodes,
|
||||
resolveCodeText, captionFor,
|
||||
} from './assetLabel'
|
||||
|
||||
const MINI_PER_CELL = 12
|
||||
const CELLS_PER_PAGE = 6
|
||||
|
||||
const route = useRoute()
|
||||
const assettype = route.params.assettype
|
||||
const config = TYPE_CONFIG[assettype] || null
|
||||
|
||||
const assets = ref([])
|
||||
const selected = ref([])
|
||||
const loadingAssets = ref(true)
|
||||
const format = ref('uline6')
|
||||
const codetype = ref('qr')
|
||||
const encodes = ref('assetpage')
|
||||
const startCell = ref('1')
|
||||
|
||||
// assetid -> encoded code text / caption / QR data URL. Barcodes render to refs.
|
||||
const codeMap = ref({})
|
||||
const captionMap = ref({})
|
||||
const qrMap = ref({})
|
||||
const barcodeRefs = ref({})
|
||||
|
||||
const hasLocation = computed(() => hasLocationType(assettype))
|
||||
|
||||
function isSelected(asset) {
|
||||
return selected.value.some(a => a.assetid === asset.assetid)
|
||||
}
|
||||
function toggle(asset) {
|
||||
const idx = selected.value.findIndex(a => a.assetid === asset.assetid)
|
||||
if (idx > -1) selected.value.splice(idx, 1)
|
||||
else selected.value.push(asset)
|
||||
}
|
||||
function selectAll() { selected.value = [...assets.value] }
|
||||
function clearSelection() { selected.value = [] }
|
||||
|
||||
function titleFor(asset) {
|
||||
return asset.name || (config ? config.label : 'Asset')
|
||||
}
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (selected.value.length === 0) return 0
|
||||
const skipped = parseInt(startCell.value) - 1
|
||||
if (format.value === 'uline6') {
|
||||
return Math.ceil((selected.value.length + skipped) / CELLS_PER_PAGE)
|
||||
}
|
||||
const cells = Math.ceil(selected.value.length / MINI_PER_CELL)
|
||||
return Math.ceil((cells + skipped) / CELLS_PER_PAGE)
|
||||
})
|
||||
|
||||
// ULINE 6-up: each page is 6 cells, each holding one asset (or null).
|
||||
const uline6Pages = computed(() => {
|
||||
const pages = []
|
||||
if (selected.value.length === 0) return pages
|
||||
const skip = parseInt(startCell.value) - 1
|
||||
let idx = 0
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
|
||||
const blank = page === 0 && cell < skip
|
||||
cells.push((!blank && idx < selected.value.length) ? selected.value[idx++] : null)
|
||||
}
|
||||
pages.push(cells)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
// Mini 72-up: each page is 6 cells, each holding up to 12 mini labels.
|
||||
const mini72Pages = computed(() => {
|
||||
const pages = []
|
||||
if (selected.value.length === 0) return pages
|
||||
const skip = parseInt(startCell.value) - 1
|
||||
let idx = 0
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cell = 0; cell < CELLS_PER_PAGE; cell++) {
|
||||
const blank = (page === 0 && cell < skip) || idx >= selected.value.length
|
||||
const items = []
|
||||
if (!blank) {
|
||||
for (let mini = 0; mini < MINI_PER_CELL; mini++) {
|
||||
items.push(idx < selected.value.length ? selected.value[idx++] : null)
|
||||
}
|
||||
}
|
||||
cells.push({ hasContent: !blank, items })
|
||||
}
|
||||
pages.push(cells)
|
||||
}
|
||||
return pages
|
||||
})
|
||||
|
||||
function setBarcodeRef(el, assetid, prefix) {
|
||||
if (el) barcodeRefs.value[`${prefix}-${assetid}`] = el
|
||||
}
|
||||
|
||||
// Resolve the encode text + caption for every loaded asset once, then per
|
||||
// encode-mode change. Kept over the whole list (cheap) so selection is instant.
|
||||
async function buildCodeMap() {
|
||||
const codes = {}
|
||||
const captions = {}
|
||||
for (const asset of assets.value) {
|
||||
codes[asset.assetid] = await resolveCodeText(assettype, asset, encodes.value)
|
||||
captions[asset.assetid] = captionFor(asset, encodes.value)
|
||||
}
|
||||
codeMap.value = codes
|
||||
captionMap.value = captions
|
||||
}
|
||||
|
||||
async function buildQrMap() {
|
||||
if (codetype.value !== 'qr') return
|
||||
const images = {}
|
||||
for (const asset of selected.value) {
|
||||
const text = codeMap.value[asset.assetid]
|
||||
if (text) images[asset.assetid] = await renderQrDataUrl(text)
|
||||
}
|
||||
qrMap.value = images
|
||||
}
|
||||
|
||||
function renderBarcodes() {
|
||||
if (codetype.value !== 'barcode') return
|
||||
const prefix = format.value === 'uline6' ? 'u' : 'm'
|
||||
const height = format.value === 'uline6' ? 60 : 22
|
||||
const width = format.value === 'uline6' ? 2 : 1
|
||||
for (const asset of selected.value) {
|
||||
const el = barcodeRefs.value[`${prefix}-${asset.assetid}`]
|
||||
const text = codeMap.value[asset.assetid]
|
||||
if (!el || !text) continue
|
||||
try {
|
||||
JsBarcode(el, text, {
|
||||
format: 'CODE128', displayValue: false, width, height, margin: 0,
|
||||
background: 'transparent',
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Barcode error:', asset.assetnumber, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshCodes() {
|
||||
await buildCodeMap()
|
||||
await refreshRender()
|
||||
}
|
||||
|
||||
async function refreshRender() {
|
||||
await buildQrMap()
|
||||
await nextTick()
|
||||
renderBarcodes()
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (!config) { loadingAssets.value = false; return }
|
||||
codetype.value = (await getSetting('label_default_codetype', 'qr')) === 'barcode' ? 'barcode' : 'qr'
|
||||
encodes.value = await resolveDefaultEncodes(assettype)
|
||||
try {
|
||||
const response = await config.api.list({ perpage: 500 })
|
||||
assets.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading assets:', err)
|
||||
} finally {
|
||||
loadingAssets.value = false
|
||||
await refreshCodes()
|
||||
}
|
||||
})
|
||||
|
||||
watch(encodes, refreshCodes)
|
||||
watch([selected, format, codetype, startCell], refreshRender, { deep: true })
|
||||
|
||||
function print() { window.print() }
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { padding: 20px; }
|
||||
.controls {
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
}
|
||||
.controls h3 { margin-top: 0; }
|
||||
.control-row { display: flex; flex-wrap: wrap; gap: 16px; margin-bottom: 16px; }
|
||||
.control-row label { display: flex; flex-direction: column; font-size: 0.875rem; gap: 4px; }
|
||||
.control-row select { padding: 6px; font-size: 0.875rem; }
|
||||
|
||||
.asset-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
.asset-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.asset-item:hover { border-color: var(--primary); }
|
||||
.asset-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.asset-item input { margin-right: 10px; }
|
||||
.asset-item label { cursor: pointer; flex: 1; }
|
||||
.asset-item .asset-sub { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 12px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
.secondary-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.loading-msg, .error-msg { text-align: center; padding: 1.5rem; color: var(--text-light); }
|
||||
|
||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
page-break-after: always;
|
||||
}
|
||||
.print-sheet:last-child { page-break-after: auto; }
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
|
||||
/* ULINE 6-up cells */
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.label.inactive { border: 1px dashed #ccc; }
|
||||
.label.active { border: 2px solid var(--primary); }
|
||||
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
|
||||
.qr-container { text-align: center; }
|
||||
.qr-img { width: 144px; height: 144px; display: block; }
|
||||
.barcode-svg { width: 2.4in; height: 0.8in; }
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; color: #000; }
|
||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; text-align: center; }
|
||||
|
||||
/* Mini 72-up cells */
|
||||
.label-cell {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.label-cell.has-content { border: 1px solid var(--primary); }
|
||||
.label-cell.empty { background: #fafafa; }
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1in);
|
||||
grid-template-rows: repeat(4, 0.75in);
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
}
|
||||
.mini-label {
|
||||
width: 1in;
|
||||
height: 0.75in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0.02in;
|
||||
border: 1px dotted #ddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-label.filled { border: 1px solid #999; }
|
||||
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
|
||||
.mini-qr { width: 0.48in; height: 0.48in; }
|
||||
.mini-barcode { max-width: 0.9in; height: 24px; }
|
||||
.serial-text {
|
||||
font-size: 6pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
.empty-cell-text {
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* ULINE S-5627 / 6-up 3in label positions on a letter sheet */
|
||||
.pos-1, .cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2, .cell-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3, .cell-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4, .cell-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5, .cell-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6, .cell-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
@media print {
|
||||
body, .print-sheet, .label, .label-cell, .mini-label, .qr-img, .mini-qr, .barcode-svg, .mini-barcode {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.sheet-label { display: none; }
|
||||
.label { border: none !important; }
|
||||
.label.inactive { visibility: hidden; }
|
||||
.label-cell { border: none !important; }
|
||||
.label-cell.empty { visibility: hidden; }
|
||||
.mini-label { border: 1px dotted #ccc !important; }
|
||||
.mini-label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
169
frontend/src/views/print/MachineBadge.vue
Normal file
169
frontend/src/views/print/MachineBadge.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<template>
|
||||
<div>
|
||||
<button class="print-btn" @click="print" v-if="!loading">Print Badge</button>
|
||||
<div v-if="loading" class="loading-msg">Loading...</div>
|
||||
<div v-else-if="machine" class="badge-container">
|
||||
<div class="model-name">{{ modelName }}</div>
|
||||
<img
|
||||
v-if="isInspection"
|
||||
class="machine-image"
|
||||
:src="geLogo"
|
||||
alt="GE Logo"
|
||||
/>
|
||||
<img
|
||||
v-else-if="imageUrl"
|
||||
class="machine-image"
|
||||
:src="imageUrl"
|
||||
:alt="modelName"
|
||||
/>
|
||||
<div class="barcode-container">
|
||||
<svg ref="barcodeEl"></svg>
|
||||
<div class="machine-number">*{{ machine.assetnumber }}*</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="error-msg">Machine not found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { machinesApi } from '../../api'
|
||||
import { getBadgeLogo } from '@/utils/siteSettings'
|
||||
import { withBase } from '@/utils/basePath'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const machine = ref(null)
|
||||
const barcodeEl = ref(null)
|
||||
|
||||
const geLogo = ref(withBase('/ge-aerospace-logo.svg'))
|
||||
|
||||
const isInspection = computed(() => {
|
||||
if (!machine.value) return false
|
||||
return machine.value.assetnumber?.startsWith('06')
|
||||
})
|
||||
|
||||
const modelName = computed(() => {
|
||||
if (!machine.value) return ''
|
||||
if (isInspection.value) return 'Inspection'
|
||||
return machine.value.machine?.modelname || ''
|
||||
})
|
||||
|
||||
const imageUrl = computed(() => {
|
||||
if (!machine.value) return null
|
||||
const img = machine.value.machine?.imageurl
|
||||
if (img) return img.startsWith('/') ? img : `/images/models/machines/${img}`
|
||||
return null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
getBadgeLogo().then(logo => { geLogo.value = logo })
|
||||
try {
|
||||
const response = await machinesApi.get(route.params.id)
|
||||
machine.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading machine:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
generateBarcode()
|
||||
}
|
||||
})
|
||||
|
||||
function generateBarcode() {
|
||||
if (!barcodeEl.value || !machine.value) return
|
||||
try {
|
||||
JsBarcode(barcodeEl.value, machine.value.assetnumber, {
|
||||
format: 'CODE39',
|
||||
displayValue: false,
|
||||
width: 2,
|
||||
height: 70,
|
||||
margin: 0
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Barcode generation error:', e)
|
||||
}
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: 2.13in 3.38in; margin: 0; }
|
||||
|
||||
body { font-family: Arial, sans-serif; }
|
||||
|
||||
.badge-container {
|
||||
width: 2.13in;
|
||||
height: 3.38in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
border: 1px solid #ccc;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 0.15in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.model-name {
|
||||
font-size: 12pt;
|
||||
font-weight: bold;
|
||||
text-align: center;
|
||||
margin-bottom: 0.1in;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.machine-image {
|
||||
max-width: 1.8in;
|
||||
max-height: 1.5in;
|
||||
object-fit: contain;
|
||||
margin-bottom: 0.1in;
|
||||
}
|
||||
|
||||
.barcode-container {
|
||||
text-align: center;
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.barcode-container svg {
|
||||
width: 1.8in;
|
||||
height: 0.9in;
|
||||
}
|
||||
|
||||
.machine-number {
|
||||
font-size: 14pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
margin-top: -0.1in;
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.print-btn {
|
||||
display: block;
|
||||
margin: 20px auto;
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.loading-msg, .error-msg {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 1.125rem;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.print-btn { display: none; }
|
||||
.badge-container { border: none; margin: 0; }
|
||||
}
|
||||
</style>
|
||||
307
frontend/src/views/print/PrinterQRBatch.vue
Normal file
307
frontend/src/views/print/PrinterQRBatch.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print Printer QR Codes</h3>
|
||||
<p>Select printers to print (6 per page):</p>
|
||||
|
||||
<div v-if="loadingPrinters" class="loading-msg">Loading printers...</div>
|
||||
<div v-else class="printer-grid">
|
||||
<div
|
||||
v-for="printer in printers"
|
||||
:key="printer.assetid"
|
||||
class="printer-item"
|
||||
:class="{ selected: isSelected(printer) }"
|
||||
@click="togglePrinter(printer)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(printer)" @click.stop />
|
||||
<label>
|
||||
<strong>{{ displayName(printer) }}</strong>
|
||||
<div class="model">{{ printer.printer?.modelname || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selectedPrinters.length }}</span> printers
|
||||
(<span class="pages">{{ pageCount }}</span> pages)
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selectedPrinters.length === 0" @click="print">Print QR Codes</button>
|
||||
<button class="clear-btn" @click="clearSelection">Clear All</button>
|
||||
<button class="select-all-btn" @click="selectAll">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheets-container">
|
||||
<div v-for="(page, pageIdx) in pages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="pos in 6"
|
||||
:key="pos"
|
||||
class="label"
|
||||
:class="[`pos-${pos}`, page[pos - 1] ? 'filled' : 'empty']"
|
||||
>
|
||||
<template v-if="page[pos - 1]">
|
||||
<div class="model-name">{{ page[pos - 1].printer?.modelname || '' }}</div>
|
||||
<div class="qr-container">
|
||||
<img v-if="qrImages[`${pageIdx}-${pos}`]" :src="qrImages[`${pageIdx}-${pos}`]" class="qr-img" alt="QR" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ page[pos - 1].assetnumber }}</div>
|
||||
<div class="info-inner">
|
||||
<div v-if="page[pos - 1].printer?.windowsname" class="info-row">{{ page[pos - 1].printer.windowsname }}</div>
|
||||
<div v-if="getIp(page[pos - 1])" class="info-row">{{ getIp(page[pos - 1]) }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<span v-else class="empty-label">Empty</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { printersApi } from '../../api'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
|
||||
const printers = ref([])
|
||||
const selectedPrinters = ref([])
|
||||
const loadingPrinters = ref(true)
|
||||
// QR codes rendered to data-URL images (not live <canvas>): canvases are
|
||||
// unreliable in print output, images print every time.
|
||||
const qrImages = ref({})
|
||||
|
||||
const pageCount = computed(() => Math.ceil(selectedPrinters.value.length / 6) || 0)
|
||||
|
||||
const pages = computed(() => {
|
||||
const result = []
|
||||
for (let i = 0; i < selectedPrinters.value.length; i += 6) {
|
||||
const page = []
|
||||
for (let j = 0; j < 6; j++) {
|
||||
page.push(selectedPrinters.value[i + j] || null)
|
||||
}
|
||||
result.push(page)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printersApi.list({ perpage: 500 })
|
||||
printers.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading printers:', error)
|
||||
} finally {
|
||||
loadingPrinters.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch(selectedPrinters, async () => {
|
||||
await nextTick()
|
||||
generateQRCodes()
|
||||
}, { deep: true })
|
||||
|
||||
async function generateQRCodes() {
|
||||
const next = {}
|
||||
for (let pageIdx = 0; pageIdx < pages.value.length; pageIdx++) {
|
||||
const page = pages.value[pageIdx]
|
||||
for (let idx = 0; idx < page.length; idx++) {
|
||||
const printer = page[idx]
|
||||
if (!printer) continue
|
||||
const pos = idx + 1
|
||||
const detailId = printer.printer?.printerid || printer.assetid
|
||||
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
|
||||
printerid: printer.printer?.printerid || '',
|
||||
assetid: printer.assetid || '',
|
||||
assetnumber: printer.assetnumber || '',
|
||||
serialnumber: printer.serialnumber || '',
|
||||
ip: getIp(printer) || '',
|
||||
hostname: printer.printer?.windowsname || printer.name || '',
|
||||
})
|
||||
next[`${pageIdx}-${pos}`] = await renderQrDataUrl(qrUrl)
|
||||
}
|
||||
}
|
||||
qrImages.value = next
|
||||
}
|
||||
|
||||
function displayName(printer) {
|
||||
return printer.assetnumber || printer.name || `Printer-${printer.assetid}`
|
||||
}
|
||||
|
||||
function getIp(printer) {
|
||||
// Check direct ipaddress field first (from list API)
|
||||
if (printer.ipaddress && printer.ipaddress !== 'USB') return printer.ipaddress
|
||||
// Fall back to communications array (from detail API)
|
||||
if (!printer.communications?.length) return null
|
||||
const primary = printer.communications.find(c => c.isprimary) || printer.communications[0]
|
||||
return primary?.ipaddress || primary?.address || null
|
||||
}
|
||||
|
||||
function isSelected(printer) {
|
||||
return selectedPrinters.value.some(p => p.assetid === printer.assetid)
|
||||
}
|
||||
|
||||
function togglePrinter(printer) {
|
||||
const idx = selectedPrinters.value.findIndex(p => p.assetid === printer.assetid)
|
||||
if (idx > -1) {
|
||||
selectedPrinters.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedPrinters.value.push(printer)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedPrinters.value = []
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedPrinters.value = [...printers.value]
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { margin-bottom: 20px; padding: 20px; }
|
||||
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
|
||||
.controls h3 { margin-top: 0; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
|
||||
.clear-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.printer-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.printer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.printer-item:hover { border-color: var(--primary); }
|
||||
.printer-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.printer-item input { margin-right: 10px; }
|
||||
.printer-item label { cursor: pointer; flex: 1; }
|
||||
.printer-item .model { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
|
||||
|
||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
page-break-after: always;
|
||||
}
|
||||
.print-sheet:last-child { page-break-after: auto; }
|
||||
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1in;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
}
|
||||
.label.filled { border: 2px solid var(--primary); }
|
||||
.label.empty { background: #fafafa; }
|
||||
|
||||
.qr-img { width: 144px; height: 144px; display: block; }
|
||||
|
||||
.pos-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
|
||||
.qr-container { text-align: center; }
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.info-inner { text-align: left; }
|
||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
||||
.empty-label { color: #999; font-size: 14px; }
|
||||
|
||||
@media print {
|
||||
/* Force the browser to print rendered images/colors even when the user's
|
||||
"Background graphics" option is off. Without this the QR images and
|
||||
borders can drop out of the printout. */
|
||||
body, .print-sheet, .label, .qr-img, .qr-container {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.sheet-label { display: none; }
|
||||
.label { border: none !important; }
|
||||
.label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
187
frontend/src/views/print/PrinterQRSingle.vue
Normal file
187
frontend/src/views/print/PrinterQRSingle.vue
Normal file
@@ -0,0 +1,187 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<button class="print-btn" :disabled="!printer" @click="print">Print QR Code</button>
|
||||
<label>Position:
|
||||
<select class="position-select" v-model="position">
|
||||
<option value="1">1 - Top Left</option>
|
||||
<option value="2">2 - Top Right</option>
|
||||
<option value="3">3 - Middle Left</option>
|
||||
<option value="4">4 - Middle Right</option>
|
||||
<option value="5">5 - Bottom Left</option>
|
||||
<option value="6">6 - Bottom Right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading-msg">Loading...</div>
|
||||
|
||||
<div v-else-if="printer" class="print-sheet">
|
||||
<div
|
||||
v-for="pos in 6"
|
||||
:key="pos"
|
||||
class="label"
|
||||
:class="[`pos-${pos}`, pos === parseInt(position) ? 'active' : 'inactive']"
|
||||
>
|
||||
<template v-if="pos === parseInt(position)">
|
||||
<div class="model-name">{{ printer.printer?.modelname || '' }}</div>
|
||||
<div class="qr-container">
|
||||
<img v-if="qrImage" :src="qrImage" class="qr-img" alt="QR" />
|
||||
</div>
|
||||
<div class="info-section">
|
||||
<div class="csf-name">{{ printer.assetnumber }}</div>
|
||||
<div class="info-inner">
|
||||
<div v-if="printer.printer?.windowsname" class="info-row">{{ printer.printer.windowsname }}</div>
|
||||
<div v-if="ipAddress" class="info-row">{{ ipAddress }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="error-msg">Printer not found</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { printersApi } from '../../api'
|
||||
import { renderQrDataUrl } from './qrLogo'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
|
||||
const route = useRoute()
|
||||
const loading = ref(true)
|
||||
const printer = ref(null)
|
||||
const position = ref('1')
|
||||
// Render QR to a data-URL image, not a live <canvas>: canvases are unreliable
|
||||
// in print output, images print every time.
|
||||
const qrImage = ref('')
|
||||
|
||||
const ipAddress = computed(() => {
|
||||
// Check direct ipaddress field first (from list API)
|
||||
if (printer.value?.ipaddress && printer.value.ipaddress !== 'USB') return printer.value.ipaddress
|
||||
// Fall back to communications array (from detail API)
|
||||
if (!printer.value?.communications?.length) return null
|
||||
const primary = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
|
||||
return primary?.ipaddress || primary?.address || null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printersApi.get(route.params.id)
|
||||
printer.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error loading printer:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
await nextTick()
|
||||
generateQR()
|
||||
}
|
||||
})
|
||||
|
||||
watch(position, async () => {
|
||||
await nextTick()
|
||||
generateQR()
|
||||
})
|
||||
|
||||
async function generateQR() {
|
||||
if (!printer.value) return
|
||||
const p = printer.value
|
||||
const detailId = p.printer?.printerid || p.assetid
|
||||
const qrUrl = await buildQrUrl('qr_target_printer', `/printers/${detailId}`, {
|
||||
printerid: p.printer?.printerid || '',
|
||||
assetid: p.assetid || '',
|
||||
assetnumber: p.assetnumber || '',
|
||||
serialnumber: p.serialnumber || '',
|
||||
ip: ipAddress.value || '',
|
||||
hostname: p.printer?.windowsname || p.name || '',
|
||||
})
|
||||
qrImage.value = await renderQrDataUrl(qrUrl)
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { margin-bottom: 20px; text-align: center; padding: 20px; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
|
||||
.position-select { padding: 8px; font-size: 14px; margin-left: 10px; }
|
||||
|
||||
.loading-msg, .error-msg {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
}
|
||||
|
||||
.label {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.1in;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.label.inactive { border: 1px dashed #ccc; }
|
||||
.label.active { border: 2px solid var(--primary); }
|
||||
|
||||
.qr-img { width: 144px; height: 144px; display: block; }
|
||||
|
||||
.pos-1 { top: 0.875in; left: 1.1875in; }
|
||||
.pos-2 { top: 0.875in; left: 4.3125in; }
|
||||
.pos-3 { top: 4in; left: 1.1875in; }
|
||||
.pos-4 { top: 4in; left: 4.3125in; }
|
||||
.pos-5 { top: 7.125in; left: 1.1875in; }
|
||||
.pos-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
.model-name { font-size: 11pt; font-weight: bold; text-align: center; margin-bottom: 0.1in; color: #000; }
|
||||
.qr-container { text-align: center; }
|
||||
.info-section { margin-top: 0.1in; display: flex; flex-direction: column; align-items: center; }
|
||||
.info-inner { text-align: left; }
|
||||
.info-row { font-size: 9pt; color: #333; margin: 1px 0; white-space: nowrap; }
|
||||
.csf-name { font-size: 12pt; font-weight: bold; font-family: monospace; text-align: center; margin-bottom: 2px; color: #000; }
|
||||
|
||||
@media print {
|
||||
/* Force rendered images/colors to print even when "Background graphics" is
|
||||
off, otherwise the QR image can drop out. */
|
||||
body, .print-sheet, .label, .qr-img, .qr-container {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.label { border: none !important; }
|
||||
.label.inactive { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
425
frontend/src/views/print/USBLabelBatch.vue
Normal file
425
frontend/src/views/print/USBLabelBatch.vue
Normal file
@@ -0,0 +1,425 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="no-print">
|
||||
<div class="controls">
|
||||
<h3>Batch Print USB Barcode Labels</h3>
|
||||
<p>Select USB devices to print (72 labels per page - 6 ULINE labels x 12 mini-labels each, cut after printing):</p>
|
||||
|
||||
<div v-if="loadingDevices" class="loading-msg">Loading USB devices...</div>
|
||||
<div v-else-if="devices.length === 0" class="loading-msg">No USB devices found</div>
|
||||
<div v-else class="usb-grid">
|
||||
<div
|
||||
v-for="device in devices"
|
||||
:key="device.device_id"
|
||||
class="usb-item"
|
||||
:class="{ selected: isSelected(device) }"
|
||||
@click="toggleDevice(device)"
|
||||
>
|
||||
<input type="checkbox" :checked="isSelected(device)" @click.stop />
|
||||
<label>
|
||||
<strong><code>{{ device.device_id || '-' }}</code></strong>
|
||||
<div class="alias">{{ device.device_desc || '' }}</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="selected-count">
|
||||
Selected: <span class="count">{{ selectedDevices.length }}</span> USB devices
|
||||
(<span class="pages">{{ pageCount }}</span> pages)
|
||||
<label style="margin-left: 20px;">Start at cell:
|
||||
<select v-model="startCell" style="padding: 5px; font-size: 14px;">
|
||||
<option value="1">1 - Top Left</option>
|
||||
<option value="2">2 - Top Right</option>
|
||||
<option value="3">3 - Middle Left</option>
|
||||
<option value="4">4 - Middle Right</option>
|
||||
<option value="5">5 - Bottom Left</option>
|
||||
<option value="6">6 - Bottom Right</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button class="print-btn" :disabled="selectedDevices.length === 0" @click="print">Print Labels</button>
|
||||
<button class="clear-btn" @click="clearSelection">Clear All</button>
|
||||
<button class="select-all-btn" @click="selectAll">Select All</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="sheets-container">
|
||||
<div v-for="(page, pageIdx) in sheetPages" :key="pageIdx" class="print-sheet">
|
||||
<div class="sheet-label">Page {{ pageIdx + 1 }} of {{ pageCount }}</div>
|
||||
<div
|
||||
v-for="cellNum in 6"
|
||||
:key="cellNum"
|
||||
class="label-cell"
|
||||
:class="[`cell-${cellNum}`, page[cellNum - 1].hasContent ? 'has-content' : 'empty']"
|
||||
>
|
||||
<div v-if="page[cellNum - 1].hasContent" class="mini-grid">
|
||||
<div
|
||||
v-for="(miniItem, miniIdx) in page[cellNum - 1].items"
|
||||
:key="miniIdx"
|
||||
class="mini-label"
|
||||
:class="miniItem ? 'filled' : 'empty'"
|
||||
>
|
||||
<template v-if="miniItem">
|
||||
<img
|
||||
v-if="labelStyle === 'qr'"
|
||||
class="qr-img"
|
||||
:src="qrImages[`${pageIdx}-${cellNum}-${miniIdx}`] || ''"
|
||||
/>
|
||||
<div v-else class="barcode-container">
|
||||
<svg :ref="el => setBarcodeRef(el, pageIdx, cellNum, miniIdx)"></svg>
|
||||
</div>
|
||||
<div class="serial-text">{{ miniItem.device_id }}</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="empty-cell-text">Empty</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { usbApi } from '../../api'
|
||||
import JsBarcode from 'jsbarcode'
|
||||
import QRCode from 'qrcode'
|
||||
import { getSetting } from '@/utils/siteSettings'
|
||||
import { buildQrUrl } from '@/utils/qrTarget'
|
||||
|
||||
const MINI_LABELS_PER_CELL = 12
|
||||
const CELLS_PER_PAGE = 6
|
||||
|
||||
const devices = ref([])
|
||||
const selectedDevices = ref([])
|
||||
const loadingDevices = ref(true)
|
||||
const startCell = ref('1')
|
||||
const barcodeRefs = ref({})
|
||||
// usb_label_style setting: 'barcode' (CODE128 of the serial) or 'qr' (QR code
|
||||
// linking to the qr_target_usb target, default = the device page).
|
||||
const labelStyle = ref('barcode')
|
||||
// QR data-URL images keyed like barcodeRefs. Images print reliably; live
|
||||
// canvases do not.
|
||||
const qrImages = ref({})
|
||||
|
||||
const pageCount = computed(() => {
|
||||
if (selectedDevices.value.length === 0) return 0
|
||||
const skippedCells = parseInt(startCell.value) - 1
|
||||
const numCells = Math.ceil(selectedDevices.value.length / MINI_LABELS_PER_CELL)
|
||||
const totalCellsNeeded = numCells + skippedCells
|
||||
return Math.ceil(totalCellsNeeded / CELLS_PER_PAGE)
|
||||
})
|
||||
|
||||
const sheetPages = computed(() => {
|
||||
const result = []
|
||||
if (selectedDevices.value.length === 0) return result
|
||||
|
||||
const startCellNum = parseInt(startCell.value)
|
||||
let usbIdx = 0
|
||||
|
||||
for (let page = 0; page < pageCount.value; page++) {
|
||||
const cells = []
|
||||
for (let cellNum = 1; cellNum <= CELLS_PER_PAGE; cellNum++) {
|
||||
const skipThisCell = page === 0 && cellNum < startCellNum
|
||||
const hasContent = !skipThisCell && usbIdx < selectedDevices.value.length
|
||||
|
||||
const items = []
|
||||
if (hasContent) {
|
||||
for (let mini = 0; mini < MINI_LABELS_PER_CELL; mini++) {
|
||||
if (usbIdx < selectedDevices.value.length) {
|
||||
items.push(selectedDevices.value[usbIdx])
|
||||
usbIdx++
|
||||
} else {
|
||||
items.push(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cells.push({ hasContent, items })
|
||||
}
|
||||
result.push(cells)
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
labelStyle.value = (await getSetting('usb_label_style', 'barcode')) === 'qr' ? 'qr' : 'barcode'
|
||||
try {
|
||||
const response = await usbApi.list({ perpage: 500 })
|
||||
devices.value = response.data.data || []
|
||||
} catch (error) {
|
||||
console.error('Error loading USB devices:', error)
|
||||
} finally {
|
||||
loadingDevices.value = false
|
||||
}
|
||||
})
|
||||
|
||||
watch([selectedDevices, startCell], async () => {
|
||||
await nextTick()
|
||||
if (labelStyle.value === 'qr') {
|
||||
await generateQrImages()
|
||||
} else {
|
||||
generateBarcodes()
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
async function generateQrImages() {
|
||||
const next = {}
|
||||
for (let pageIdx = 0; pageIdx < sheetPages.value.length; pageIdx++) {
|
||||
const page = sheetPages.value[pageIdx]
|
||||
for (let cellIdx = 0; cellIdx < page.length; cellIdx++) {
|
||||
const cell = page[cellIdx]
|
||||
if (!cell.hasContent) continue
|
||||
for (let miniIdx = 0; miniIdx < cell.items.length; miniIdx++) {
|
||||
const item = cell.items[miniIdx]
|
||||
if (!item) continue
|
||||
try {
|
||||
const url = await buildQrUrl('qr_target_usb', `/usb/${encodeURIComponent(item.device_id)}`, {
|
||||
id: item.device_id || '',
|
||||
serialnumber: item.device_id || '',
|
||||
alias: item.device_desc || '',
|
||||
})
|
||||
next[`${pageIdx}-${cellIdx + 1}-${miniIdx}`] = await QRCode.toDataURL(url, {
|
||||
margin: 0,
|
||||
width: 150,
|
||||
errorCorrectionLevel: 'M',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('QR error:', item.device_id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
qrImages.value = next
|
||||
}
|
||||
|
||||
function setBarcodeRef(el, pageIdx, cellNum, miniIdx) {
|
||||
if (el) {
|
||||
barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`] = el
|
||||
}
|
||||
}
|
||||
|
||||
function generateBarcodes() {
|
||||
sheetPages.value.forEach((page, pageIdx) => {
|
||||
page.forEach((cell, cellIdx) => {
|
||||
if (!cell.hasContent) return
|
||||
const cellNum = cellIdx + 1
|
||||
cell.items.forEach((item, miniIdx) => {
|
||||
if (!item) return
|
||||
const el = barcodeRefs.value[`${pageIdx}-${cellNum}-${miniIdx}`]
|
||||
if (!el) return
|
||||
try {
|
||||
JsBarcode(el, item.device_id, {
|
||||
format: 'CODE128',
|
||||
width: 1,
|
||||
height: 22,
|
||||
displayValue: false,
|
||||
margin: 0,
|
||||
background: 'transparent'
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Barcode error:', item.device_id, e)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function isSelected(device) {
|
||||
return selectedDevices.value.some(d => d.device_id === device.device_id)
|
||||
}
|
||||
|
||||
function toggleDevice(device) {
|
||||
const idx = selectedDevices.value.findIndex(d => d.device_id === device.device_id)
|
||||
if (idx > -1) {
|
||||
selectedDevices.value.splice(idx, 1)
|
||||
} else {
|
||||
selectedDevices.value.push(device)
|
||||
}
|
||||
}
|
||||
|
||||
function clearSelection() {
|
||||
selectedDevices.value = []
|
||||
}
|
||||
|
||||
function selectAll() {
|
||||
selectedDevices.value = [...devices.value]
|
||||
}
|
||||
|
||||
function print() {
|
||||
window.print()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@page { size: letter; margin: 0; }
|
||||
|
||||
.no-print { margin-bottom: 20px; padding: 20px; }
|
||||
.controls { background: var(--bg-card); color: var(--text); padding: 20px; border-radius: 8px; margin-bottom: 20px; border: 1px solid var(--border); }
|
||||
.controls h3 { margin-top: 0; }
|
||||
|
||||
.print-btn {
|
||||
padding: 10px 30px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
background: var(--primary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.print-btn:hover:not(:disabled) { background: var(--primary-dark); }
|
||||
.print-btn:disabled { background: var(--text-light); cursor: not-allowed; }
|
||||
|
||||
.clear-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--danger);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.select-all-btn {
|
||||
padding: 10px 20px;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
background: var(--success);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
.usb-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||
gap: 10px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--border);
|
||||
padding: 10px;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.usb-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.usb-item:hover { border-color: var(--primary); }
|
||||
.usb-item.selected { border-color: var(--primary); box-shadow: 0 0 0 1px var(--primary); }
|
||||
.usb-item input { margin-right: 10px; }
|
||||
.usb-item label { cursor: pointer; flex: 1; }
|
||||
.usb-item .alias { font-size: 11px; color: var(--text-light); }
|
||||
|
||||
.selected-count { font-weight: bold; margin: 10px 0; color: var(--text); }
|
||||
.selected-count .count { color: var(--primary); }
|
||||
.selected-count .pages { color: var(--success); }
|
||||
|
||||
.loading-msg { text-align: center; padding: 2rem; color: var(--text-light); }
|
||||
|
||||
.sheets-container { display: flex; flex-direction: column; gap: 20px; }
|
||||
|
||||
.print-sheet {
|
||||
width: 8.5in;
|
||||
height: 11in;
|
||||
background: white;
|
||||
margin: 0 auto;
|
||||
position: relative;
|
||||
border: 1px solid #ccc;
|
||||
page-break-after: always;
|
||||
}
|
||||
.print-sheet:last-child { page-break-after: auto; }
|
||||
|
||||
.sheet-label { position: absolute; top: -25px; left: 0; font-size: 12px; color: #666; }
|
||||
|
||||
.label-cell {
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
position: absolute;
|
||||
box-sizing: border-box;
|
||||
border: 1px dashed #ccc;
|
||||
overflow: hidden;
|
||||
}
|
||||
.label-cell.has-content { border: 1px solid var(--primary); }
|
||||
.label-cell.empty { background: #fafafa; }
|
||||
|
||||
.cell-1 { top: 0.875in; left: 1.1875in; }
|
||||
.cell-2 { top: 0.875in; left: 4.3125in; }
|
||||
.cell-3 { top: 4in; left: 1.1875in; }
|
||||
.cell-4 { top: 4in; left: 4.3125in; }
|
||||
.cell-5 { top: 7.125in; left: 1.1875in; }
|
||||
.cell-6 { top: 7.125in; left: 4.3125in; }
|
||||
|
||||
.mini-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1in);
|
||||
grid-template-rows: repeat(4, 0.75in);
|
||||
width: 3in;
|
||||
height: 3in;
|
||||
}
|
||||
|
||||
.mini-label {
|
||||
width: 1in;
|
||||
height: 0.75in;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
padding: 0.02in;
|
||||
border: 1px dotted #ddd;
|
||||
overflow: hidden;
|
||||
}
|
||||
.mini-label.filled { border: 1px solid #999; }
|
||||
.mini-label.empty { background: #f8f8f8; border: 1px dotted #eee; }
|
||||
|
||||
.barcode-container { text-align: center; line-height: 0; }
|
||||
.barcode-container svg { max-width: 0.9in; height: 24px; }
|
||||
|
||||
.qr-img { width: 0.48in; height: 0.48in; }
|
||||
|
||||
.serial-text {
|
||||
font-size: 6pt;
|
||||
font-weight: bold;
|
||||
font-family: monospace;
|
||||
text-align: center;
|
||||
margin-top: 1px;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.empty-cell-text {
|
||||
color: #ccc;
|
||||
font-size: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@media print {
|
||||
/* Force the barcodes/borders to print even when "Background graphics" is
|
||||
off. */
|
||||
body, .print-sheet, .label-cell, .mini-label, .barcode-container {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
body { padding: 0; margin: 0; background: white; }
|
||||
.no-print { display: none !important; }
|
||||
.sheets-container { gap: 0; }
|
||||
.print-sheet { border: none; margin: 0; width: 8.5in; height: 11in; overflow: hidden; }
|
||||
.sheet-label { display: none; }
|
||||
.label-cell { border: none !important; }
|
||||
.label-cell.empty { visibility: hidden; }
|
||||
.mini-label { border: 1px dotted #ccc !important; }
|
||||
.mini-label.empty { visibility: hidden; }
|
||||
}
|
||||
</style>
|
||||
99
frontend/src/views/print/assetLabel.js
Normal file
99
frontend/src/views/print/assetLabel.js
Normal file
@@ -0,0 +1,99 @@
|
||||
// Shared wiring for the asset label pages (single AssetLabel.vue and batch
|
||||
// AssetLabelBatch.vue): per-type api/route config plus the "what does the code
|
||||
// encode" resolution so both surfaces behave identically.
|
||||
import {
|
||||
machinesApi, computersApi, printersApi, networkApi, measuringtoolsApi,
|
||||
} from '../../api'
|
||||
import { fillUrlTemplate } from '@/utils/qrTarget'
|
||||
import { getSetting, getSiteBaseUrl } from '@/utils/siteSettings'
|
||||
|
||||
// Per asset-type: which api loads it, its detail/list routes, the extension key
|
||||
// nested in the merged payload, and its qr_target_<type> settings key.
|
||||
export const TYPE_CONFIG = {
|
||||
machine: {
|
||||
api: machinesApi, extkey: 'machine', targetKey: 'qr_target_machine',
|
||||
label: 'Machine', detailPath: id => `/machines/${id}`, listPath: '/machines',
|
||||
},
|
||||
computer: {
|
||||
api: computersApi, extkey: 'computer', targetKey: 'qr_target_computer',
|
||||
label: 'Computer', detailPath: id => `/pcs/${id}`, listPath: '/pcs',
|
||||
},
|
||||
printer: {
|
||||
api: printersApi, extkey: 'printer', targetKey: 'qr_target_printer',
|
||||
label: 'Printer', detailPath: id => `/printers/${id}`, listPath: '/printers',
|
||||
},
|
||||
network_device: {
|
||||
api: networkApi, extkey: 'network_device', targetKey: 'qr_target_network_device',
|
||||
label: 'Network Device', detailPath: id => `/network/${id}`, listPath: '/network',
|
||||
},
|
||||
measuring_tool: {
|
||||
api: measuringtoolsApi, extkey: 'measuringtool', targetKey: 'qr_target_measuring_tool',
|
||||
label: 'Measuring Tool', detailPath: id => `/measuringtools/${id}`, listPath: '/measuringtools',
|
||||
},
|
||||
}
|
||||
|
||||
// Hardcoded fallback default per type when the site setting is unset. Machines
|
||||
// encode their machine number, measuring tools their inspection location.
|
||||
const DEFAULT_ENCODES = { machine: 'assetnumber', measuring_tool: 'location' }
|
||||
|
||||
export function hasLocationType(assettype) {
|
||||
return assettype === 'measuring_tool'
|
||||
}
|
||||
|
||||
// The default encode mode for a type: the label_default_encodes_<type> site
|
||||
// setting when valid, else the hardcoded fallback.
|
||||
export async function resolveDefaultEncodes(assettype) {
|
||||
const valid = ['assetpage', 'assetnumber', 'serialnumber', 'custom']
|
||||
if (hasLocationType(assettype)) valid.push('location')
|
||||
const seeded = await getSetting(`label_default_encodes_${assettype}`, '')
|
||||
return valid.includes(seeded) ? seeded : (DEFAULT_ENCODES[assettype] || 'assetpage')
|
||||
}
|
||||
|
||||
// 'location' degrades to the asset page when the asset has no location code.
|
||||
export function effectiveEncodes(encodes, asset) {
|
||||
if (encodes === 'location' && !asset?.locationcode) return 'assetpage'
|
||||
return encodes
|
||||
}
|
||||
|
||||
function tokensFor(asset) {
|
||||
return {
|
||||
assetid: asset.assetid || '',
|
||||
assetnumber: asset.assetnumber || '',
|
||||
serialnumber: asset.serialnumber || '',
|
||||
name: asset.name || '',
|
||||
pluginid: asset.pluginid || '',
|
||||
locationcode: asset.locationcode || '',
|
||||
locationname: asset.locationname || '',
|
||||
}
|
||||
}
|
||||
|
||||
// The string a label's code encodes for one asset. Empty when the chosen field
|
||||
// has no value (e.g. serial number on an asset with none).
|
||||
export async function resolveCodeText(assettype, asset, encodes) {
|
||||
const config = TYPE_CONFIG[assettype]
|
||||
if (!config || !asset) return ''
|
||||
const detailId = asset.pluginid
|
||||
const assetPageUrl = `${await getSiteBaseUrl()}${config.detailPath(detailId)}`
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'assetnumber': return asset.assetnumber || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'custom': {
|
||||
const template = (await getSetting(config.targetKey, '')).trim()
|
||||
return template ? fillUrlTemplate(template, tokensFor(asset)) : assetPageUrl
|
||||
}
|
||||
case 'assetpage':
|
||||
default: return assetPageUrl
|
||||
}
|
||||
}
|
||||
|
||||
// Human caption printed under the code.
|
||||
export function captionFor(asset, encodes) {
|
||||
if (!asset) return ''
|
||||
switch (effectiveEncodes(encodes, asset)) {
|
||||
case 'location': return asset.locationcode || ''
|
||||
case 'serialnumber': return asset.serialnumber || ''
|
||||
case 'assetnumber':
|
||||
default: return asset.assetnumber || ''
|
||||
}
|
||||
}
|
||||
62
frontend/src/views/print/qrLogo.js
Normal file
62
frontend/src/views/print/qrLogo.js
Normal file
@@ -0,0 +1,62 @@
|
||||
// Shared GE monogram + QR-with-logo rendering for the printer QR label pages.
|
||||
// Both PrinterQRBatch and PrinterQRSingle render each QR to a data-URL image
|
||||
// (canvases print unreliably) with the GE monogram composited in the center.
|
||||
import QRCode from 'qrcode'
|
||||
import { getQrLogo } from '@/utils/siteSettings'
|
||||
|
||||
const GE_LOGO_SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32.5 32"><path d="M19.8915 11.8362C19.8915 10.0196 21.1404 8.25119 21.826 8.5888C22.6014 8.97061 21.2424 10.6868 19.8915 11.8362ZM11.3823 12.4994C11.3823 11.0364 12.8475 8.25521 13.7453 8.54861C14.8023 8.89425 12.8679 11.6996 11.3823 12.4994ZM9.89679 22.9611C9.2234 22.9932 8.77447 22.5672 8.77447 21.8558C8.77447 19.9508 11.4558 18.1301 13.4841 17.1535C13.125 19.8141 12.2108 22.8525 9.90087 22.957M22.279 16.7516C20.7486 16.7516 19.5773 17.8608 19.5773 19.1912C19.5773 20.3004 20.2507 21.1846 21.1526 21.1846C21.4668 21.1846 21.7811 21.0078 21.7811 20.6099C21.7811 20.0352 21.0057 19.8945 21.0669 19.0304C21.1036 18.4637 21.6505 18.0819 22.1892 18.0819C23.2707 18.0819 23.7768 19.1148 23.7768 20.1758C23.7319 21.8156 22.5075 22.957 21.0669 22.957C19.1773 22.957 17.9611 21.1846 17.9611 19.2756C17.9611 16.4381 19.8507 15.3328 20.8424 15.0676C20.8547 15.0676 23.4299 15.5217 23.3483 14.4004C23.3156 13.9101 22.5688 13.7212 22.03 13.6971C21.4301 13.6729 20.8302 13.886 20.8302 13.886C20.5159 13.7292 20.2996 13.4238 20.165 13.0701C22.0096 11.6956 23.3156 10.3652 23.3156 8.85808C23.3156 8.0623 22.7769 7.35092 21.7403 7.35092C19.8956 7.35092 18.4998 9.65386 18.4998 11.7398C18.4998 12.0934 18.4998 12.4511 18.5896 12.7606C17.4183 13.6046 16.5491 14.1271 14.9737 15.0555C14.9737 14.8626 15.0145 14.3602 15.1492 13.7131C15.6879 13.1384 16.4307 12.2743 16.4307 11.6112C16.4307 11.3017 16.2511 11.0364 15.892 11.0364C14.9941 11.0364 14.3167 12.3667 14.1371 13.2952C13.7331 13.7815 12.9209 14.4044 12.2475 14.4044C11.7088 14.4044 11.5292 13.9141 11.4803 13.7413C13.1903 13.1625 15.3084 10.8596 15.3084 8.77769C15.3084 8.33559 15.1288 7.35895 13.778 7.35895C11.7537 7.35895 10.0437 10.3291 10.0437 12.632C9.32134 12.632 9.05607 11.8764 9.05607 11.3017C9.05607 10.727 9.28053 10.1482 9.28053 9.97136C9.28053 9.79452 9.19075 9.57347 8.92139 9.57347C8.248 9.57347 7.83989 10.4617 7.83989 11.4785C7.88478 12.8973 8.83161 13.7855 10.0886 13.8739C10.2682 14.7179 11.0354 15.5137 11.9782 15.5137C12.5659 15.5137 13.2841 15.3369 13.778 14.8947C13.7331 15.2042 13.6882 15.4695 13.6433 15.7388C11.6639 16.7596 10.2233 17.467 8.91731 18.6204C7.88478 19.5529 7.29709 20.7908 7.29709 21.7674C7.29709 23.0977 8.15005 24.3356 9.90903 24.3356C11.9782 24.3356 13.5535 22.6958 14.3208 20.4371C14.6799 19.372 14.8268 17.8247 14.9166 16.4059C16.9857 15.2565 17.9693 14.5853 19.0467 13.8337C19.1814 14.0548 19.3202 14.2316 19.4956 14.3642C18.5529 14.8505 16.3001 16.2251 16.3001 19.4604C16.3001 21.7674 17.8754 24.3356 20.9812 24.3356C23.5482 24.3356 25.3031 22.2537 25.3031 20.2602C25.3031 18.4436 24.2665 16.7596 22.2872 16.7596M30.025 20.5657C30.025 20.5657 29.9924 20.6019 29.9434 20.5818C29.9067 20.5697 29.8944 20.5496 29.8944 20.5255C29.8944 20.4974 30.4372 18.9219 30.4331 17.1133C30.429 15.164 29.621 13.9663 28.5884 13.9663C27.96 13.9663 27.5069 14.4084 27.5069 15.0756C27.5069 16.2733 28.9925 16.3617 28.9925 18.9781C28.9925 20.0432 28.768 21.06 28.4089 22.1693C26.7438 27.7076 21.4301 30.2798 16.2593 30.2798C13.8718 30.2798 12.1781 29.7975 11.6721 29.5765C11.6517 29.5684 11.6354 29.5283 11.6517 29.4881C11.6639 29.4559 11.6966 29.4358 11.717 29.4439C11.921 29.5242 13.378 29.9744 15.1778 29.9744C17.1571 29.9744 18.3284 29.1786 18.3284 28.202C18.3284 27.583 17.8346 27.0967 17.202 27.0967C15.9859 27.0967 15.8961 28.6039 13.2882 28.6039C12.1618 28.6039 11.1742 28.3828 10.0029 28.0291C4.41988 26.3451 1.76306 21.1605 1.76714 16.0161C1.76714 13.5122 2.48134 11.5187 2.49358 11.4986C2.50174 11.4866 2.53439 11.4705 2.5752 11.4866C2.61602 11.4986 2.62418 11.5348 2.62418 11.5428C2.55888 11.7518 2.08547 13.1786 2.08547 14.951C2.08547 16.9003 2.89353 18.0538 3.93015 18.0538C4.51783 18.0538 5.01165 17.6117 5.01165 16.9887C5.01165 15.791 3.52611 15.6584 3.52611 13.0862C3.52611 11.9769 3.75058 11.0043 4.10972 9.85079C5.80747 4.34464 11.0722 1.7684 16.2471 1.72821C18.6509 1.70811 20.7567 2.41949 20.8383 2.47978C20.8506 2.49184 20.8669 2.52399 20.8506 2.56016C20.8343 2.60035 20.8057 2.60839 20.7935 2.60437C20.769 2.60437 19.3977 2.03768 17.3286 2.03768C15.3941 2.03768 14.1779 2.83346 14.1779 3.85431C14.1779 4.42904 14.6268 4.91535 15.3043 4.91535C16.5205 4.91535 16.6103 3.4524 19.2181 3.4524C20.3445 3.4524 21.3322 3.67345 22.5035 4.02713C28.1314 5.71113 30.6902 10.94 30.7392 15.996C30.7637 18.5843 30.025 20.5456 30.0169 20.5576M16.2471 0.75157C7.69705 0.75157 0.763175 7.58001 0.763175 16C0.763175 24.42 7.69705 31.2444 16.2471 31.2444C24.7971 31.2444 31.7269 24.42 31.7269 16C31.7269 7.58001 24.7971 0.75157 16.2471 0.75157ZM16.2471 32C7.28893 32 0 24.8661 0 16C0 7.13389 7.28893 0 16.2471 0C25.2052 0 32.4941 7.18212 32.4941 16C32.4941 24.8179 25.2011 32 16.2471 32Z" fill="black"/></svg>`
|
||||
|
||||
let logoImage = null
|
||||
|
||||
// Load the configured QR overlay image. On any load failure, fall back to the
|
||||
// built-in GE monogram (last-resort constant above).
|
||||
function loadLogo(url) {
|
||||
if (logoImage) return Promise.resolve(logoImage)
|
||||
return new Promise(resolve => {
|
||||
const img = new Image()
|
||||
img.onload = () => { logoImage = img; resolve(img) }
|
||||
img.onerror = () => {
|
||||
const fallback = new Image()
|
||||
fallback.onload = () => { logoImage = fallback; resolve(fallback) }
|
||||
fallback.onerror = () => resolve(null)
|
||||
fallback.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(GE_LOGO_SVG)
|
||||
}
|
||||
img.src = url
|
||||
})
|
||||
}
|
||||
|
||||
function drawLogoOverlay(canvas, logo) {
|
||||
const canvasContext = canvas.getContext('2d')
|
||||
const size = canvas.width
|
||||
const logoSize = Math.round(size * 0.22)
|
||||
const x = (size - logoSize) / 2
|
||||
const y = (size - logoSize) / 2
|
||||
|
||||
// White circle background behind the monogram
|
||||
canvasContext.beginPath()
|
||||
canvasContext.arc(size / 2, size / 2, logoSize / 2 + 4, 0, Math.PI * 2)
|
||||
canvasContext.fillStyle = '#fff'
|
||||
canvasContext.fill()
|
||||
|
||||
if (logo) {
|
||||
canvasContext.drawImage(logo, x, y, logoSize, logoSize)
|
||||
}
|
||||
}
|
||||
|
||||
// Render a QR for `url` to a PNG data URL with the configured logo composited
|
||||
// in the center. Empty qr_logo setting = no overlay. Returns '' on failure.
|
||||
export async function renderQrDataUrl(url) {
|
||||
const qrLogoUrl = await getQrLogo()
|
||||
const canvas = document.createElement('canvas')
|
||||
try {
|
||||
await QRCode.toCanvas(canvas, url, { width: 144, margin: 0, errorCorrectionLevel: 'H' })
|
||||
if (qrLogoUrl) {
|
||||
const logo = await loadLogo(qrLogoUrl)
|
||||
drawLogoOverlay(canvas, logo)
|
||||
}
|
||||
return canvas.toDataURL('image/png')
|
||||
} catch (err) {
|
||||
console.error('QR error:', err)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
457
frontend/src/views/printers/PrinterDetail.vue
Normal file
457
frontend/src/views/printers/PrinterDetail.vue
Normal file
@@ -0,0 +1,457 @@
|
||||
<template>
|
||||
<div class="detail-page">
|
||||
<div class="page-header">
|
||||
<h2>Printer Details</h2>
|
||||
<div class="header-actions">
|
||||
<router-link :to="`/print/printer-qr/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
||||
Print QR
|
||||
</router-link>
|
||||
<router-link :to="`/print/asset-label/printer/${$route.params.id}`" class="btn btn-secondary" target="_blank">
|
||||
Print Label
|
||||
</router-link>
|
||||
<router-link :to="`/printers/${$route.params.id}/edit`" class="btn btn-primary">Edit</router-link>
|
||||
<router-link to="/printers" class="btn btn-secondary">Back to List</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<template v-else-if="printer">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-card">
|
||||
<div class="hero-image" v-if="printer.printer?.imageurl">
|
||||
<img :src="printer.printer.imageurl" :alt="printer.printer.modelname || 'Model photo'" />
|
||||
</div>
|
||||
<div class="hero-content">
|
||||
<div class="hero-title">
|
||||
<h1>{{ displayTitle }}</h1>
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-printer">Printer</span>
|
||||
<span v-if="printer.printer?.iscsf" class="badge badge-lg badge-info">CSF</span>
|
||||
<span v-if="printer.printer?.iscolor" class="badge badge-lg badge-success">Color</span>
|
||||
<span v-if="printer.printer?.isnetwork" class="badge badge-lg badge-secondary">Network</span>
|
||||
<span v-if="heroWarranty" class="badge badge-lg" :style="{ background: heroWarranty.statuscolor, color: '#fff' }"
|
||||
:title="heroWarranty.enddate ? `Warranty ends ${warrantyDate(heroWarranty.enddate)}` : 'Warranty'">
|
||||
{{ heroWarranty.label }}<template v-if="heroWarranty.enddate"> - {{ warrantyDate(heroWarranty.enddate) }}</template>
|
||||
</span>
|
||||
</div>
|
||||
<div class="hero-details">
|
||||
<div class="hero-detail" v-if="printer.printer?.vendorname">
|
||||
<span class="hero-detail-label">Vendor</span>
|
||||
<span class="hero-detail-value">{{ printer.printer.vendorname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="printer.printer?.modelname">
|
||||
<span class="hero-detail-label">Model</span>
|
||||
<span class="hero-detail-value">{{ printer.printer.modelname }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="printer.serialnumber">
|
||||
<span class="hero-detail-label">Serial Number</span>
|
||||
<span class="hero-detail-value mono">{{ printer.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="hero-detail" v-if="ipAddress">
|
||||
<span class="hero-detail-label">IP Address</span>
|
||||
<span class="hero-detail-value mono">{{ ipAddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Canonical card order: Identity -> type-specific -> status -> Location & Organization -> domain -> Custom Fields -> Warranty -> Relationships -> Notes -> audit footer -->
|
||||
<!-- Main Content Grid -->
|
||||
<div class="content-grid">
|
||||
<!-- Left Column -->
|
||||
<div class="content-column">
|
||||
<!-- Identity Section -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Identity</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Asset Number</span>
|
||||
<span class="info-value mono">{{ printer.assetnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.printer?.windowsname">
|
||||
<span class="info-label">Windows Name</span>
|
||||
<span class="info-value mono">{{ printer.printer.windowsname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('fqdn', 'printer') && printer.printer?.hostname">
|
||||
<span class="info-label">Hostname / FQDN</span>
|
||||
<span class="info-value mono">{{ printer.printer.hostname }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.printer?.sharename">
|
||||
<span class="info-label">CSF Share Name</span>
|
||||
<span class="info-value mono">{{ printer.printer.sharename }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.serialnumber">
|
||||
<span class="info-label">Serial Number</span>
|
||||
<span class="info-value mono">{{ printer.serialnumber }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('gaugelabreference', 'printer') && printer.gaugelabreference">
|
||||
<span class="info-label">Gauge Lab Reference</span>
|
||||
<span class="info-value mono">{{ printer.gaugelabreference }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="isEnabled('maintenancereference', 'printer') && printer.maintenancereference">
|
||||
<span class="info-label">Maintenance Reference</span>
|
||||
<span class="info-value mono">{{ printer.maintenancereference }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Printer Settings -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Printer Settings</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="printer.printer?.installpath">
|
||||
<span class="info-label">Install Path</span>
|
||||
<span class="info-value mono">{{ printer.printer.installpath }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Color</span>
|
||||
<span class="info-value">{{ printer.printer?.iscolor ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Duplex</span>
|
||||
<span class="info-value">{{ printer.printer?.isduplex ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Network Printer</span>
|
||||
<span class="info-value">{{ printer.printer?.isnetwork ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">CSF Printer</span>
|
||||
<span class="info-value">{{ printer.printer?.iscsf ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.printer?.pin">
|
||||
<span class="info-label">PIN</span>
|
||||
<span class="info-value mono">{{ printer.printer.pin }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Network -->
|
||||
<div class="section-card" v-if="printer.communications?.length">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div class="network-list">
|
||||
<div v-for="comm in printer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location & Organization -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location & Organization</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Map Location</span>
|
||||
<span class="info-value">
|
||||
<LocationMapTooltip
|
||||
v-if="printer.mapx != null && printer.mapy != null"
|
||||
:left="printer.mapx"
|
||||
:top="printer.mapy"
|
||||
:machineName="printer.name || printer.assetnumber"
|
||||
>
|
||||
<span class="location-link">View on Map</span>
|
||||
</LocationMapTooltip>
|
||||
<span v-else>Not mapped</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="printer.businessunitname">
|
||||
<span class="info-label">Business Unit</span>
|
||||
<span class="info-value">{{ printer.businessunitname }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="printer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="printer.assetid" :items="warranties" />
|
||||
|
||||
<!-- All relationships (defaultprinter, connectedto, ...) -->
|
||||
<AssetRelationships v-if="printer.assetid" :assetid="printer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="printer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
<p class="notes-text">{{ printer.notes }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Supplies Card -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Supplies</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="supplies.length === 0" class="empty-state">
|
||||
No supply information available
|
||||
</div>
|
||||
|
||||
<div v-else class="supplies-grid">
|
||||
<div v-for="supply in supplies" :key="supply.itemid || supply.name" class="supply-item">
|
||||
<div class="supply-header">
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<span class="supply-level" :class="supply.status">
|
||||
{{ supply.level !== null ? `${supply.level}%` : 'N/A' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="supply-bar">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="supply.status"
|
||||
:style="{ width: `${supply.level || 0}%` }"
|
||||
></div>
|
||||
</div>
|
||||
<div class="supply-meta">
|
||||
<span>{{ formatSupplyType(supply.supplytype) }}<template v-if="supply.iswaste"> ({{ supply.remaining }}% remaining)</template></span>
|
||||
<span v-if="supply.partnumbers && supply.partnumbers.length" class="supply-parts">
|
||||
<span
|
||||
v-for="part in supply.partnumbers"
|
||||
:key="part.partnumber"
|
||||
class="supply-part"
|
||||
:data-tip="`Part ${part.partnumber}` + (part.pageyield ? ` - ${part.pageyield} pages` : '')"
|
||||
>{{ part.marketingname || part.partnumber }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Drivers Card: pulled from the driver catalog by this printer's model -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Drivers</h3>
|
||||
</div>
|
||||
|
||||
<div v-if="drivers.length === 0" class="empty-state">
|
||||
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>Name</th>
|
||||
<th>Location</th>
|
||||
<th>Description</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="driver in drivers" :key="driver.driverid">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Audit Footer -->
|
||||
<div class="audit-footer">
|
||||
<span>Created {{ formatDate(printer.createddate) }}<template v-if="printer.createdby"> by {{ printer.createdby }}</template></span>
|
||||
<span>Modified {{ formatDate(printer.modifieddate) }}<template v-if="printer.modifiedby"> by {{ printer.modifiedby }}</template></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="card">
|
||||
<p style="text-align: center; color: var(--text-light);">Printer not found</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
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 AssetRelationships from '../../components/AssetRelationships.vue'
|
||||
import { useWarrantyBadge } from '../../composables/warrantyBadge'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
const loading = ref(true)
|
||||
const printer = ref(null)
|
||||
const { warranties, heroWarranty, warrantyDate } = useWarrantyBadge(() => printer.value?.assetid)
|
||||
const supplies = ref([])
|
||||
const drivers = ref([])
|
||||
|
||||
// Best display identifier for a printer. name is often the literal "NONE",
|
||||
// so fall back to the Windows name, then hostname, then asset number.
|
||||
const displayTitle = computed(() => {
|
||||
const p = printer.value
|
||||
if (!p) return ''
|
||||
const name = (p.name || '').trim()
|
||||
if (name && name.toUpperCase() !== 'NONE') return name
|
||||
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
|
||||
const primaryComm = printer.value.communications.find(c => c.isprimary) || printer.value.communications[0]
|
||||
return primaryComm?.ipaddress || null
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [printerRes, suppliesRes] = await Promise.all([
|
||||
printersApi.get(route.params.id),
|
||||
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 are attached to the printer detail, matched by model
|
||||
drivers.value = printerRes.data.data?.drivers || []
|
||||
} catch (error) {
|
||||
console.error('Error loading printer:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active' || s === 'online') return 'badge-success'
|
||||
if (s === 'in repair' || s === 'offline') return 'badge-warning'
|
||||
if (s === 'retired' || s === 'error') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
|
||||
function formatSupplyType(supplytype) {
|
||||
if (!supplytype) return ''
|
||||
return supplytype.charAt(0).toUpperCase() + supplytype.slice(1)
|
||||
}
|
||||
|
||||
function formatDate(dateStr) {
|
||||
if (!dateStr) return '-'
|
||||
return new Date(dateStr).toLocaleString()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* Printer-specific styles - shared styles are in global style.css */
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
color: var(--text-light);
|
||||
padding: 2.5rem;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
/* 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));
|
||||
gap: 1.25rem;
|
||||
}
|
||||
|
||||
.supply-item {
|
||||
background: var(--bg);
|
||||
padding: 1.25rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.supply-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
font-weight: 500;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
font-weight: 600;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.supply-level.ok { color: var(--success); }
|
||||
.supply-level.low { color: var(--warning); }
|
||||
.supply-level.critical { color: var(--danger); }
|
||||
|
||||
.supply-bar {
|
||||
height: 10px;
|
||||
background: var(--border);
|
||||
border-radius: 5px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
transition: width 0.3s ease;
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-bar-fill.low { background: var(--warning); }
|
||||
.supply-bar-fill.critical { background: var(--danger); }
|
||||
|
||||
.supply-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
margin-top: 0.625rem;
|
||||
}
|
||||
|
||||
.supply-parts {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.supply-part {
|
||||
cursor: help;
|
||||
border-bottom: 1px dotted var(--text-light);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* instant CSS tooltip, no native title delay */
|
||||
.supply-part:hover::after {
|
||||
content: attr(data-tip);
|
||||
position: absolute;
|
||||
bottom: 125%;
|
||||
right: 0;
|
||||
white-space: nowrap;
|
||||
background: var(--text);
|
||||
color: var(--bg-card);
|
||||
padding: 0.35rem 0.6rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85rem;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
655
frontend/src/views/printers/PrinterForm.vue
Normal file
655
frontend/src/views/printers/PrinterForm.vue
Normal file
@@ -0,0 +1,655 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>{{ isEdit ? 'Edit Printer' : 'New Printer' }}</h2>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
|
||||
<form v-else @submit.prevent="savePrinter">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinenumber">Windows Name *</label>
|
||||
<input
|
||||
id="machinenumber"
|
||||
v-model="form.machinenumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
required
|
||||
@input="onWindowsNameInput"
|
||||
:class="{ 'auto-generated': !manualWindowsName && form.machinenumber }"
|
||||
/>
|
||||
<small class="form-hint">Auto-generated from CSF Name, Alias, Vendor & Model</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="alias">Alias / Location</label>
|
||||
<input
|
||||
id="alias"
|
||||
v-model="form.alias"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., SpoolsInspection"
|
||||
/>
|
||||
<small class="form-hint">Used in Windows name generation</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group" v-if="isEnabled('fqdn', 'printer')">
|
||||
<label for="hostname">Hostname (FQDN)</label>
|
||||
<input
|
||||
id="hostname"
|
||||
v-model="form.hostname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
@input="onHostnameInput"
|
||||
:class="{ 'auto-generated': !manualHostname && form.hostname }"
|
||||
/>
|
||||
<small class="form-hint">Auto-generated from IP address</small>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="serialnumber">Serial Number</label>
|
||||
<input
|
||||
id="serialnumber"
|
||||
v-model="form.serialnumber"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" v-if="isEnabled('gaugelabreference', 'printer') || isEnabled('maintenancereference', 'printer')">
|
||||
<div class="form-group" v-if="isEnabled('gaugelabreference', 'printer')">
|
||||
<label for="gaugelabreference">Gauge Lab Reference</label>
|
||||
<input
|
||||
id="gaugelabreference"
|
||||
v-model="form.gaugelabreference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group" v-if="isEnabled('maintenancereference', 'printer')">
|
||||
<label for="maintenancereference">Maintenance Reference</label>
|
||||
<input
|
||||
id="maintenancereference"
|
||||
v-model="form.maintenancereference"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="machinetypeid">Printer Type *</label>
|
||||
<select
|
||||
id="machinetypeid"
|
||||
v-model="form.machinetypeid"
|
||||
class="form-control"
|
||||
required
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option
|
||||
v-for="pt in printerTypes"
|
||||
:key="pt.printertypeid"
|
||||
:value="pt.printertypeid"
|
||||
>
|
||||
{{ pt.printertype }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="statusid">Status</label>
|
||||
<select
|
||||
id="statusid"
|
||||
v-model="form.statusid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select status...</option>
|
||||
<option
|
||||
v-for="s in statuses"
|
||||
:key="s.statusid"
|
||||
:value="s.statusid"
|
||||
>
|
||||
{{ s.status }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="vendorid">Vendor</label>
|
||||
<select
|
||||
id="vendorid"
|
||||
v-model="form.vendorid"
|
||||
class="form-control"
|
||||
@change="form.modelnumberid = ''"
|
||||
>
|
||||
<option value="">Select vendor...</option>
|
||||
<option
|
||||
v-for="v in vendors"
|
||||
:key="v.vendorid"
|
||||
:value="v.vendorid"
|
||||
>
|
||||
{{ v.vendor }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="modelnumberid">Model</label>
|
||||
<select
|
||||
id="modelnumberid"
|
||||
v-model="form.modelnumberid"
|
||||
class="form-control"
|
||||
:disabled="!form.vendorid"
|
||||
>
|
||||
<option value="">{{ form.vendorid ? 'Select model...' : 'Select a vendor first' }}</option>
|
||||
<option
|
||||
v-for="m in filteredModels"
|
||||
:key="m.modelnumberid"
|
||||
:value="m.modelnumberid"
|
||||
>
|
||||
{{ m.modelnumber }}
|
||||
</option>
|
||||
</select>
|
||||
<small v-if="!form.vendorid" class="form-hint">
|
||||
Select a vendor first to choose a model
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="locationid">Location</label>
|
||||
<select
|
||||
id="locationid"
|
||||
v-model="form.locationid"
|
||||
class="form-control"
|
||||
>
|
||||
<option value="">Select location...</option>
|
||||
<option
|
||||
v-for="l in locations"
|
||||
:key="l.locationid"
|
||||
:value="l.locationid"
|
||||
>
|
||||
{{ l.location }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Printer-specific fields -->
|
||||
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printer Settings</h4>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="ipaddress">IP Address</label>
|
||||
<input
|
||||
id="ipaddress"
|
||||
v-model="form.ipaddress"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="e.g., 192.168.1.100"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="csfname">CSF Name</label>
|
||||
<input
|
||||
id="csfname"
|
||||
v-model="form.csfname"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="pin">PIN</label>
|
||||
<input
|
||||
id="pin"
|
||||
v-model="form.pin"
|
||||
type="text"
|
||||
class="form-control"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="installpath">Driver Install Path</label>
|
||||
<input
|
||||
id="installpath"
|
||||
v-model="form.installpath"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Leave empty for universal driver"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="notes">Notes</label>
|
||||
<textarea
|
||||
id="notes"
|
||||
v-model="form.notes"
|
||||
class="form-control"
|
||||
rows="3"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Map Location Picker -->
|
||||
<div class="form-group">
|
||||
<label>Map Location</label>
|
||||
<div class="map-location-control">
|
||||
<div v-if="form.mapx !== null && form.mapy !== null" class="current-position">
|
||||
Position: {{ form.mapx }}, {{ form.mapy }}
|
||||
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
|
||||
</div>
|
||||
<button type="button" class="btn btn-secondary" @click="showMapPicker = true">
|
||||
Set Location on Map
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Map Picker Modal -->
|
||||
<Modal v-model="showMapPicker" title="Select Location on Map" size="fullscreen">
|
||||
<div class="map-modal-content">
|
||||
<ShopFloorMap
|
||||
:pickerMode="true"
|
||||
:initialPosition="form.mapx !== null ? { left: form.mapx, top: form.mapy } : null"
|
||||
:theme="currentTheme"
|
||||
@positionPicked="handlePositionPicked"
|
||||
/>
|
||||
</div>
|
||||
<template #footer>
|
||||
<button class="btn btn-secondary" @click="showMapPicker = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="confirmMapPosition">Confirm Location</button>
|
||||
</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;">
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">
|
||||
{{ saving ? 'Saving...' : 'Save Printer' }}
|
||||
</button>
|
||||
<router-link to="/printers" class="btn btn-secondary">Cancel</router-link>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
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'
|
||||
import { apiError } from '../../utils/apiError'
|
||||
import { getPrinterHostnameTemplate } from '../../utils/siteSettings'
|
||||
|
||||
const { isEnabled } = useIdentifierFlags()
|
||||
|
||||
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)
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const showMapPicker = ref(false)
|
||||
const tempMapPosition = ref(null)
|
||||
|
||||
const form = ref({
|
||||
machinenumber: '',
|
||||
alias: '',
|
||||
hostname: '',
|
||||
serialnumber: '',
|
||||
gaugelabreference: '',
|
||||
maintenancereference: '',
|
||||
machinetypeid: '',
|
||||
statusid: '',
|
||||
vendorid: '',
|
||||
modelnumberid: '',
|
||||
locationid: '',
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
// Printer-specific
|
||||
ipaddress: '',
|
||||
csfname: '',
|
||||
installpath: '',
|
||||
pin: ''
|
||||
})
|
||||
|
||||
const printerTypes = ref([])
|
||||
const statuses = ref([])
|
||||
const vendors = ref([])
|
||||
const models = ref([])
|
||||
const locations = ref([])
|
||||
|
||||
// Filter models by selected vendor and printer type
|
||||
const filteredModels = computed(() => {
|
||||
return models.value.filter(m => {
|
||||
// Filter by vendor if selected
|
||||
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
|
||||
return false
|
||||
}
|
||||
// Filter by printer type if selected, but only exclude models that have a
|
||||
// type set and it differs. Most models have no machinetypeid, so excluding
|
||||
// null-typed models would hide the printer's own model from the dropdown.
|
||||
if (form.value.machinetypeid && m.machinetypeid && m.machinetypeid !== form.value.machinetypeid) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
// Get short description from model number for naming
|
||||
function getModelShortDesc(modelNumber) {
|
||||
if (!modelNumber) return ''
|
||||
const mn = modelNumber.toLowerCase()
|
||||
|
||||
if (mn.includes('colorlaserjet')) return 'ColorLaserJet'
|
||||
if (mn.includes('laserjetpro') || mn.includes('laserjet pro')) return 'LaserJetPro'
|
||||
if (mn.includes('laserjet')) return 'LaserJet'
|
||||
if (mn.includes('altalink')) return 'Altalink'
|
||||
if (mn.includes('versalink')) return 'Versalink'
|
||||
if (mn.includes('designjet')) return 'DesignJet'
|
||||
if (mn.includes('dtc')) return 'DTC'
|
||||
if (mn.includes('officejet')) return 'OfficeJet'
|
||||
if (mn.includes('pagewide')) return 'PageWide'
|
||||
|
||||
// Fallback: get letters before first digit
|
||||
const match = modelNumber.match(/^([A-Za-z]+)/)
|
||||
return match ? match[1] : modelNumber.substring(0, 5)
|
||||
}
|
||||
|
||||
// Auto-generate hostname from IP address using the site template ({ip} =
|
||||
// dash-separated IP).
|
||||
async function generateHostname(ip) {
|
||||
if (!ip) return ''
|
||||
const ipDashed = ip.replace(/\./g, '-')
|
||||
const template = await getPrinterHostnameTemplate()
|
||||
return template.replace('{ip}', ipDashed)
|
||||
}
|
||||
|
||||
// Auto-generate Windows name (machinenumber)
|
||||
function generateWindowsName() {
|
||||
const parts = []
|
||||
|
||||
// 1. CSF Name (if set and not "NONE")
|
||||
const csfName = form.value.csfname?.trim()
|
||||
if (csfName && csfName.toUpperCase() !== 'NONE') {
|
||||
parts.push(csfName.replace(/\s+/g, ''))
|
||||
}
|
||||
|
||||
// 2. Location (from alias, removing spaces and "Machine")
|
||||
const alias = form.value.alias?.trim()
|
||||
if (alias) {
|
||||
const location = alias.replace(/\s+/g, '').replace(/Machine/gi, '')
|
||||
// Skip if same as CSF name
|
||||
if (location.toLowerCase() !== csfName?.toLowerCase()) {
|
||||
parts.push(location)
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Vendor + Model short description
|
||||
const selectedModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
|
||||
const selectedVendor = vendors.value.find(v => v.vendorid === form.value.vendorid)
|
||||
|
||||
let vendorModel = ''
|
||||
if (selectedVendor) {
|
||||
vendorModel = selectedVendor.vendor.replace(/\s+/g, '')
|
||||
}
|
||||
if (selectedModel) {
|
||||
vendorModel += getModelShortDesc(selectedModel.modelnumber)
|
||||
}
|
||||
if (vendorModel) {
|
||||
parts.push(vendorModel)
|
||||
}
|
||||
|
||||
return parts.join('-')
|
||||
}
|
||||
|
||||
// Watch IP address and auto-generate hostname
|
||||
watch(() => form.value.ipaddress, async (newIp) => {
|
||||
if (!manualHostname.value && newIp) {
|
||||
form.value.hostname = await generateHostname(newIp)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch fields that affect Windows name generation
|
||||
watch(
|
||||
() => [form.value.csfname, form.value.alias, form.value.vendorid, form.value.modelnumberid],
|
||||
() => {
|
||||
if (!manualWindowsName.value && !isEdit.value) {
|
||||
const generated = generateWindowsName()
|
||||
if (generated) {
|
||||
form.value.machinenumber = generated
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// Track manual edits to hostname
|
||||
function onHostnameInput() {
|
||||
manualHostname.value = true
|
||||
}
|
||||
|
||||
// Track manual edits to Windows name
|
||||
function onWindowsNameInput() {
|
||||
manualWindowsName.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
// Load reference data (models paged in full via listAll, see api/index.js)
|
||||
// perpage 100 so dropdowns aren't truncated to the default 20-row page
|
||||
// (e.g. 44 vendors; the editing record's vendor can be past row 20)
|
||||
const [mtRes, statusRes, vendorRes, allModels, locRes] = await Promise.all([
|
||||
printersApi.types.list({ perpage: 100 }),
|
||||
assetsApi.statuses.list(),
|
||||
vendorsApi.list({ perpage: 100 }),
|
||||
modelsApi.listAll(),
|
||||
locationsApi.list({ perpage: 100 })
|
||||
])
|
||||
|
||||
printerTypes.value = mtRes.data.data || []
|
||||
statuses.value = statusRes.data.data || []
|
||||
vendors.value = vendorRes.data.data || []
|
||||
models.value = allModels
|
||||
locations.value = locRes.data.data || []
|
||||
|
||||
// Load printer if editing
|
||||
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 || {}
|
||||
|
||||
// Get IP from communications
|
||||
const primaryComm = printer.communications?.find(c => c.isprimary) || printer.communications?.[0]
|
||||
|
||||
form.value = {
|
||||
// "Windows Name" is the printer's business identifier (assetnumber).
|
||||
// Prefer the explicit windowsname, then fall back to assetnumber.
|
||||
machinenumber: ext.windowsname || printer.assetnumber || '',
|
||||
alias: printer.name && printer.name.toUpperCase() !== 'NONE' ? printer.name : '',
|
||||
hostname: ext.hostname || '',
|
||||
serialnumber: printer.serialnumber || '',
|
||||
gaugelabreference: printer.gaugelabreference || '',
|
||||
maintenancereference: printer.maintenancereference || '',
|
||||
machinetypeid: ext.printertypeid || '',
|
||||
statusid: printer.statusid || '',
|
||||
vendorid: ext.vendorid || '',
|
||||
modelnumberid: ext.modelnumberid || '',
|
||||
locationid: printer.locationid || '',
|
||||
notes: printer.notes || '',
|
||||
mapx: printer.mapx ?? null,
|
||||
mapy: printer.mapy ?? null,
|
||||
// Printer-specific
|
||||
ipaddress: primaryComm?.ipaddress || '',
|
||||
csfname: ext.sharename || '',
|
||||
installpath: ext.installpath || '',
|
||||
pin: ext.pin || ''
|
||||
}
|
||||
|
||||
// Don't auto-generate for existing printers
|
||||
manualWindowsName.value = true
|
||||
manualHostname.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading data:', err)
|
||||
error.value = 'Failed to load data'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
function handlePositionPicked(position) {
|
||||
tempMapPosition.value = position
|
||||
}
|
||||
|
||||
function confirmMapPosition() {
|
||||
if (tempMapPosition.value) {
|
||||
form.value.mapx = tempMapPosition.value.left
|
||||
form.value.mapy = tempMapPosition.value.top
|
||||
}
|
||||
showMapPicker.value = false
|
||||
}
|
||||
|
||||
function clearMapPosition() {
|
||||
form.value.mapx = null
|
||||
form.value.mapy = null
|
||||
tempMapPosition.value = null
|
||||
}
|
||||
|
||||
async function savePrinter() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
|
||||
try {
|
||||
// One payload for the printers plugin, which owns asset core + extension +
|
||||
// primary communication. The "Windows Name" field is the business
|
||||
// identifier, written to both assetnumber and the extension windowsname.
|
||||
const payload = {
|
||||
assetnumber: form.value.machinenumber,
|
||||
windowsname: form.value.machinenumber || null,
|
||||
hostname: form.value.hostname || null,
|
||||
serialnumber: form.value.serialnumber || null,
|
||||
gaugelabreference: form.value.gaugelabreference || null,
|
||||
maintenancereference: form.value.maintenancereference || null,
|
||||
printertypeid: form.value.machinetypeid || null,
|
||||
statusid: form.value.statusid || null,
|
||||
vendorid: form.value.vendorid || null,
|
||||
modelnumberid: form.value.modelnumberid || null,
|
||||
locationid: form.value.locationid || null,
|
||||
sharename: form.value.csfname || null,
|
||||
iscsf: !!form.value.csfname,
|
||||
installpath: form.value.installpath || null,
|
||||
pin: form.value.pin || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
mapx: form.value.mapx,
|
||||
mapy: form.value.mapy
|
||||
}
|
||||
// only set the display name when an alias is given, so we don't clobber it
|
||||
if (form.value.alias) {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
let assetId = currentAssetId.value
|
||||
if (isEdit.value) {
|
||||
const response = await printersApi.update(route.params.id, payload)
|
||||
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
|
||||
} else {
|
||||
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')
|
||||
} catch (err) {
|
||||
console.error('Error saving printer:', err)
|
||||
error.value = apiError(err, 'Failed to save printer')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.map-location-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.current-position {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 1rem;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.map-modal-content {
|
||||
height: calc(90vh - 140px);
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.shopfloor-map) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.map-modal-content :deep(.map-container) {
|
||||
height: calc(100% - 50px);
|
||||
}
|
||||
|
||||
.form-hint {
|
||||
display: block;
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light, #666);
|
||||
}
|
||||
|
||||
/* auto-fill cue: a subtle translucent blue tint + accent border that reads
|
||||
correctly over both light and dark backgrounds. Text color stays themed
|
||||
(no solid light fill that turns into an unreadable white box in dark mode). */
|
||||
.auto-generated {
|
||||
background-color: rgba(33, 150, 243, 0.12);
|
||||
border-color: #90caf9;
|
||||
}
|
||||
</style>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user