Files
shopdb-flask/plugins/machines/frontend/views/MachineForm.vue

918 lines
32 KiB
Vue

<template>
<div>
<div class="page-header">
<h2>{{ isEdit ? 'Edit Machine' : 'New Machine' }}</h2>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<!-- The record failed to load. Show the reason INSTEAD of the form: an
empty edit form is indistinguishable from a record whose fields are
genuinely empty, and saving it would write the blanks back. -->
<div v-else-if="loadFailed" class="error-message">{{ error }}</div>
<form v-else @submit.prevent="saveMachine">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
<div class="form-row">
<div class="form-group">
<label for="assetnumber">Asset Number *</label>
<input
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
/>
</div>
<div class="form-group">
<label for="name">Name / Alias</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
<small class="form-help">Layperson-friendly label</small>
</div>
</div>
<div class="form-row" v-if="isEnabled('gaugelabreference', 'machine') || isEnabled('maintenancereference', 'machine')">
<div class="form-group" v-if="isEnabled('gaugelabreference', 'machine')">
<label for="gaugelabreference">Gauge Lab Reference</label>
<input
id="gaugelabreference"
v-model="form.gaugelabreference"
type="text"
class="form-control"
/>
<small class="form-help">Authoritative gauge lab asset reference (if tracked)</small>
</div>
<div class="form-group" v-if="isEnabled('maintenancereference', 'machine')">
<label for="maintenancereference">Maintenance Reference</label>
<input
id="maintenancereference"
v-model="form.maintenancereference"
type="text"
class="form-control"
/>
<small class="form-help">Maintenance system asset reference (if tracked)</small>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="serialnumber">Serial Number</label>
<input
id="serialnumber"
v-model="form.serialnumber"
type="text"
class="form-control"
/>
</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>
<!-- Machine Hardware Section -->
<h3 class="form-section-title">Machine Hardware</h3>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Machine Type</label>
<select
id="machinetypeid"
v-model="form.machinetypeid"
class="form-control"
>
<option value="">Select type...</option>
<option
v-for="mt in machineTypes"
:key="mt.machinetypeid"
:value="mt.machinetypeid"
>
{{ mt.machinetype }}
</option>
</select>
</div>
<div class="form-group">
<label for="vendorid">Vendor</label>
<select
id="vendorid"
v-model="form.vendorid"
class="form-control"
>
<option value="">Select vendor...</option>
<option
v-for="v in filteredVendors"
:key="v.vendorid"
:value="v.vendorid"
>
{{ v.vendor }}
</option>
</select>
</div>
</div>
<div class="form-row">
<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 class="form-help" v-if="form.vendorid">Filtered by selected vendor</small>
</div>
<div class="form-group"></div>
</div>
<!-- Controller Section (for CNC machines) -->
<h3 class="form-section-title">Controller</h3>
<div class="form-row">
<div class="form-group">
<label for="controllervendorid">Controller Vendor</label>
<select
id="controllervendorid"
v-model="form.controllervendorid"
class="form-control"
>
<option value="">Select vendor...</option>
<option
v-for="v in filteredControllerVendors"
:key="v.vendorid"
:value="v.vendorid"
>
{{ v.vendor }}
</option>
</select>
<small class="form-help">e.g., Fanuc, Siemens, Allen-Bradley</small>
</div>
<div class="form-group">
<label for="controllermodelid">Controller Model</label>
<select
id="controllermodelid"
v-model="form.controllermodelid"
class="form-control"
>
<option value="">Select model...</option>
<option
v-for="m in filteredControllerModels"
:key="m.modelnumberid"
:value="m.modelnumberid"
>
{{ m.modelnumber }}
</option>
</select>
<small class="form-help" v-if="form.controllervendorid">Filtered by controller vendor</small>
</div>
</div>
<!-- Location Section -->
<h3 class="form-section-title">Location & Organization</h3>
<div class="form-row">
<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 class="form-group">
<label for="businessunitid">Business Unit</label>
<select
id="businessunitid"
v-model="form.businessunitid"
class="form-control"
>
<option value="">Select business unit...</option>
<option
v-for="bu in businessunits"
:key="bu.businessunitid"
:value="bu.businessunitid"
>
{{ bu.businessunit }}
</option>
</select>
</div>
</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>
<!-- Configuration Section -->
<h3 class="form-section-title">Configuration</h3>
<div class="form-row">
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.requiresmanualconfig" />
Requires Manual Config
</label>
<small class="form-help">Machine a tech must configure by hand (e.g. driven by multiple PCs)</small>
</div>
</div>
<!-- Controlling PC Selection -->
<h3 class="form-section-title">Connected PC</h3>
<div class="form-row">
<div class="form-group">
<label for="controllingpc">Controlling PC</label>
<select
id="controllingpc"
v-model="controllingPcId"
class="form-control"
>
<option :value="null">None (standalone)</option>
<option
v-for="pc in pcs"
:key="pc.assetid"
:value="pc.assetid"
>
{{ pc.assetnumber }}{{ pc.name ? ` (${pc.name})` : '' }}
</option>
</select>
<small class="form-help">Select the PC that controls this machine</small>
</div>
<div class="form-group" v-if="controllingPcId">
<label for="connectiontype">Connection Type</label>
<select
id="connectiontype"
v-model="relationshipTypeId"
class="form-control"
>
<option
v-for="rt in relationshipTypes"
:key="rt.relationshiptypeid"
:value="rt.relationshiptypeid"
>
{{ rt.relationshiptype }}
</option>
</select>
<small class="form-help">How the PC connects to this machine</small>
</div>
</div>
<!-- Notes Section -->
<h3 class="form-section-title">Notes</h3>
<!-- Maintenance. Shown on the detail page since it was added but never
editable, so a wrong date needed database access to correct. These
are the columns a measuring instrument's calibration dates map onto
when metrology moves to machines, which is the other reason they
have to be enterable. -->
<div class="form-group">
<label for="lastmaintenancedate">Last Maintenance</label>
<input
id="lastmaintenancedate"
v-model="form.lastmaintenancedate"
type="date"
class="form-control"
/>
</div>
<div class="form-group">
<label for="nextmaintenancedate">Next Maintenance</label>
<input
id="nextmaintenancedate"
v-model="form.nextmaintenancedate"
type="date"
class="form-control"
/>
<small class="muted">Set directly; it is not derived from the interval.</small>
</div>
<div class="form-group">
<label for="maintenanceintervaldays">Maintenance Interval (days)</label>
<input
id="maintenanceintervaldays"
v-model.number="form.maintenanceintervaldays"
type="number"
min="1"
class="form-control"
placeholder="e.g. 180"
/>
</div>
<div class="form-group">
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
placeholder="Additional notes..."
></textarea>
</div>
<!-- Site-defined custom fields for machines -->
<CustomFieldsInputs ref="customFieldsRef" :assettypeid="MACHINE_ASSETTYPEID" :assetid="currentAssetId" />
<!-- Printers belong to the MACHINE, so whichever PC controls it installs
them - including a replacement after a reimage. -->
<PrinterAssignmentPicker ref="printerPickerRef" :assetid="currentAssetId" scope="machine" />
<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 Machine' }}
</button>
<router-link to="/machines" 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 { machinesApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi, 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 PrinterAssignmentPicker from '@/components/PrinterAssignmentPicker.vue'
import { currentTheme } from '@/stores/theme'
import { useIdentifierFlags } from '@/composables/identifierSettings'
import { apiError } from '@/utils/apiError'
import { planControllingPcChange } from './controllingPc'
const { isEnabled } = useIdentifierFlags()
const route = useRoute()
const router = useRouter()
const isEdit = computed(() => !!route.params.id)
// Seeded asset-type id for machines (see /api/assets/types).
const MACHINE_ASSETTYPEID = 1
const customFieldsRef = ref(null)
const printerPickerRef = ref(null)
const currentAssetId = ref(null)
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({
assetnumber: '',
name: '',
gaugelabreference: '',
maintenancereference: '',
serialnumber: '',
statusid: 1,
machinetypeid: '',
vendorid: '',
modelnumberid: '',
controllervendorid: '',
controllermodelid: '',
locationid: '',
businessunitid: '',
requiresmanualconfig: false,
islocationonly: false,
lastmaintenancedate: '',
nextmaintenancedate: '',
maintenanceintervaldays: null,
notes: '',
mapx: null,
mapy: null,
levelid: null
})
const machineTypes = ref([])
const statuses = ref([])
const vendors = ref([])
const locations = ref([])
const models = ref([])
// Set when the record itself could not be loaded, as opposed to a reference
// list failing. The form is withheld entirely, because an empty edit form is
// indistinguishable from a record whose fields are genuinely empty.
const loadFailed = ref(false)
const businessunits = ref([])
const pcs = ref([])
const relationshipTypes = ref([])
const controllingPcId = ref(null)
const relationshipTypeId = ref(null)
const existingRelationshipId = ref(null)
// Which PC the link named when the form loaded, so an unchanged one can be left
// alone. Editing a machine is mostly editing something else about it.
const loadedControllingPcId = ref(null)
const currentMachine = ref(null)
// Ensure a saved model id is present in models.value even when the filtered
// list (category Equipment) excluded it because it has no modeltype. Fetches
// the single row by id and appends it; a no-op when already present or blank.
async function ensureModelLoaded(modelId) {
if (!modelId) return
if (models.value.some(m => m.modelnumberid === modelId)) return
try {
const response = await modelsApi.get(modelId)
const model = response.data.data
if (model) models.value = [...models.value, model]
} catch (e) {
// Leave it absent; the select shows blank but we did not invent a value.
}
}
// Filter models by selected machine vendor.
// only in the controller dropdown, so keep them out of the machine list. Keep
// the record's own saved model even if it is untyped or a different vendor, so
// editing a machine never renders a blank select that clears the field on save.
const filteredModels = computed(() => {
const savedId = form.value.modelnumberid
const machineModels = models.value.filter(
m => m.modeltype !== 'Controller' || m.modelnumberid === savedId)
if (!form.value.vendorid) return machineModels
return machineModels.filter(
m => m.vendorid === form.value.vendorid || m.modelnumberid === savedId)
})
// Filter models by selected controller vendor. Only Controller-type models are
// valid here. Keep the record's own saved controller model even if it is untyped
// or a different vendor, for the same reason as filteredModels above.
const filteredControllerModels = computed(() => {
const savedId = form.value.controllermodelid
const controllerModels = models.value.filter(
m => m.modeltype === 'Controller' || m.modelnumberid === savedId)
if (!form.value.controllervendorid) return controllerModels
return controllerModels.filter(
m => m.vendorid === form.value.controllervendorid || m.modelnumberid === savedId)
})
// Only offer vendors that have machine (non-controller Equipment) models. Keep
// the record's own vendor even if it falls outside that set, so editing an
// existing machine never silently drops its saved vendor.
const filteredVendors = computed(() => {
const machineVendorIds = new Set(
models.value.filter(m => m.modeltype !== 'Controller').map(m => m.vendorid)
)
return vendors.value.filter(
v => machineVendorIds.has(v.vendorid) || v.vendorid === form.value.vendorid
)
})
// Only offer controller vendors that have Controller-type models. Keep the
// record's own controller vendor even if it falls outside that set.
const filteredControllerVendors = computed(() => {
const controllerVendorIds = new Set(
models.value.filter(m => m.modeltype === 'Controller').map(m => m.vendorid)
)
return vendors.value.filter(
v => controllerVendorIds.has(v.vendorid) || v.vendorid === form.value.controllervendorid
)
})
// Clear model selection when vendor changes
watch(() => form.value.vendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.modelnumberid)
if (currentModel && currentModel.vendorid !== newVal) {
form.value.modelnumberid = ''
}
}
})
// Clear controller model when controller vendor changes
watch(() => form.value.controllervendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.controllermodelid)
if (currentModel && currentModel.vendorid !== newVal) {
form.value.controllermodelid = ''
}
}
})
onMounted(async () => {
try {
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, allModels, buRes, pcsRes, relTypesRes] = await Promise.all([
machinesApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ perpage: 500 }),
locationsApi.listAll(), // backend caps perpage at 100; page through all
modelsApi.listAll({ category: 'Equipment' }), // backend caps perpage at 100; page through all
businessunitsApi.list({ perpage: 500 }),
computersApi.listAll(), // backend caps perpage at 100; page through all
assetsApi.types.list() // Used for relationship types, will fix below
])
machineTypes.value = typesRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
locations.value = locRes || []
models.value = allModels
businessunits.value = buRes.data.data || []
// listAll resolves to the ARRAY, not a response: fetchAllPages already
// unwrapped every page. Reading .data.data off it threw, and the whole
// parallel load went to the catch - so every dropdown on this form came up
// empty and the machine's own values never loaded.
pcs.value = pcsRes || []
// Load relationship types separately
try {
const relRes = await relationshipTypesApi.list()
relationshipTypes.value = relRes.data.data || []
} catch (e) {
// Fallback - use hardcoded Controls type
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]
}
// Set default relationship type to "Controls" if available (name is data, compare folded)
const controlsType = relationshipTypes.value.find(t => (t.relationshiptype || '').toLowerCase() === 'controls')
if (controlsType) {
relationshipTypeId.value = controlsType.relationshiptypeid
}
// Load machine if editing.
//
// ITS OWN try/catch, deliberately. This used to sit inside the same block
// as the eight reference loads above, so ONE transient failure among them -
// a page of listAll() timing out on a busy collector cycle - rejected the
// whole thing and rendered a fully editable EDIT form with every field
// BLANK, including the required Asset Number, next to a Save button. Typing
// a number and saving then wrote the blank-loaded values over a real
// machine. The reference lists degrade to empty dropdowns; the record must
// not degrade at all.
if (isEdit.value) {
let response
try {
response = await machinesApi.get(route.params.id)
} catch (err) {
console.error('Error loading machine:', err)
error.value = 'Could not load this machine. Nothing has been changed - '
+ 'reload the page rather than saving, or the blank form would '
+ 'overwrite the record.'
loadFailed.value = true
loading.value = false
return
}
const data = response.data.data
currentAssetId.value = data.assetid || null
currentMachine.value = data
form.value = {
assetnumber: data.assetnumber || '',
name: data.name || '',
gaugelabreference: data.gaugelabreference || '',
maintenancereference: data.maintenancereference || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || 1,
machinetypeid: data.machine?.machinetypeid || '',
vendorid: data.machine?.vendorid || '',
modelnumberid: data.machine?.modelnumberid || '',
controllervendorid: data.machine?.controllervendorid || '',
controllermodelid: data.machine?.controllermodelid || '',
locationid: data.locationid || '',
businessunitid: data.businessunitid || '',
requiresmanualconfig: data.machine?.requiresmanualconfig || false,
islocationonly: data.machine?.islocationonly || false,
lastmaintenancedate: (data.machine?.lastmaintenancedate || '').slice(0, 10),
nextmaintenancedate: (data.machine?.nextmaintenancedate || '').slice(0, 10),
maintenanceintervaldays: data.machine?.maintenanceintervaldays ?? null,
notes: data.notes || '',
mapx: data.mapx ?? null,
mapy: data.mapy ?? null,
levelid: data.levelid ?? null,
}
// The model list is loaded filtered to category Equipment, which the
// backend inner-joins against ModelType - so a model with no modeltype is
// absent from models.value. If this machine's saved model or controller
// model is one of those untyped rows, fetch it by id and merge it in, or
// its dropdown would render blank and saving would clear the field.
await ensureModelLoaded(form.value.modelnumberid)
await ensureModelLoaded(form.value.controllermodelid)
// Load existing relationships to find controlling PC
if (data.assetid) {
try {
const relResponse = await assetsApi.getRelationships(data.assetid)
const relationships = relResponse.data.data || { incoming: [], outgoing: [] }
// Check incoming relationships for a controlling PC
for (const rel of relationships.incoming || []) {
if (rel.sourceasset?.assettypename === 'computer' && (rel.relationshiptypename || '').toLowerCase() === 'controls') {
controllingPcId.value = rel.sourceasset.assetid
relationshipTypeId.value = rel.relationshiptypeid
// relationshipid. `assetrelationshipid` is not a key the API has
// ever emitted - to_dict serialises the column names and the
// column is relationshipid - so this read undefined every time,
// and saveRelationship then took the "there is nothing to
// replace" path on a machine that had a controlling PC. Saving
// ANY change, a map position included, re-POSTed the link that
// was already there and the server correctly answered 409 "this
// relationship already exists".
existingRelationshipId.value = rel.relationshipid
loadedControllingPcId.value = rel.sourceasset.assetid
break
}
}
} catch (e) {
}
}
}
} 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 saveMachine() {
error.value = ''
saving.value = true
try {
const data = {
assetnumber: form.value.assetnumber,
name: form.value.name || null,
gaugelabreference: form.value.gaugelabreference || null,
maintenancereference: form.value.maintenancereference || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || 1,
machinetypeid: form.value.machinetypeid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
controllervendorid: form.value.controllervendorid || null,
controllermodelid: form.value.controllermodelid || null,
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
requiresmanualconfig: form.value.requiresmanualconfig,
islocationonly: form.value.islocationonly,
lastmaintenancedate: form.value.lastmaintenancedate || null,
nextmaintenancedate: form.value.nextmaintenancedate || null,
maintenanceintervaldays: form.value.maintenanceintervaldays || null,
notes: form.value.notes || null,
mapx: form.value.mapx,
mapy: form.value.mapy
}
let savedMachine
let assetId
if (isEdit.value) {
const response = await machinesApi.update(route.params.id, data)
savedMachine = response.data.data
assetId = savedMachine.assetid
} else {
const response = await machinesApi.create(data)
savedMachine = response.data.data
assetId = savedMachine.assetid
}
// Handle relationship (controlling PC)
await saveRelationship(assetId)
// Persist custom-field values against the asset id.
if (assetId && customFieldsRef.value) {
try {
await customFieldsRef.value.save(assetId)
} catch (cfErr) {
console.error('Error saving custom fields:', cfErr)
}
}
// Printer assignment, after the asset exists so a new machine can be
// assigned in the same save. A failure here must not lose the machine the
// user just entered, so it is reported and not thrown.
if (assetId && printerPickerRef.value) {
try {
await printerPickerRef.value.save(assetId)
} catch (printerErr) {
console.error('Error saving printer assignment:', printerErr)
}
}
router.push(`/machines/${savedMachine.machine?.machineid || route.params.id}`)
} catch (err) {
console.error('Error saving machine:', err)
error.value = apiError(err, 'Failed to save machine')
} finally {
saving.value = false
}
}
async function saveRelationship(assetId) {
// The decision lives in controllingPc.js, where it is covered by tests.
// See that file for why "is there a link" and "do I know its id" are two
// different questions and what happened when they were treated as one.
const plan = planControllingPcChange({
selected: controllingPcId.value,
loaded: loadedControllingPcId.value,
existingRelationshipId: existingRelationshipId.value,
})
if (plan.action === 'none') {
return
}
if (plan.deleteId !== null) {
await assetsApi.deleteRelationship(plan.deleteId)
existingRelationshipId.value = null
loadedControllingPcId.value = null
}
if (plan.action === 'unlink') {
return
}
// PC controls Machine, so the PC is the source and the machine the target.
const created = await assetsApi.createRelationship({
sourceassetid: controllingPcId.value,
targetassetid: assetId,
relationshiptypeid: relationshipTypeId.value
})
// Keep the form honest about what is now stored, so a second save without a
// reload does not repeat the create it just did.
existingRelationshipId.value = created?.data?.data?.relationshipid ?? null
loadedControllingPcId.value = controllingPcId.value
}
</script>
<style scoped>
.form-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--text);
margin: 1.5rem 0 0.75rem 0;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--border);
}
.form-section-title:first-of-type {
margin-top: 0;
}
.checkbox-group {
display: flex;
flex-direction: column;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.1rem;
height: 1.1rem;
}
.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-radius: 4px;
font-family: monospace;
}
.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-help {
display: block;
margin-top: 0.25rem;
font-size: 0.8rem;
color: var(--text-light);
}
</style>