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:
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`)
|
||||
}
|
||||
Reference in New Issue
Block a user