Files
shopdb-flask/plugins/computers/frontend/views/PCForm.vue
cproudlock 0dc0ac13c8 Assign printers to a machine, and let the PC that drives it inherit them
Printers belong to the bay, not to the box currently driving it. The assignment
goes on the MACHINE asset and reaches whichever PC controls it, so a reimaged or
swapped PC comes back with the right printers and nothing had to be saved off the
old one. The asset register is the backup.

New relationship type usesprinter ("this printer is installed here"), beside the
existing defaultprinter ("which of them is the default"), both seeded and both
given a propagation rail through controls. The rails are consumed at READ time
only: the create-time fan-out skips directional through-types, and controls is
directional, so assigning a printer to a machine does not copy rows onto its PC.
That is what keeps own-beats-inherited possible.

Resolution for a PC is its OWN rows if it has any, otherwise one hop out along
controls to the machines it drives. Whole set at a time, not merged: a PC with
its own assignment is overriding the bay deliberately, and the UI has to say so
or a tech "fixing" a bay by editing the PC will shadow the machine's record and
wonder why they keep disagreeing.

GET /api/printers/for-host/<hostname> is what the convergence client asks every
cycle. Resolved by hostname because the collector upserts PCs by hostname and an
office PC has no machine number. An unknown host, a site without the computers
plugin, and nothing assigned all return an empty set - that is the client's
designed no-op and it must stay indistinguishable from "assigned nothing".

PUT /api/printers/assignments/for-asset/<id> reconciles the whole set in one
call. The endpoint was specified, documented and asserted by three tests, and
never written - the verification pass caught that, with four failures. It
validates the default BEFORE any write, so a rejected request changes nothing;
soft-deletes rows that went away; and REACTIVATES soft-deleted rows rather than
inserting, because the unique constraint spans inactive rows and a blind insert
after an unassign raises IntegrityError on MySQL while passing on SQLite.

One default per asset, enforced here because the schema cannot: the constraint is
(source, target, type), which accepts two different defaults quite happily. Two
active defaults are still reachable through the generic relationships endpoint,
where the oldest silently wins - recorded in the proposal as the next thing to
close.

printerdrivers gains drivername: the exact string the INF declares, which
Add-PrinterDriver matches on and nothing else. Deriving it by parsing INFs on
hundreds of bays is fragile; a human confirming it once is not.
2026-08-19 09:33:22 -04:00

