Global toast notifications; replace all alert() calls
Add a useToast() composable + a single ToastHost mounted in AppLayout. Convert every alert() across views/components (29 call sites, all error paths) to toast.error, and use toast.success to confirm a warranty refresh. Kills the native "localhost says" dialog and gives consistent, dismissable feedback. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,388 +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'
|
||||
|
||||
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 = {
|
||||
'equipment': '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 === 'equipment') {
|
||||
return a.typedata.equipmenttypeid === 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)
|
||||
alert('Failed to export map PDF. See console for details.')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadMapConfig()
|
||||
try {
|
||||
const response = await assetsApi.getMap()
|
||||
const data = response.data.data || {}
|
||||
|
||||
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>
|
||||
<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 = {
|
||||
'equipment': '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 === 'equipment') {
|
||||
return a.typedata.equipmenttypeid === 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>
|
||||
|
||||
Reference in New Issue
Block a user