Add custom fields + warranty plugin, rework settings into two-pane shell
Feature work from the 2026-07 session: Settings IA - Replace the flat 27-card settings hub with a persistent two-pane shell (SettingsLayout.vue): grouped, searchable left rail + content pane. - Nest all settings/* routes under the shell via router post-processing; shared nav catalog in settingsNav.js. Group by asset class (PCs, Printers, Equipment, Network) so per-type settings stop scattering. Custom fields (core) - customfields + customfieldvalues tables (migration 7d14), CRUD API at /api/customfields, per-asset value get/save. - Settings management page + reusable CustomFieldsSection (detail) and CustomFieldsInputs (form) wired into all four asset types. Warranty (new plugin) - plugins/warranty: warranties + warrantyassets (migration 7d15), derived coverage status, provider abstraction (manual now; Dell/Lenovo/HP stubs). - API CRUD + per-asset panel + report buckets; WarrantyPanel on all four detail pages; Warranties management page; Warranty report + Reports card. - Seed warranty.* permissions. Printer drivers - printerdrivers table (migration 7d13) linked to printer models; drivers now surface on the matching printer's detail page. Other - PCDetail rebalanced (Network + Status + Warranty + custom fields on the right). - Rename PCs list "Features" column to "Remote Access"; fix badge hover underline. - Drop equipment islocationonly field. - Centralize asset-type label/route maps into utils/assetTypes.js. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,7 +20,7 @@
|
||||
</div>
|
||||
<div class="hero-meta">
|
||||
<span class="badge badge-lg badge-info">Computer</span>
|
||||
<span class="badge badge-lg" :class="getStatusClass(computer.statusname)">
|
||||
<span class="badge badge-lg" :style="colorStyle(computer.statuscolor)">
|
||||
{{ computer.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -84,6 +84,14 @@
|
||||
<span class="info-label">Computer Type</span>
|
||||
<span class="info-value">{{ computer.computer?.computertypename || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Vendor</span>
|
||||
<span class="info-value">{{ computer.computer?.vendorname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Model</span>
|
||||
<span class="info-value">{{ computer.computer?.modelname || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Operating System</span>
|
||||
<span class="info-value">{{ computer.computer?.osname || '-' }}</span>
|
||||
@@ -91,36 +99,58 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- PC Status -->
|
||||
<!-- Remote Access -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row" v-if="computer.computer?.loggedinuser">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer.loggedinuser }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Features</span>
|
||||
<span class="info-value">
|
||||
<span class="feature-tag" :class="{ active: computer.computer?.isvnc }">VNC</span>
|
||||
<span class="feature-tag" :class="{ active: computer.computer?.iswinrm }">WinRM</span>
|
||||
<span class="feature-tag" :class="{ active: computer.computer?.isshopfloor }">Shopfloor</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="computer.computer?.lastreporteddate">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ formatDate(computer.computer.lastreporteddate) }}</span>
|
||||
</div>
|
||||
<div class="info-row" v-if="computer.computer?.lastboottime">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ formatDate(computer.computer.lastboottime) }}</span>
|
||||
</div>
|
||||
<h3 class="section-title">Remote Access</h3>
|
||||
<div class="access-methods">
|
||||
<template v-for="a in (computer.accessmethods || [])" :key="a.id">
|
||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link">{{ a.name }}</a>
|
||||
<span v-else class="access-link disabled" title="No hostname/IP set for this PC">{{ a.name }}</span>
|
||||
</template>
|
||||
<span v-if="!(computer.accessmethods || []).length" class="muted">None configured</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Right Column -->
|
||||
<div class="content-column">
|
||||
<!-- Network -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Network</h3>
|
||||
<div v-if="computer.communications?.length" class="network-list">
|
||||
<div v-for="comm in computer.communications" :key="comm.communicationid" class="network-item">
|
||||
<div class="network-primary">
|
||||
<span class="ip-address mono">{{ comm.ipaddress || comm.address || '-' }}</span>
|
||||
<span v-if="comm.communicationtypename" class="comm-type">{{ comm.communicationtypename }}</span>
|
||||
<span v-if="comm.isprimary" class="primary-badge">Primary</span>
|
||||
</div>
|
||||
<div class="network-secondary" v-if="comm.macaddress">
|
||||
<span class="mac-address mono">{{ comm.macaddress }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else class="muted">No network addresses on record</p>
|
||||
</div>
|
||||
|
||||
<!-- Status / Check-in -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Status</h3>
|
||||
<div class="info-list">
|
||||
<div class="info-row">
|
||||
<span class="info-label">Logged In User</span>
|
||||
<span class="info-value">{{ computer.computer?.loggedinuser || '-' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Reported</span>
|
||||
<span class="info-value">{{ computer.computer?.lastreporteddate ? formatDate(computer.computer.lastreporteddate) : 'Never' }}</span>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<span class="info-label">Last Boot</span>
|
||||
<span class="info-value">{{ computer.computer?.lastboottime ? formatDate(computer.computer.lastboottime) : '-' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Location -->
|
||||
<div class="section-card">
|
||||
<h3 class="section-title">Location</h3>
|
||||
@@ -189,6 +219,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Fields -->
|
||||
<CustomFieldsSection :assetid="computer.assetid" />
|
||||
|
||||
<!-- Warranty -->
|
||||
<WarrantyPanel :assetid="computer.assetid" />
|
||||
|
||||
<!-- Notes -->
|
||||
<div class="section-card" v-if="computer.notes">
|
||||
<h3 class="section-title">Notes</h3>
|
||||
@@ -212,9 +248,12 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { colorStyle } from "@/utils/colorStyle"
|
||||
import { useRoute } from 'vue-router'
|
||||
import { computersApi, applicationsApi, assetsApi } from '../../api'
|
||||
import LocationMapTooltip from '../../components/LocationMapTooltip.vue'
|
||||
import CustomFieldsSection from '../../components/CustomFieldsSection.vue'
|
||||
import WarrantyPanel from '../../components/WarrantyPanel.vue'
|
||||
import { useIdentifierFlags } from '../../composables/identifierSettings'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -301,6 +340,36 @@ function formatDate(dateStr) {
|
||||
<style scoped>
|
||||
/* PC-specific styles - shared styles are in global style.css */
|
||||
|
||||
/* Remote-access protocol links */
|
||||
.access-methods {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.access-methods .muted {
|
||||
color: var(--text-light);
|
||||
}
|
||||
.access-link {
|
||||
display: inline-block;
|
||||
padding: 3px 12px;
|
||||
margin: 0 6px 4px 0;
|
||||
border-radius: 14px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link:hover {
|
||||
background: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link.disabled {
|
||||
background: var(--secondary);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Installed Applications */
|
||||
.app-list {
|
||||
display: flex;
|
||||
@@ -370,4 +439,46 @@ function formatDate(dateStr) {
|
||||
font-size: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
/* Network card (IP / MAC list) */
|
||||
.network-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.network-item {
|
||||
padding: 0.5rem 0.7rem;
|
||||
background: var(--bg);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.network-primary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.ip-address {
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
.comm-type {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.primary-badge {
|
||||
padding: 0.1rem 0.5rem;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.mac-address {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
.muted {
|
||||
color: var(--text-light);
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -223,16 +223,25 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="display: flex; align-items: flex-end; gap: 1.5rem;">
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<input type="checkbox" v-model="form.isvnc" />
|
||||
VNC Enabled
|
||||
</label>
|
||||
<label style="display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;">
|
||||
<input type="checkbox" v-model="form.iswinrm" />
|
||||
WinRM Enabled
|
||||
<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>
|
||||
|
||||
@@ -276,6 +285,9 @@
|
||||
</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;">
|
||||
@@ -295,6 +307,7 @@ 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'
|
||||
|
||||
@@ -305,6 +318,12 @@ 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)
|
||||
@@ -332,8 +351,7 @@ const form = ref({
|
||||
locationid: '',
|
||||
osid: '',
|
||||
loggedinuser: '',
|
||||
isvnc: false,
|
||||
iswinrm: false,
|
||||
accessmethods: [],
|
||||
notes: '',
|
||||
mapx: null,
|
||||
mapy: null,
|
||||
@@ -341,7 +359,33 @@ const form = ref({
|
||||
})
|
||||
|
||||
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([])
|
||||
@@ -366,13 +410,14 @@ 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] = await Promise.all([
|
||||
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 })
|
||||
operatingsystemsApi.list({ perpage: 100 }),
|
||||
computersApi.protocols.list()
|
||||
])
|
||||
|
||||
pcTypes.value = ptRes.data.data || []
|
||||
@@ -381,12 +426,14 @@ onMounted(async () => {
|
||||
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]
|
||||
|
||||
@@ -404,8 +451,10 @@ onMounted(async () => {
|
||||
locationid: pc.locationid || '',
|
||||
osid: ext.osid || '',
|
||||
loggedinuser: ext.loggedinuser || '',
|
||||
isvnc: ext.isvnc || false,
|
||||
iswinrm: ext.iswinrm || false,
|
||||
accessmethods: (pc.accessmethods || []).map(a => ({
|
||||
protocolid: a.protocolid,
|
||||
portoverride: a.portoverride ?? null
|
||||
})),
|
||||
notes: pc.notes || '',
|
||||
mapx: pc.mapx ?? null,
|
||||
mapy: pc.mapy ?? null,
|
||||
@@ -458,8 +507,7 @@ async function savePC() {
|
||||
locationid: form.value.locationid || null,
|
||||
osid: form.value.osid || null,
|
||||
loggedinuser: form.value.loggedinuser || null,
|
||||
isvnc: form.value.isvnc,
|
||||
iswinrm: form.value.iswinrm,
|
||||
accessmethods: form.value.accessmethods,
|
||||
notes: form.value.notes || null,
|
||||
ipaddress: form.value.ipaddress || null,
|
||||
mapx: form.value.mapx,
|
||||
@@ -470,10 +518,22 @@ async function savePC() {
|
||||
payload.name = form.value.alias
|
||||
}
|
||||
|
||||
let assetId = currentAssetId.value
|
||||
if (isEdit.value) {
|
||||
await computersApi.update(route.params.id, payload)
|
||||
const response = await computersApi.update(route.params.id, payload)
|
||||
assetId = assetId || response.data?.data?.assetid || response.data?.data?.asset?.assetid
|
||||
} else {
|
||||
await computersApi.create(payload)
|
||||
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')
|
||||
@@ -487,6 +547,28 @@ async function savePC() {
|
||||
</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;
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
<th>Hostname</th>
|
||||
<th>Serial Number</th>
|
||||
<th>Type</th>
|
||||
<th>Features</th>
|
||||
<th>Remote Access</th>
|
||||
<th>Status</th>
|
||||
<th>Location</th>
|
||||
<th>Actions</th>
|
||||
@@ -41,12 +41,14 @@
|
||||
<td class="mono">{{ item.serialnumber || '-' }}</td>
|
||||
<td>{{ item.computer?.computertypename || '-' }}</td>
|
||||
<td class="features">
|
||||
<span v-if="item.computer?.isvnc" class="feature-tag active">VNC</span>
|
||||
<span v-if="item.computer?.iswinrm" class="feature-tag active">WinRM</span>
|
||||
<span v-if="!item.computer?.isvnc && !item.computer?.iswinrm">-</span>
|
||||
<template v-for="a in (item.accessmethods || [])" :key="a.id">
|
||||
<a v-if="a.link" :href="a.link" class="access-link" :title="a.link" @click.stop>{{ a.name }}</a>
|
||||
<span v-else class="access-link disabled" title="No hostname/IP set">{{ a.name }}</span>
|
||||
</template>
|
||||
<span v-if="!(item.accessmethods || []).length">-</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge" :class="getStatusClass(item.statusname)">
|
||||
<span class="badge" :style="colorStyle(item.statuscolor)">
|
||||
{{ item.statusname || 'Unknown' }}
|
||||
</span>
|
||||
</td>
|
||||
@@ -86,6 +88,7 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { computersApi } from '../../api'
|
||||
import PaginationBar from '../../components/PaginationBar.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
|
||||
const computers = ref([])
|
||||
const loading = ref(true)
|
||||
@@ -138,17 +141,30 @@ function changePerPage(newPerPage) {
|
||||
loadComputers()
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
if (!status) return 'badge-info'
|
||||
const s = status.toLowerCase()
|
||||
if (s === 'in use' || s === 'active') return 'badge-success'
|
||||
if (s === 'in repair') return 'badge-warning'
|
||||
if (s === 'retired') return 'badge-danger'
|
||||
return 'badge-info'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.access-link {
|
||||
display: inline-block;
|
||||
padding: 2px 10px;
|
||||
margin: 0 4px 3px 0;
|
||||
border-radius: 12px;
|
||||
background: var(--primary);
|
||||
color: #fff;
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link:hover {
|
||||
background: var(--primary-dark);
|
||||
text-decoration: none;
|
||||
}
|
||||
.access-link.disabled {
|
||||
background: var(--secondary);
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.mono {
|
||||
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user