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:
cproudlock
2026-07-09 15:37:21 -04:00
parent 419f26107d
commit 78a0ee8d83
154 changed files with 9479 additions and 1098 deletions

View File

@@ -0,0 +1,292 @@
<template>
<div>
<div class="page-header">
<h2>Warranties</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Warranty</button>
</div>
<div class="filters">
<label>Status
<select v-model="statusFilter" class="form-control" @change="loadData">
<option value="">All</option>
<option value="active">Active</option>
<option value="expiring">Expiring Soon</option>
<option value="expired">Expired</option>
<option value="unknown">Unknown</option>
</select>
</label>
</div>
<div class="card">
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Status</th>
<th>Service Level</th>
<th>Ends</th>
<th>Covers</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="w in items" :key="w.warrantyid">
<td><strong>{{ w.vendor }}</strong><span v-if="w.provider !== 'manual'" class="muted"> ({{ w.provider }})</span></td>
<td><span class="status-badge" :style="colorStyle(w.statuscolor)">{{ statusLabel(w.status) }}</span></td>
<td>{{ w.servicelevel || '-' }}</td>
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<span v-if="!w.assets.length" class="muted">-</span>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
</td>
<td class="actions">
<button v-if="w.provider !== 'manual'" class="btn btn-secondary btn-sm" @click="refresh(w)">Refresh</button>
<button class="btn btn-secondary btn-sm" @click="openModal(w)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteWarranty(w)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No warranties</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Warranty</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label>Vendor *</label>
<input v-model="form.vendor" type="text" class="form-control" maxlength="100" required />
</div>
<div class="form-group">
<label>Provider</label>
<select v-model="form.provider" class="form-control">
<option value="manual">Manual</option>
<option value="dell">Dell</option>
<option value="lenovo">Lenovo</option>
<option value="hp">HP</option>
</select>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Service Tag / Serial</label>
<input v-model="form.servicetag" type="text" class="form-control" maxlength="100" />
</div>
<div class="form-group">
<label>Service Level</label>
<input v-model="form.servicelevel" type="text" class="form-control" maxlength="150" />
</div>
</div>
<div class="form-row">
<div class="form-group">
<label>Start Date</label>
<input v-model="form.startdate" type="date" class="form-control" />
</div>
<div class="form-group">
<label>End Date</label>
<input v-model="form.enddate" type="date" class="form-control" />
</div>
</div>
<div class="form-group">
<label>Covered Assets</label>
<div class="asset-search">
<input v-model="assetQuery" type="text" class="form-control" placeholder="Search asset number or name..."
@input="searchAssets" />
<ul v-if="assetResults.length" class="asset-results">
<li v-for="a in assetResults" :key="a.assetid" @click="addAsset(a)">
{{ a.assetnumber }}<span v-if="a.name" class="muted"> - {{ a.name }}</span>
</li>
</ul>
</div>
<div class="asset-chips">
<span v-for="a in selectedAssets" :key="a.assetid" class="asset-chip removable">
{{ a.assetnumber }}
<button type="button" @click="removeAsset(a)">x</button>
</span>
<span v-if="!selectedAssets.length" class="muted">None linked</span>
</div>
</div>
<div class="form-group">
<label>Notes</label>
<textarea v-model="form.notes" class="form-control" rows="2"></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
</div>
</form>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { colorStyle } from '@/utils/colorStyle'
import { warrantyApi, assetsApi } from '../../api'
const items = ref([])
const loading = ref(true)
const statusFilter = ref('')
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref(blankForm())
const selectedAssets = ref([])
const assetQuery = ref('')
const assetResults = ref([])
function blankForm() {
return { vendor: '', provider: 'manual', servicetag: '', servicelevel: '', startdate: '', enddate: '', notes: '' }
}
function statusLabel(status) {
return { active: 'Active', expiring: 'Expiring Soon', expired: 'Expired', unknown: 'Unknown' }[status] || status
}
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
// Route to the right detail page by asset type.
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', equipment: '/machines/' }
const base = map[a.assettypename] || '/assets/'
return base + a.assetid
}
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const params = statusFilter.value ? { status: statusFilter.value } : {}
const response = await warrantyApi.list(params)
items.value = response.data.data || []
} catch (err) {
console.error('Error loading warranties:', err)
} finally {
loading.value = false
}
}
let searchTimer = null
function searchAssets() {
clearTimeout(searchTimer)
const q = assetQuery.value.trim()
if (!q) { assetResults.value = []; return }
searchTimer = setTimeout(async () => {
try {
const response = await assetsApi.search(q, { perpage: 8 })
assetResults.value = response.data.data || []
} catch (err) {
assetResults.value = []
}
}, 250)
}
function addAsset(a) {
if (!selectedAssets.value.some(x => x.assetid === a.assetid)) {
selectedAssets.value.push({ assetid: a.assetid, assetnumber: a.assetnumber })
}
assetQuery.value = ''
assetResults.value = []
}
function removeAsset(a) {
selectedAssets.value = selectedAssets.value.filter(x => x.assetid !== a.assetid)
}
function openModal(item = null) {
editing.value = item
if (item) {
form.value = {
vendor: item.vendor || '', provider: item.provider || 'manual',
servicetag: item.servicetag || '', servicelevel: item.servicelevel || '',
startdate: item.startdate || '', enddate: item.enddate || '', notes: item.notes || '',
}
selectedAssets.value = (item.assets || []).map(a => ({ assetid: a.assetid, assetnumber: a.assetnumber }))
} else {
form.value = blankForm()
selectedAssets.value = []
}
assetQuery.value = ''
assetResults.value = []
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const payload = { ...form.value, assetids: selectedAssets.value.map(a => a.assetid) }
if (editing.value) {
await warrantyApi.update(editing.value.warrantyid, payload)
} else {
await warrantyApi.create(payload)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteWarranty(w) {
if (!confirm(`Delete the ${w.vendor} warranty?`)) return
try {
await warrantyApi.remove(w.warrantyid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || 'Failed to delete')
}
}
async function refresh(w) {
try {
await warrantyApi.refresh(w.warrantyid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Refresh failed')
}
}
</script>
<style scoped>
.muted { color: var(--text-light); }
.status-badge { padding: 0.15rem 0.6rem; border-radius: 12px; font-size: 0.78rem; font-weight: 600; }
.form-row { display: flex; gap: 1rem; }
.form-row .form-group { flex: 1; }
.asset-search { position: relative; }
.asset-results {
position: absolute; z-index: 10; left: 0; right: 0; margin: 2px 0 0;
padding: 0; list-style: none; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 6px; max-height: 200px; overflow-y: auto;
}
.asset-results li { padding: 0.45rem 0.6rem; cursor: pointer; }
.asset-results li:hover { background: var(--bg); }
.asset-chips { margin-top: 0.5rem; display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.asset-chip {
display: inline-flex; align-items: center; gap: 0.3rem;
padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
}
.asset-chip.removable button {
border: none; background: none; color: var(--text-light); cursor: pointer; font-size: 0.85rem; padding: 0;
}
</style>