Migrate frontend to plugin-based asset architecture

- Add equipmentApi and computersApi to replace legacy machinesApi
- Add controller vendor/model fields to Equipment model and forms
- Fix map marker navigation to use plugin-specific IDs (equipmentid,
  computerid, printerid, networkdeviceid) instead of assetid
- Fix search to use unified Asset table with correct plugin IDs
- Remove legacy printer search that used non-existent field names
- Enable optional JWT auth for detail endpoints (public read access)
- Clean up USB plugin models (remove unused checkout model)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-01-29 16:07:41 -05:00
parent c8b325d2e7
commit 180e6a0915
28 changed files with 4123 additions and 3454 deletions

View File

@@ -7,13 +7,15 @@
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<form v-else @submit.prevent="saveMachine">
<form v-else @submit.prevent="saveEquipment">
<!-- Identity Section -->
<h3 class="form-section-title">Identity</h3>
<div class="form-row">
<div class="form-group">
<label for="machinenumber">Machine Number *</label>
<label for="assetnumber">Asset Number *</label>
<input
id="machinenumber"
v-model="form.machinenumber"
id="assetnumber"
v-model="form.assetnumber"
type="text"
class="form-control"
required
@@ -21,10 +23,10 @@
</div>
<div class="form-group">
<label for="alias">Alias</label>
<label for="name">Name / Alias</label>
<input
id="alias"
v-model="form.alias"
id="name"
v-model="form.name"
type="text"
class="form-control"
/>
@@ -32,16 +34,6 @@
</div>
<div class="form-row">
<div class="form-group">
<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
@@ -51,27 +43,6 @@
class="form-control"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="machinetypeid">Equipment Type *</label>
<select
id="machinetypeid"
v-model="form.machinetypeid"
class="form-control"
required
>
<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="statusid">Status</label>
@@ -92,7 +63,27 @@
</div>
</div>
<!-- Equipment Hardware Section -->
<h3 class="form-section-title">Equipment Hardware</h3>
<div class="form-row">
<div class="form-group">
<label for="equipmenttypeid">Equipment Type</label>
<select
id="equipmenttypeid"
v-model="form.equipmenttypeid"
class="form-control"
>
<option value="">Select type...</option>
<option
v-for="et in equipmentTypes"
:key="et.equipmenttypeid"
:value="et.equipmenttypeid"
>
{{ et.equipmenttype }}
</option>
</select>
</div>
<div class="form-group">
<label for="vendorid">Vendor</label>
<select
@@ -110,7 +101,9 @@
</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="modelnumberid">Model</label>
<select
@@ -127,10 +120,56 @@
{{ m.modelnumber }}
</option>
</select>
<small class="form-help">Selecting a model will auto-set the equipment type</small>
<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="controller_vendorid">Controller Vendor</label>
<select
id="controller_vendorid"
v-model="form.controller_vendorid"
class="form-control"
>
<option value="">Select vendor...</option>
<option
v-for="v in vendors"
: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="controller_modelid">Controller Model</label>
<select
id="controller_modelid"
v-model="form.controller_modelid"
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.controller_vendorid">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>
@@ -145,7 +184,7 @@
:key="l.locationid"
:value="l.locationid"
>
{{ l.location }}
{{ l.locationname }}
</option>
</select>
</div>
@@ -169,56 +208,6 @@
</div>
</div>
<div class="form-group">
<label for="notes">Notes</label>
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
></textarea>
</div>
<!-- Controlling PC Selection -->
<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.machineid"
:value="pc.machineid"
>
{{ pc.machinenumber }}{{ pc.alias ? ` (${pc.alias})` : '' }}
</option>
</select>
<small class="form-help">Select the PC that controls this equipment</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 equipment</small>
</div>
</div>
<!-- Map Location Picker -->
<div class="form-group">
<label>Map Location</label>
@@ -249,6 +238,79 @@
</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">Multi-PC machine needs manual configuration</small>
</div>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.islocationonly" />
Location Only
</label>
<small class="form-help">Virtual location marker (not actual equipment)</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 equipment</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 equipment</small>
</div>
</div>
<!-- Notes Section -->
<h3 class="form-section-title">Notes</h3>
<div class="form-group">
<textarea
id="notes"
v-model="form.notes"
class="form-control"
rows="3"
placeholder="Additional notes..."
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
<div style="display: flex; gap: 0.5rem; margin-top: 1.5rem;">
@@ -265,7 +327,7 @@
<script setup>
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { machinesApi, machinetypesApi, statusesApi, vendorsApi, locationsApi, relationshipTypesApi, modelsApi, businessunitsApi } from '../../api'
import { equipmentApi, vendorsApi, locationsApi, modelsApi, businessunitsApi, computersApi, assetsApi } from '../../api'
import ShopFloorMap from '../../components/ShopFloorMap.vue'
import Modal from '../../components/Modal.vue'
import { currentTheme } from '../../stores/theme'
@@ -282,22 +344,25 @@ const showMapPicker = ref(false)
const tempMapPosition = ref(null)
const form = ref({
machinenumber: '',
alias: '',
hostname: '',
assetnumber: '',
name: '',
serialnumber: '',
machinetypeid: '',
modelnumberid: '',
statusid: '',
statusid: 1,
equipmenttypeid: '',
vendorid: '',
modelnumberid: '',
controller_vendorid: '',
controller_modelid: '',
locationid: '',
businessunitid: '',
requiresmanualconfig: false,
islocationonly: false,
notes: '',
mapleft: null,
maptop: null
})
const machineTypes = ref([])
const equipmentTypes = ref([])
const statuses = ref([])
const vendors = ref([])
const locations = ref([])
@@ -308,55 +373,73 @@ const relationshipTypes = ref([])
const controllingPcId = ref(null)
const relationshipTypeId = ref(null)
const existingRelationshipId = ref(null)
const currentEquipment = ref(null)
// Filter models by selected vendor
// Filter models by selected equipment vendor
const filteredModels = computed(() => {
return models.value.filter(m => {
// Filter by vendor if selected
if (form.value.vendorid && m.vendorid !== form.value.vendorid) {
return false
}
// Only show models for Equipment category types
const modelType = machineTypes.value.find(t => t.machinetypeid === m.machinetypeid)
if (modelType && modelType.category !== 'Equipment') {
return false
}
return true
})
if (!form.value.vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.vendorid)
})
// When model changes, auto-set the machine type
watch(() => form.value.modelnumberid, (newModelId) => {
if (newModelId) {
const selectedModel = models.value.find(m => m.modelnumberid === newModelId)
if (selectedModel && selectedModel.machinetypeid) {
form.value.machinetypeid = selectedModel.machinetypeid
// Filter models by selected controller vendor
const filteredControllerModels = computed(() => {
if (!form.value.controller_vendorid) return models.value
return models.value.filter(m => m.vendorid === form.value.controller_vendorid)
})
// 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.controller_vendorid, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) {
const currentModel = models.value.find(m => m.modelnumberid === form.value.controller_modelid)
if (currentModel && currentModel.vendorid !== newVal) {
form.value.controller_modelid = ''
}
}
})
onMounted(async () => {
try {
// Load reference data
const [mtRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([
machinetypesApi.list({ category: 'Equipment' }),
statusesApi.list(),
vendorsApi.list(),
locationsApi.list(),
modelsApi.list(),
businessunitsApi.list(),
machinesApi.list({ category: 'PC', perpage: 500 }),
relationshipTypesApi.list()
// Load reference data in parallel
const [typesRes, statusRes, vendorRes, locRes, modelsRes, buRes, pcsRes, relTypesRes] = await Promise.all([
equipmentApi.types.list(),
assetsApi.statuses.list(),
vendorsApi.list({ per_page: 500 }),
locationsApi.list({ per_page: 500 }),
modelsApi.list({ per_page: 1000 }),
businessunitsApi.list({ per_page: 500 }),
computersApi.list({ per_page: 500 }),
assetsApi.types.list() // Used for relationship types, will fix below
])
machineTypes.value = mtRes.data.data || []
equipmentTypes.value = typesRes.data.data || []
statuses.value = statusRes.data.data || []
vendors.value = vendorRes.data.data || []
locations.value = locRes.data.data || []
models.value = modelsRes.data.data || []
businessunits.value = buRes.data.data || []
pcs.value = pcsRes.data.data || []
relationshipTypes.value = relTypesRes.data.data || []
// Load relationship types separately
try {
const relRes = await fetch('/api/assets/relationshiptypes')
if (relRes.ok) {
const relData = await relRes.json()
relationshipTypes.value = relData.data || []
}
} catch (e) {
// Fallback - use hardcoded Controls type
relationshipTypes.value = [{ relationshiptypeid: 1, relationshiptype: 'Controls' }]
}
// Set default relationship type to "Controls" if available
const controlsType = relationshipTypes.value.find(t => t.relationshiptype === 'Controls')
@@ -364,34 +447,49 @@ onMounted(async () => {
relationshipTypeId.value = controlsType.relationshiptypeid
}
// Load machine if editing
// Load equipment if editing
if (isEdit.value) {
const response = await machinesApi.get(route.params.id)
const machine = response.data.data
const response = await equipmentApi.get(route.params.id)
const data = response.data.data
currentEquipment.value = data
form.value = {
machinenumber: machine.machinenumber || '',
alias: machine.alias || '',
hostname: machine.hostname || '',
serialnumber: machine.serialnumber || '',
machinetypeid: machine.machinetype?.machinetypeid || '',
modelnumberid: machine.model?.modelnumberid || '',
statusid: machine.status?.statusid || '',
vendorid: machine.vendor?.vendorid || '',
locationid: machine.location?.locationid || '',
businessunitid: machine.businessunit?.businessunitid || '',
notes: machine.notes || '',
mapleft: machine.mapleft ?? null,
maptop: machine.maptop ?? null
assetnumber: data.assetnumber || '',
name: data.name || '',
serialnumber: data.serialnumber || '',
statusid: data.statusid || 1,
equipmenttypeid: data.equipment?.equipmenttypeid || '',
vendorid: data.equipment?.vendorid || '',
modelnumberid: data.equipment?.modelnumberid || '',
controller_vendorid: data.equipment?.controller_vendorid || '',
controller_modelid: data.equipment?.controller_modelid || '',
locationid: data.locationid || '',
businessunitid: data.businessunitid || '',
requiresmanualconfig: data.equipment?.requiresmanualconfig || false,
islocationonly: data.equipment?.islocationonly || false,
notes: data.notes || '',
mapleft: data.mapleft ?? null,
maptop: data.maptop ?? null
}
// Load existing relationship (controlling PC)
const relResponse = await machinesApi.getRelationships(route.params.id)
const relationships = relResponse.data.data || []
const pcRelationship = relationships.find(r => r.direction === 'controlled_by' && r.relatedcategory === 'PC')
if (pcRelationship) {
controllingPcId.value = pcRelationship.relatedmachineid
relationshipTypeId.value = pcRelationship.relationshiptypeid
existingRelationshipId.value = pcRelationship.relationshipid
// 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.source_asset?.assettype_name === 'computer' && rel.relationship_type_name === 'Controls') {
controllingPcId.value = rel.source_asset.assetid
relationshipTypeId.value = rel.relationshiptypeid
existingRelationshipId.value = rel.assetrelationshipid
break
}
}
} catch (e) {
console.log('Could not load relationships')
}
}
}
} catch (err) {
@@ -420,45 +518,56 @@ function clearMapPosition() {
tempMapPosition.value = null
}
async function saveMachine() {
async function saveEquipment() {
error.value = ''
saving.value = true
try {
const data = {
...form.value,
machinetypeid: form.value.machinetypeid || null,
modelnumberid: form.value.modelnumberid || null,
statusid: form.value.statusid || null,
assetnumber: form.value.assetnumber,
name: form.value.name || null,
serialnumber: form.value.serialnumber || null,
statusid: form.value.statusid || 1,
equipmenttypeid: form.value.equipmenttypeid || null,
vendorid: form.value.vendorid || null,
modelnumberid: form.value.modelnumberid || null,
controller_vendorid: form.value.controller_vendorid || null,
controller_modelid: form.value.controller_modelid || null,
locationid: form.value.locationid || null,
businessunitid: form.value.businessunitid || null,
requiresmanualconfig: form.value.requiresmanualconfig,
islocationonly: form.value.islocationonly,
notes: form.value.notes || null,
mapleft: form.value.mapleft,
maptop: form.value.maptop
}
let machineId = route.params.id
let savedEquipment
let assetId
if (isEdit.value) {
await machinesApi.update(route.params.id, data)
const response = await equipmentApi.update(route.params.id, data)
savedEquipment = response.data.data
assetId = savedEquipment.assetid
} else {
const response = await machinesApi.create(data)
machineId = response.data.data.machineid
const response = await equipmentApi.create(data)
savedEquipment = response.data.data
assetId = savedEquipment.assetid
}
// Handle relationship (controlling PC)
await saveRelationship(machineId)
await saveRelationship(assetId)
router.push('/machines')
router.push(`/machines/${savedEquipment.equipment?.equipmentid || route.params.id}`)
} catch (err) {
console.error('Error saving machine:', err)
error.value = err.response?.data?.message || 'Failed to save machine'
console.error('Error saving equipment:', err)
error.value = err.response?.data?.message || 'Failed to save equipment'
} finally {
saving.value = false
}
}
async function saveRelationship(machineId) {
async function saveRelationship(assetId) {
// If no PC selected and no existing relationship, nothing to do
if (!controllingPcId.value && !existingRelationshipId.value) {
return
@@ -466,7 +575,7 @@ async function saveRelationship(machineId) {
// If clearing the relationship
if (!controllingPcId.value && existingRelationshipId.value) {
await machinesApi.deleteRelationship(existingRelationshipId.value)
await assetsApi.deleteRelationship(existingRelationshipId.value)
return
}
@@ -474,20 +583,50 @@ async function saveRelationship(machineId) {
if (controllingPcId.value) {
// If there's an existing relationship, delete it first
if (existingRelationshipId.value) {
await machinesApi.deleteRelationship(existingRelationshipId.value)
await assetsApi.deleteRelationship(existingRelationshipId.value)
}
// Create new relationship
await machinesApi.createRelationship(machineId, {
relatedmachineid: controllingPcId.value,
relationshiptypeid: relationshipTypeId.value,
direction: 'controlled_by'
// Create new relationship (PC controls Equipment, so PC is source, Equipment is target)
await assetsApi.createRelationship({
source_assetid: controllingPcId.value,
target_assetid: assetId,
relationshiptypeid: relationshipTypeId.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;
@@ -499,7 +638,7 @@ async function saveRelationship(machineId) {
align-items: center;
gap: 0.5rem;
padding: 0.5rem 1rem;
background: #e3f2fd;
background: var(--bg);
border-radius: 4px;
font-family: monospace;
}