Files
shopdb-flask/frontend/src/views/printers/PrinterForm.vue
cproudlock 37ffb4add5 Make gauge/maintenance identifiers per-asset-type and extend to all types
Optional asset identifiers (gauge lab reference, maintenance reference, FQDN)
were global per-identifier and only surfaced on equipment. Now they are
toggleable per asset type and rendered on every asset type.

- settings: replace 3 global identifier toggles with a per-type matrix. New
  keys identifier_<name>_<assettype>_enabled (3 identifiers x 4 types).
  IDENTIFIER_LABELS / IDENTIFIER_ASSETTYPES constants drive the seed (API seed
  and CLI seed settings).
- composable: identifierSettings now exposes isEnabled(name, assettype),
  per-type flag winning over the legacy global key, defaulting on.
- backend writes: computers, network, printers asset create + update now
  accept gaugelabreference and maintenancereference (equipment already did).
  Reads already flowed through Asset.to_dict.
- frontend: Settings page renders an identifier x asset-type toggle matrix.
  Equipment, PC, printer, network forms and detail pages show gauge/maintenance
  (and FQDN where applicable) gated by isEnabled(name, type).

Legacy global identifier_<name>_enabled keys are still honored as a fallback
for older installs. SystemSettings toggles upsert (create on 404) so a deploy
that has not re-seeded still works on first toggle.

144 tests pass, naming/style check green, frontend builds. Verified live:
matrix renders, PC form shows the fields, PUT persists gauge/maintenance on a
PC and reads back.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:34:50 -04:00

631 lines
20 KiB
Vue

<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>
<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 { currentTheme } from '../../stores/theme'
import { useIdentifierFlags } from '../../composables/identifierSettings'
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
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
function generateHostname(ip) {
if (!ip) return ''
const ipDashed = ip.replace(/\./g, '-')
return `Printer-${ipDashed}.printer.geaerospace.net`
}
// 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, (newIp) => {
if (!manualHostname.value && newIp) {
form.value.hostname = 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
// 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
}
if (isEdit.value) {
await printersApi.update(route.params.id, payload)
} else {
await printersApi.create(payload)
}
router.push('/printers')
} catch (err) {
console.error('Error saving printer:', err)
error.value = err.response?.data?.message || '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>