Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session: Settings IA - Replace the flat 27-card settings hub with a persistent two-pane shell (SettingsLayout.vue): grouped, searchable left rail + content pane. - Nest all settings/* routes under the shell via router post-processing; shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers, Equipment, Network) so per-type settings stop scattering. Custom fields (core) - customfields + customfieldvalues tables (migration 7d14), CRUD API at /api/customfields, per-asset value get/save. - Settings management page + reusable CustomFieldsSection (detail) and CustomFieldsInputs (form) wired into all four asset types. Warranty (new plugin) - plugins/warranty: warranties + warrantyassets (migration 7d15), derived coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs). - API CRUD + per-asset panel + report buckets; WarrantyPanel on all four detail pages; Warranties management page; Warranty report + Reports card. - Seed warranty.* permissions. Printer drivers - printerdrivers table (migration 7d13) linked to printer models; drivers now surface on the matching printer's detail page. Other - PCDetail rebalanced (Network + Status + Warranty + custom fields on the right). - Rename PCs list "Features" column to "Remote Access"; fix badge hover underline. - Drop equipment islocationonly field. - Centralize asset-type label/route maps into utils/assetTypes.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -47,6 +47,15 @@
|
||||
@input="debouncedSearch"
|
||||
/>
|
||||
|
||||
<button
|
||||
class="btn btn-secondary export-btn"
|
||||
@click="exportPdf"
|
||||
:disabled="exporting || !filteredAssets.length"
|
||||
:title="filteredAssets.length ? 'Export the filtered map to PDF' : 'No assets to export'"
|
||||
>
|
||||
{{ exporting ? 'Exporting...' : 'Export PDF' }}
|
||||
</button>
|
||||
|
||||
<span class="result-count">{{ filteredAssets.length }} assets</span>
|
||||
</div>
|
||||
|
||||
@@ -73,6 +82,9 @@ import ShopFloorMap from '../components/ShopFloorMap.vue'
|
||||
import { assetsApi } from '../api'
|
||||
import { currentTheme } from '../stores/theme'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { loadMapConfig, state as mapConfig } from '../composables/mapConfig'
|
||||
import { exportMapPdf } from '../utils/mapPdf'
|
||||
import { assetTypeLabel, assetDetailRoute } from '../utils/assetTypes'
|
||||
|
||||
const router = useRouter()
|
||||
const authStore = useAuthStore()
|
||||
@@ -89,18 +101,20 @@ const selectedSubtype = ref('')
|
||||
const selectedBusinessUnit = ref('')
|
||||
const selectedStatus = ref('')
|
||||
const searchQuery = ref('')
|
||||
const exporting = ref(false)
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// Case-insensitive lookup helper for subtypes
|
||||
// Lookup helper for subtypes. Normalizes case AND underscores-vs-spaces, since
|
||||
// the asset-type value is 'network_device' but the subtypes key is
|
||||
// 'Network Device' - without normalizing, network subtypes never match.
|
||||
function getSubtypesForType(typeName) {
|
||||
if (!typeName || !subtypes.value) return []
|
||||
// Try exact match first
|
||||
if (subtypes.value[typeName]) return subtypes.value[typeName]
|
||||
// Try case-insensitive match
|
||||
const lowerType = typeName.toLowerCase()
|
||||
const norm = typeName.toLowerCase().replace(/_/g, ' ')
|
||||
for (const [key, value] of Object.entries(subtypes.value)) {
|
||||
if (key.toLowerCase() === lowerType) return value
|
||||
if (key.toLowerCase().replace(/_/g, ' ') === norm) return value
|
||||
}
|
||||
return []
|
||||
}
|
||||
@@ -119,7 +133,7 @@ const subtypeLabel = computed(() => {
|
||||
'network device': 'All Device Types',
|
||||
'printer': 'All Printer Types'
|
||||
}
|
||||
return labels[selectedType.value.toLowerCase()] || 'All Subtypes'
|
||||
return labels[selectedType.value.toLowerCase().replace(/_/g, ' ')] || 'All Subtypes'
|
||||
})
|
||||
|
||||
// Generate distinct colors for subtypes
|
||||
@@ -130,12 +144,13 @@ const subtypeColorPalette = [
|
||||
'#FF5722', '#795548', '#607D8B', '#00ACC1', '#5C6BC0'
|
||||
]
|
||||
|
||||
// Map subtype IDs to colors
|
||||
// Map subtype IDs to colors: prefer each subtype's stored color; fall back to
|
||||
// the auto palette (by index) for subtypes that have not been given one.
|
||||
const subtypeColorMap = computed(() => {
|
||||
const colorMap = {}
|
||||
const allSubtypes = currentSubtypes.value
|
||||
allSubtypes.forEach((st, index) => {
|
||||
colorMap[st.id] = subtypeColorPalette[index % subtypeColorPalette.length]
|
||||
colorMap[st.id] = st.color || subtypeColorPalette[index % subtypeColorPalette.length]
|
||||
})
|
||||
return colorMap
|
||||
})
|
||||
@@ -158,10 +173,10 @@ const filteredAssets = computed(() => {
|
||||
result = result.filter(a => a.assettype && a.assettype.toLowerCase() === selectedLower)
|
||||
}
|
||||
|
||||
// Filter by subtype (case-insensitive type check)
|
||||
// Filter by subtype (normalize network_device -> network device)
|
||||
if (selectedSubtype.value) {
|
||||
const subtypeId = parseInt(selectedSubtype.value)
|
||||
const typeLower = selectedType.value?.toLowerCase() || ''
|
||||
const typeLower = (selectedType.value || '').toLowerCase().replace(/_/g, ' ')
|
||||
result = result.filter(a => {
|
||||
if (!a.typedata) return false
|
||||
// Check different ID fields based on asset type
|
||||
@@ -202,7 +217,51 @@ const filteredAssets = computed(() => {
|
||||
return result
|
||||
})
|
||||
|
||||
// Human-readable labels for the filters currently applied, for the PDF header.
|
||||
function activeFilterLabels() {
|
||||
const labels = []
|
||||
if (selectedType.value) labels.push(`Type: ${formatTypeName(selectedType.value)}`)
|
||||
if (selectedSubtype.value) {
|
||||
const st = currentSubtypes.value.find(s => String(s.id) === String(selectedSubtype.value))
|
||||
if (st) labels.push(`Subtype: ${st.name}`)
|
||||
}
|
||||
if (selectedBusinessUnit.value) {
|
||||
const bu = businessunits.value.find(b => String(b.businessunitid) === String(selectedBusinessUnit.value))
|
||||
if (bu) labels.push(`Business Unit: ${bu.businessunit}`)
|
||||
}
|
||||
if (selectedStatus.value) {
|
||||
const s = statuses.value.find(x => String(x.statusid) === String(selectedStatus.value))
|
||||
if (s) labels.push(`Status: ${s.status}`)
|
||||
}
|
||||
if (searchQuery.value) labels.push(`Search: "${searchQuery.value}"`)
|
||||
return labels
|
||||
}
|
||||
|
||||
async function exportPdf() {
|
||||
if (!filteredAssets.value.length) return
|
||||
exporting.value = true
|
||||
try {
|
||||
await loadMapConfig()
|
||||
await exportMapPdf({
|
||||
assets: filteredAssets.value,
|
||||
blueprintUrl: mapConfig.blueprintLight,
|
||||
mapWidth: mapConfig.width,
|
||||
mapHeight: mapConfig.height,
|
||||
selectedType: selectedType.value,
|
||||
subtypeColors: subtypeColorMap.value,
|
||||
subtypeNames: subtypeNameMap.value,
|
||||
filters: activeFilterLabels()
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('Map PDF export failed:', e)
|
||||
alert('Failed to export map PDF. See console for details.')
|
||||
} finally {
|
||||
exporting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
loadMapConfig()
|
||||
try {
|
||||
const response = await assetsApi.getMap()
|
||||
const data = response.data.data || {}
|
||||
@@ -220,15 +279,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
function formatTypeName(assettype) {
|
||||
if (!assettype) return assettype
|
||||
const names = {
|
||||
'equipment': 'Equipment',
|
||||
'computer': 'Computers',
|
||||
'printer': 'Printers',
|
||||
'network device': 'Network Devices',
|
||||
'network_device': 'Network Devices'
|
||||
}
|
||||
return names[assettype.toLowerCase()] || assettype
|
||||
return assetTypeLabel(assettype)
|
||||
}
|
||||
|
||||
function getTypeCount(assettype) {
|
||||
@@ -255,33 +306,7 @@ function debouncedSearch() {
|
||||
}
|
||||
|
||||
function handleMarkerClick(asset) {
|
||||
// Route based on asset type (lowercase keys to match API data)
|
||||
const assetType = (asset.assettype || '').toLowerCase()
|
||||
const routeMap = {
|
||||
'equipment': '/machines',
|
||||
'computer': '/pcs',
|
||||
'printer': '/printers',
|
||||
'network_device': '/network',
|
||||
'network device': '/network'
|
||||
}
|
||||
|
||||
const basePath = routeMap[assetType] || '/machines'
|
||||
|
||||
// Get the plugin-specific ID from typedata
|
||||
let id = asset.assetid // fallback
|
||||
if (asset.typedata) {
|
||||
if (assetType === 'equipment' && asset.typedata.equipmentid) {
|
||||
id = asset.typedata.equipmentid
|
||||
} else if (assetType === 'computer' && asset.typedata.computerid) {
|
||||
id = asset.typedata.computerid
|
||||
} else if (assetType === 'printer' && asset.typedata.printerid) {
|
||||
id = asset.typedata.printerid
|
||||
} else if ((assetType === 'network_device' || assetType === 'network device') && asset.typedata.networkdeviceid) {
|
||||
id = asset.typedata.networkdeviceid
|
||||
}
|
||||
}
|
||||
|
||||
router.push(`${basePath}/${id}`)
|
||||
router.push(assetDetailRoute(asset))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -331,10 +356,30 @@ function handleMarkerClick(asset) {
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.export-btn {
|
||||
margin-left: auto;
|
||||
padding: 0.5rem 0.9rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-card);
|
||||
color: var(--text);
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.export-btn:hover:not(:disabled) {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.export-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.result-count {
|
||||
color: var(--text-light);
|
||||
font-size: 0.875rem;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.map-page :deep(.shopfloor-map) {
|
||||
|
||||
Reference in New Issue
Block a user