Add USB, Notifications, Network plugins and reusable EmployeeSearch component

New Plugins:
- USB plugin: Device checkout/checkin with employee lookup, checkout history
- Notifications plugin: Announcements with types, scheduling, shopfloor display
- Network plugin: Network device management with subnets and VLANs
- Equipment and Computers plugins: Asset type separation

Frontend:
- EmployeeSearch component: Reusable employee lookup with autocomplete
- USB views: List, detail, checkout/checkin modals
- Notifications views: List, form with recognition mode
- Network views: Device list, detail, form
- Calendar view with FullCalendar integration
- Shopfloor and TV dashboard views
- Reports index page
- Map editor for asset positioning
- Light/dark mode fixes for map tooltips

Backend:
- Employee search API with external lookup service
- Collector API for PowerShell data collection
- Reports API endpoints
- Slides API for TV dashboard
- Fixed AppVersion model (removed BaseModel inheritance)
- Added checkout_name column to usbcheckouts table

Styling:
- Unified detail page styles
- Improved pagination (page numbers instead of prev/next)
- Dark/light mode theme improvements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-21 16:37:49 -05:00
parent 7e729c8fdc
commit c8b325d2e7
108 changed files with 17693 additions and 600 deletions

View File

@@ -67,7 +67,8 @@ const props = defineProps({
statuses: { type: Array, default: () => [] },
theme: { type: String, default: 'dark' },
pickerMode: { type: Boolean, default: false },
initialPosition: { type: Object, default: null } // { left, top }
initialPosition: { type: Object, default: null }, // { left, top }
assetTypeMode: { type: Boolean, default: false } // When true, use unified asset format
})
const emit = defineEmits(['markerClick', 'positionPicked'])
@@ -91,6 +92,14 @@ const MAP_WIDTH = 3300
const MAP_HEIGHT = 2550
const bounds = [[0, 0], [MAP_HEIGHT, MAP_WIDTH]]
// Asset type colors (for unified map mode)
const assetTypeColors = {
'equipment': '#F44336', // Red
'computer': '#2196F3', // Blue
'printer': '#4CAF50', // Green
'network_device': '#FF9800' // Orange
}
// Type colors - distinct colors for each machine type
const typeColors = {
// Machining
@@ -158,7 +167,8 @@ function initMap() {
map = L.map(mapContainer.value, {
crs: L.CRS.Simple,
minZoom: -4,
maxZoom: 2
maxZoom: 2,
attributionControl: false
})
const blueprintUrl = props.theme === 'light'
@@ -168,8 +178,8 @@ function initMap() {
imageOverlay = L.imageOverlay(blueprintUrl, bounds)
imageOverlay.addTo(map)
// Set initial view
const initialZoom = -1
// 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)
@@ -263,15 +273,29 @@ function renderMarkers() {
markers.value.forEach(m => m.marker.remove())
markers.value = []
props.machines.forEach(machine => {
if (machine.mapleft == null || machine.maptop == null) return
props.machines.forEach(item => {
if (item.mapleft == null || item.maptop == null) return
// Transform coordinates (database Y is top-down, Leaflet is bottom-up)
const leafletY = MAP_HEIGHT - machine.maptop
const leafletX = machine.mapleft
const leafletY = MAP_HEIGHT - item.maptop
const leafletX = item.mapleft
const typeName = machine.machinetype || ''
const color = getTypeColor(typeName)
// Determine color based on mode
let color, typeName, displayName, detailRoute
if (props.assetTypeMode) {
// Unified asset mode
color = assetTypeColors[item.assettype] || '#BDBDBD'
typeName = item.assettype || ''
displayName = item.displayname || item.name || item.assetnumber || 'Unknown'
detailRoute = getAssetDetailRoute(item)
} else {
// Legacy machine mode
typeName = item.machinetype || ''
color = getTypeColor(typeName)
displayName = item.alias || item.machinenumber || 'Unknown'
detailRoute = getDetailRoute(item)
}
const icon = L.divIcon({
html: `<div class="machine-marker-dot" style="background: ${color};"></div>`,
@@ -283,45 +307,56 @@ function renderMarkers() {
const marker = L.marker([leafletY, leafletX], { icon })
// Display name for tooltips and popups
const displayName = machine.alias || machine.machinenumber || 'Unknown'
const detailRoute = getDetailRoute(machine)
const category = machine.category?.toLowerCase() || ''
// Build tooltip content based on category
// Build tooltip content
let tooltipLines = [`<strong>${displayName}</strong>`]
// Don't show "LocationOnly" as machine type
if (typeName && typeName.toLowerCase() !== 'locationonly') {
tooltipLines.push(`<span style="color: #888;">${typeName}</span>`)
}
// Add vendor and model if available
if (machine.vendor) {
tooltipLines.push(`<span style="color: #aaa;">${machine.vendor}${machine.model ? ' ' + machine.model : ''}</span>`)
} else if (machine.model) {
tooltipLines.push(`<span style="color: #aaa;">${machine.model}</span>`)
}
// Category-specific info
if (category === 'printer') {
// Printers: show IP and hostname
if (machine.ipaddress) {
tooltipLines.push(`<span style="color: #8cf;">IP: ${machine.ipaddress}</span>`)
if (props.assetTypeMode) {
// Asset mode tooltips
const assetTypeLabel = {
'equipment': 'Equipment',
'computer': 'Computer',
'printer': 'Printer',
'network_device': 'Network Device'
}
if (machine.hostname) {
tooltipLines.push(`<span style="color: #8cf;">${machine.hostname}</span>`)
tooltipLines.push(`<span style="color: #888;">${assetTypeLabel[item.assettype] || item.assettype}</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 {
// Equipment/PC: show connected PC if available
if (machine.connected_pc) {
tooltipLines.push(`<span style="color: #fc8;">PC: ${machine.connected_pc}</span>`)
// Legacy machine mode tooltips
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>`)
}
}
}
// Business unit
if (machine.businessunit) {
tooltipLines.push(`<span style="color: #ccc;">${machine.businessunit}</span>`)
if (item.businessunit) {
tooltipLines.push(`<span style="color: #ccc;">${item.businessunit}</span>`)
}
const tooltipContent = tooltipLines.join('<br>')
@@ -332,36 +367,76 @@ function renderMarkers() {
})
// Click popup (detailed info)
const popupContent = `
<div class="marker-popup">
<strong>${displayName}</strong>
<div class="popup-details">
<div><span class="label">Number:</span> ${machine.machinenumber || '-'}</div>
<div><span class="label">Type:</span> ${typeName || '-'}</div>
<div><span class="label">Category:</span> ${machine.category || '-'}</div>
<div><span class="label">Status:</span> ${machine.status || '-'}</div>
<div><span class="label">Vendor:</span> ${machine.vendor || '-'}</div>
<div><span class="label">Model:</span> ${machine.model || '-'}</div>
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>
<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', machine))
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,
searchData: `${machine.machinenumber} ${machine.alias} ${typeName} ${machine.vendor} ${machine.model} ${machine.serialnumber} ${machine.businessunit}`.toLowerCase()
machine: item,
searchData
})
})
applyFilters()
}
// Get detail route for unified asset format
function getAssetDetailRoute(asset) {
const routeMap = {
'equipment': '/machines',
'computer': '/pcs',
'printer': '/printers',
'network_device': '/network'
}
const basePath = routeMap[asset.assettype] || '/machines'
if (asset.assettype === 'network_device' && asset.typedata?.networkdeviceid) {
return `/network/${asset.typedata.networkdeviceid}`
}
const id = asset.typedata?.machineid || asset.assetid
return `${basePath}/${id}`
}
function applyFilters() {
const searchTerm = filters.value.search.toLowerCase()
@@ -445,15 +520,30 @@ onUnmounted(() => {
padding: 0.625rem 1rem;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 1.125rem;
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);
}