The location option label read l.location, but the Location.to_dict() field is locationname, so every option rendered blank - the dropdown looked empty and "massive" (a long list of blank rows). Fixed across all five affected forms: printers, computers, network devices, network device form, and the subnets location filter. Other .location uses (printer-driver URL, search-result label, report bylocation key) are legitimately different fields, left alone. Also require a model on the printer form: asterisk + required attr, plus a JS guard in savePrinter (the native required is skipped while the select is disabled with no vendor picked) that points the user at the vendor first.
610 lines
19 KiB
Vue
610 lines
19 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 > PC Access Protocols.</span>
|
|
</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>
|
|
|
|
<!-- 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>
|
|
|
|
<!-- 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 } from '@/api'
|
|
import ShopFloorMap from '@/components/ShopFloorMap.vue'
|
|
import Modal from '@/components/Modal.vue'
|
|
import CustomFieldsInputs from '@/components/CustomFieldsInputs.vue'
|
|
import { currentTheme } from '@/stores/theme'
|
|
import { useIdentifierFlags } from '@/composables/identifierSettings'
|
|
import { apiError } from '@/utils/apiError'
|
|
|
|
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)
|
|
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,
|
|
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([])
|
|
|
|
// 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()
|
|
])
|
|
|
|
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,
|
|
ipaddress: primaryComm?.ipaddress || ''
|
|
}
|
|
}
|
|
} 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 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)
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
.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>
|