933 lines
32 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit PC' : 'New PC' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="savePC">
<div class="form-row">
<div class="form-group">
<label for="machinenumber">PC Number *</label>
<input
id="machinenumber"
v-model="form.machinenumber"
type="text"
class="form-control"
required
@input="onPcNumberInput"
/>
<small class="form-hint">Defaults to the serial number; editable</small>
</div>
<div class="form-group">
<label for="alias">Alias</label>
<input
id="alias"
v-model="form.alias"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group" v-if="isEnabled('fqdn', 'computer')">
<label for="hostname">Hostname</label>
<input
id="hostname"
v-model="form.hostname"
type="text"
class="form-control"
/>
</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', 'computer') || isEnabled('maintenancereference', 'computer')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'computer')">
<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', 'computer')">
<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">PC 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 pcTypes"
:key="pt.computertypeid"
:value="pt.computertypeid"
>
{{ pt.computertype }}
</option>
</select>
</div>
<div class="form-group">
<label for="osid">Operating System</label>
<select
id="osid"
v-model="form.osid"
class="form-control"
>
<option value="">Select OS...</option>
<option
v-for="os in operatingsystems"
:key="os.osid"
:value="os.osid"
>
{{ os.osname }}
</option>
</select>
</div>
</div>
<div class="form-row">
<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 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.locationname }}
</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"
>
<option value="">Select model...</option>
<option
v-for="m in filteredModels"
:key="m.modelnumberid"
:value="m.modelnumberid"
>
{{ m.modelnumber }}
</option>
</select>
<small v-if="!form.vendorid && !form.machinetypeid" class="form-hint">
Select vendor or PC type to filter models
</small>
</div>
</div>
<!-- PC-specific fields -->
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Network 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="loggedinuser">Logged In User</label>
<input
id="loggedinuser"
v-model="form.loggedinuser"
type="text"
class="form-control"
/>
</div>
</div>
<div class="form-group">
<label>Remote Access Protocols</label>
<div class="protocol-list">
<label v-for="p in protocols" :key="p.protocolid" class="protocol-item">
<input type="checkbox" :checked="isProtocolOn(p.protocolid)" @change="toggleProtocol(p.protocolid, $event.target.checked)" />
<span>{{ p.name }}</span>
<input
v-if="isProtocolOn(p.protocolid)"
type="number"
class="port-override"
:value="protocolPort(p.protocolid)"
:placeholder="p.defaultport || 'port'"
min="1"
max="65535"
title="Port override (blank = default)"
@input="setProtocolPort(p.protocolid, $event.target.value)"
/>
</label>
<span v-if="!protocols.length" class="muted">No protocols defined. Add them under Settings &gt; PC Access Protocols.</span>
</div>
</div>
<!-- Printer assignment. Written as plain asset relationships, so this
section is absent at a site without the printers plugin. -->
<template v-if="printersEnabled">
<h4 style="margin-top: 1.5rem; margin-bottom: 1rem;">Printers</h4>
<div class="form-group">
<label for="printersearch">Assigned Printers</label>
<input
id="printersearch"
v-model="printerSearch"
type="text"
class="form-control"
placeholder="Filter printers..."
/>
<div class="printer-list">
<label
v-for="printerAsset in filteredPrinters"
:key="printerAsset.assetid"
class="printer-item"
>
<input
type="checkbox"
:checked="isPrinterAssigned(printerAsset.assetid)"
@change="togglePrinter(printerAsset.assetid, $event.target.checked)"
/>
<span>{{ printerLabel(printerAsset) }}</span>
<span v-if="printerAsset.printer?.modelname" class="printer-meta">
{{ printerAsset.printer.modelname }}
</span>
</label>
<span v-if="!filteredPrinters.length" class="muted">No printers match.</span>
</div>
<small class="form-hint">
{{ assignedPrinters.length }} assigned. Printers ticked here belong to this PC
and take the place of any assigned to the machine it controls.
</small>
</div>
<div class="form-group">
<label for="defaultprinterassetid">Default Printer</label>
<select
id="defaultprinterassetid"
v-model="defaultPrinterAssetId"
class="form-control"
>
<option :value="null">No default</option>
<option
v-for="printerAsset in assignedPrinters"
:key="printerAsset.assetid"
:value="printerAsset.assetid"
>
{{ printerLabel(printerAsset) }}
</option>
</select>
<small class="form-hint">
Optional, and only ever one of the printers assigned above.
</small>
</div>
</template>
<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 }}
<span v-if="form.levelid" class="position-level">on {{ levelName(form.levelid) }}</span>
<span v-else class="position-level position-level-missing">level not set</span>
<button type="button" class="btn btn-sm btn-secondary" @click="clearMapPosition">Clear</button>
</div>
<button type="button" class="btn btn-secondary" @click="openMapPicker">
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">
<div v-if="levelOptions().length > 1" class="map-level-picker">
<label>Level</label>
<select v-model.number="pickerLevelId" class="form-control">
<option v-for="option in levelOptions()" :key="option.levelid"
:value="option.levelid">{{ option.label }}</option>
</select>
<span class="input-hint">
The position is pixels on this drawing, so pick the level first.
</span>
</div>
<ShopFloorMap
:pickerMode="true"
:levelid="pickerLevelId"
: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>
<!-- Site-defined custom fields for computers -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="COMPUTER_ASSETTYPEID" :assetid="currentAssetId" />
<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 PC' }}
</button>
<router-link to="/pcs" 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 { computersApi, assetsApi, vendorsApi, locationsApi, modelsApi, operatingsystemsApi,
printersApi, relationshipTypesApi } from '@/api'
import ShopFloorMap from '@/components/ShopFloorMap.vue'
import { loadMapConfig, levelOptions, levelName, state as mapConfig }
from '@/composables/mapConfig'
import Modal from '@/components/Modal.vue'
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { isPluginEnabled, loadEnabledPlugins } from '@/composables/enabledPlugins'
import { useToast } from '@/composables/toast'
import { apiError } from '@/utils/apiError'
const toast = useToast()
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for computers (see /api/assets/types). Custom-field
// values are keyed by the underlying asset id, captured on load / create.
const COMPUTER_ASSETTYPEID = 2
const customFieldsRef = ref(null)
const currentAssetId = ref(null)
// PC Number (assetnumber) defaults to the serial number while the user hasn't
// typed their own. Editable; only auto-fills on a new PC.
const manualPcNumber = ref(false)
function onPcNumberInput() {
manualPcNumber.value = true
}
const loading = ref(true)
const saving = ref(false)
const error = ref('')
const showMapPicker = ref(false)
// Which drawing the picker shows, and therefore which level the coordinates it
// returns belong to (ADR-017). Opens on the position's existing level so editing
// a marker does not silently move it to the default one.
const pickerLevelId = ref(null)
const tempMapPosition = ref(null)
const form = ref({
machinenumber: '',
alias: '',
hostname: '',
serialnumber: '',
gaugelabreference: '',
maintenancereference: '',
machinetypeid: '',
statusid: '',
vendorid: '',
modelnumberid: '',
locationid: '',
osid: '',
loggedinuser: '',
accessmethods: [],
notes: '',
mapx: null,
mapy: null,
levelid: null,
ipaddress: ''
})
const pcTypes = ref([])
const protocols = ref([])
const statuses = ref([])
// Access-method editor helpers (form.accessmethods = [{protocolid, portoverride}])
function isProtocolOn(protocolid) {
return form.value.accessmethods.some(a => a.protocolid === protocolid)
}
function protocolPort(protocolid) {
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
return found && found.portoverride != null ? found.portoverride : ''
}
function toggleProtocol(protocolid, on) {
if (on) {
if (!isProtocolOn(protocolid)) {
form.value.accessmethods.push({ protocolid, portoverride: null })
}
} else {
form.value.accessmethods = form.value.accessmethods.filter(a => a.protocolid !== protocolid)
}
}
function setProtocolPort(protocolid, value) {
const found = form.value.accessmethods.find(a => a.protocolid === protocolid)
if (found) {
const n = parseInt(value, 10)
found.portoverride = Number.isFinite(n) ? n : null
}
}
const vendors = ref([])
const models = ref([])
const locations = ref([])
const operatingsystems = ref([])
// Printer assignment. The rows are ordinary asset relationships - usesprinter
// for "installed here", defaultprinter for which of them wins - so the picker
// reads and writes them through the generic relationship endpoints.
const printersEnabled = ref(false)
const printers = ref([])
const printerSearch = ref('')
const assignedPrinterAssetIds = ref([])
const defaultPrinterAssetId = ref(null)
const printerRelationshipTypes = ref({ usesprinter: null, defaultprinter: null })
// The rows as loaded, so saving writes only what actually changed.
const existingPrinterRelationships = ref([])
function printerLabel(printerAsset) {
const name = printerAsset.name && printerAsset.name.toUpperCase() !== 'NONE'
? printerAsset.name
: null
return name || printerAsset.printer?.hostname || printerAsset.assetnumber
|| `Asset ${printerAsset.assetid}`
}
const sortedPrinters = computed(() =>
[...printers.value].sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
const filteredPrinters = computed(() => {
const term = printerSearch.value.trim().toLowerCase()
if (!term) return sortedPrinters.value
return sortedPrinters.value.filter(printerAsset => {
const haystack = [
printerLabel(printerAsset),
printerAsset.assetnumber || '',
printerAsset.printer?.modelname || ''
].join(' ').toLowerCase()
return haystack.includes(term)
})
})
// Drives the default dropdown, so the default can only ever be one of the
// assigned printers. A printer filtered out of the list above is still here.
const assignedPrinters = computed(() =>
assignedPrinterAssetIds.value
.map(assetid => printers.value.find(printerAsset => printerAsset.assetid === assetid))
.filter(Boolean)
.sort((a, b) => printerLabel(a).localeCompare(printerLabel(b))))
function isPrinterAssigned(assetid) {
return assignedPrinterAssetIds.value.includes(assetid)
}
function togglePrinter(assetid, on) {
if (on) {
if (!isPrinterAssigned(assetid)) {
assignedPrinterAssetIds.value = [...assignedPrinterAssetIds.value, assetid]
}
} else {
assignedPrinterAssetIds.value = assignedPrinterAssetIds.value.filter(id => id !== assetid)
}
}
// Unassigning the printer that is currently default clears the default rather
// than leaving one pointing at a printer this PC no longer has.
watch(assignedPrinterAssetIds, (assetids) => {
if (defaultPrinterAssetId.value && !assetids.includes(defaultPrinterAssetId.value)) {
defaultPrinterAssetId.value = null
}
})
// Printer list + the two relationship type ids. Both types are seed data
// (flask seed reference-data); without them there is nothing to write, so the
// section stays hidden rather than offering a control that cannot save.
async function loadPrinterOptions() {
try {
await loadEnabledPlugins()
if (!isPluginEnabled('printers')) return
const [printerRows, typeResponse] = await Promise.all([
printersApi.listAll(),
relationshipTypesApi.list()
])
const types = typeResponse.data.data || []
const typeIdFor = (name) =>
types.find(t => t.relationshiptype === name)?.relationshiptypeid || null
printerRelationshipTypes.value = {
usesprinter: typeIdFor('usesprinter'),
defaultprinter: typeIdFor('defaultprinter')
}
printers.value = printerRows || []
printersEnabled.value = !!(printerRelationshipTypes.value.usesprinter
&& printerRelationshipTypes.value.defaultprinter)
} catch (printerError) {
console.error('Error loading printers:', printerError)
printersEnabled.value = false
}
}
async function loadPrinterAssignments(assetid) {
if (!printersEnabled.value || !assetid) return
try {
const response = await assetsApi.getRelationships(assetid)
const types = printerRelationshipTypes.value
existingPrinterRelationships.value = (response.data.data?.outgoing || []).filter(
rel => rel.relationshiptypeid === types.usesprinter
|| rel.relationshiptypeid === types.defaultprinter
)
assignedPrinterAssetIds.value = existingPrinterRelationships.value
.filter(rel => rel.relationshiptypeid === types.usesprinter)
.map(rel => rel.targetassetid)
const currentDefault = existingPrinterRelationships.value
.find(rel => rel.relationshiptypeid === types.defaultprinter)
defaultPrinterAssetId.value = currentDefault ? currentDefault.targetassetid : null
// Defaults set before this form existed have no usesprinter row. Show that
// printer as assigned: a default missing from the list reads as data loss,
// and saving then writes the row that was never there.
if (defaultPrinterAssetId.value
&& !assignedPrinterAssetIds.value.includes(defaultPrinterAssetId.value)) {
assignedPrinterAssetIds.value = [
...assignedPrinterAssetIds.value, defaultPrinterAssetId.value
]
}
// /printers lists active printers only, so a retired one that is still
// assigned would be missing from every control on this form - unable to be
// unticked, and blank in the default box. The relationship carries the
// asset, so add it to the list it fell out of.
for (const rel of existingPrinterRelationships.value) {
const target = rel.targetasset
if (target && !printers.value.some(known => known.assetid === target.assetid)) {
printers.value = [...printers.value, target]
}
}
} catch (printerError) {
console.error('Error loading printer assignment:', printerError)
}
}
// Reconcile the PC's own printer rows against the picker. Row at a time
// through the generic relationship endpoints - there is no single assignment
// endpoint yet - so the order matters: the outgoing default goes before the
// incoming one lands, because the unique key is (source, target, type) and
// would let two different defaults sit side by side. Re-creating a row that
// was removed earlier is safe; the create path reactivates the soft-deleted
// one instead of inserting a duplicate.
async function savePrinterAssignments(assetid) {
const types = printerRelationshipTypes.value
const assigned = assignedPrinterAssetIds.value
const wanteddefault = defaultPrinterAssetId.value
const assignedRows = existingPrinterRelationships.value
.filter(rel => rel.relationshiptypeid === types.usesprinter)
const defaultRows = existingPrinterRelationships.value
.filter(rel => rel.relationshiptypeid === types.defaultprinter)
try {
// Removing an assignment removes the row and nothing else. It never
// uninstalls a queue anywhere.
for (const rel of assignedRows) {
if (!assigned.includes(rel.targetassetid)) {
await assetsApi.deleteRelationship(rel.relationshipid)
}
}
for (const printerassetid of assigned) {
if (!assignedRows.some(rel => rel.targetassetid === printerassetid)) {
await assetsApi.createRelationship({
sourceassetid: assetid,
targetassetid: printerassetid,
relationshiptypeid: types.usesprinter
})
}
}
for (const rel of defaultRows) {
if (rel.targetassetid !== wanteddefault) {
await assetsApi.deleteRelationship(rel.relationshipid)
}
}
if (wanteddefault && !defaultRows.some(rel => rel.targetassetid === wanteddefault)) {
await assetsApi.createRelationship({
sourceassetid: assetid,
targetassetid: wanteddefault,
relationshiptypeid: types.defaultprinter
})
}
} finally {
// Part of the reconcile may have landed, so what the form believes is
// stored has to come from the server before anyone saves again.
await loadPrinterAssignments(assetid)
}
}
// Default PC Number to serial while the user hasn't typed their own (new PC only)
watch(() => form.value.serialnumber, (serial) => {
if (!isEdit.value && !manualPcNumber.value && serial) {
form.value.machinenumber = serial
}
})
// Filter models by selected vendor and PC type
const filteredModels = computed(() => {
// filter by vendor only (PC type now maps to computertypeid, a different id
// space than a model's machinetypeid)
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
})
onMounted(async () => {
try {
// Load reference data
// perpage 100 so dropdowns aren't truncated to the default 20-row page
const [ptRes, statusRes, vendorRes, allModels, locRes, osRes, protoRes] = await Promise.all([
computersApi.types.list({ perpage: 100 }),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 100 }),
modelsApi.listAll(), // backend caps perpage at 100; page through all
locationsApi.list({ perpage: 100 }),
operatingsystemsApi.list({ perpage: 100 }),
computersApi.protocols.list(),
// Handles its own failure: a site without printers still gets a form.
loadPrinterOptions()
])
pcTypes.value = ptRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
models.value = allModels
locations.value = locRes.data.data || []
operatingsystems.value = osRes.data.data || []
protocols.value = protoRes.data.data || []
// Load PC if editing (asset-based shape: extension under pc.computer)
if (isEdit.value) {
const response = await computersApi.get(route.params.id)
const pc = response.data.data
const ext = pc.computer || {}
currentAssetId.value = pc.assetid || null
const primaryComm = pc.communications?.find(c => c.isprimary) || pc.communications?.[0]
form.value = {
machinenumber: pc.assetnumber || '',
alias: pc.name && pc.name.toUpperCase() !== 'NONE' ? pc.name : '',
hostname: ext.hostname || '',
serialnumber: pc.serialnumber || '',
gaugelabreference: pc.gaugelabreference || '',
maintenancereference: pc.maintenancereference || '',
machinetypeid: ext.computertypeid || '',
statusid: pc.statusid || '',
vendorid: ext.vendorid || '',
modelnumberid: ext.modelnumberid || '',
locationid: pc.locationid || '',
osid: ext.osid || '',
loggedinuser: ext.loggedinuser || '',
accessmethods: (pc.accessmethods || []).map(a => ({
protocolid: a.protocolid,
portoverride: a.portoverride ?? null
})),
notes: pc.notes || '',
mapx: pc.mapx ?? null,
mapy: pc.mapy ?? null,
levelid: pc.levelid ?? null,
ipaddress: primaryComm?.ipaddress || ''
}
await loadPrinterAssignments(currentAssetId.value)
}
} 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 openMapPicker() {
loadMapConfig().then(() => {
pickerLevelId.value = form.value.levelid || mapConfig.defaultlevelid
showMapPicker.value = true
})
}
function confirmMapPosition() {
if (tempMapPosition.value) {
form.value.mapx = tempMapPosition.value.left
form.value.mapy = tempMapPosition.value.top
// Never one without the other: coordinates saved with no level render as
// "level unknown", and coordinates saved against the wrong level render
// convincingly in the wrong place.
form.value.levelid = pickerLevelId.value
}
showMapPicker.value = false
}
function clearMapPosition() {
form.value.mapx = null
form.value.mapy = null
form.value.levelid = null
tempMapPosition.value = null
}
async function savePC() {
error.value = ''
saving.value = true
try {
// One payload for the computers plugin (asset core + computer extension +
// primary IP). "PC Number" is the business identifier (assetnumber).
const payload = {
assetnumber: form.value.machinenumber,
hostname: form.value.hostname || null,
serialnumber: form.value.serialnumber || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
computertypeid: form.value.machinetypeid || null,
statusid: form.value.statusid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
locationid: form.value.locationid || null,
osid: form.value.osid || null,
loggedinuser: form.value.loggedinuser || null,
accessmethods: form.value.accessmethods,
notes: form.value.notes || null,
ipaddress: form.value.ipaddress || null,
mapx: form.value.mapx,
mapy: form.value.mapy
}
// only set display name when an alias is given, so we don't clobber it
if (form.value.alias) {
payload.name = form.value.alias
}
let assetId = currentAssetId.value
if (isEdit.value) {
const response = await computersApi.update(route.params.id, payload)
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
} else {
const response = await computersApi.create(payload)
assetId = response.data?.data?.assetid || response.data?.data?.asset?.assetid
}
// Persist any custom-field values now that we have an asset id.
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
// Toasted, not thrown: the PC itself is saved by now, so staying on a form
// whose Save would create a second PC is the worse failure - but a printer
// assignment that quietly did not happen is the bug this feature exists to
// stop, so it has to be said out loud.
if (assetId && printersEnabled.value) {
try {
await savePrinterAssignments(assetId)
} catch (printerError) {
console.error('Error saving printer assignment:', printerError)
toast.error(apiError(printerError, 'PC saved, but the printer assignment did not'))
}
}
router.push('/pcs')
} catch (err) {
console.error('Error saving PC:', err)
error.value = apiError(err, 'Failed to save PC')
} finally {
saving.value = false
}
}
</script>
<style scoped>
.protocol-list {
display: flex;
flex-wrap: wrap;
gap: 14px;
}
.protocol-item {
display: flex;
align-items: center;
gap: 6px;
}
.protocol-item .port-override {
width: 78px;
padding: 4px 6px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.muted {
color: var(--text-light);
}
/* Scrolls rather than pushing the rest of the form off screen: a site can hold
dozens of printers. */
.printer-list {
display: flex;
flex-direction: column;
gap: 6px;
max-height: 220px;
overflow-y: auto;
margin-top: 0.5rem;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
}
.printer-item {
display: flex;
align-items: center;
gap: 8px;
}
.printer-meta {
color: var(--text-light);
font-size: 0.85rem;
}
.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);
}
</style>