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:
221
frontend/src/views/settings/CustomFieldsList.vue
Normal file
221
frontend/src/views/settings/CustomFieldsList.vue
Normal file
@@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Custom Fields</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" :disabled="!assettypeid" @click="openModal()">+ Add Field</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<p class="hint">
|
||||
Define extra attributes per asset type. They appear on that asset's detail
|
||||
page and edit form. Use these instead of asking for a schema change.
|
||||
</p>
|
||||
|
||||
<div class="form-group type-picker">
|
||||
<label>Asset Type</label>
|
||||
<select v-model="assettypeid" class="form-control" @change="loadFields">
|
||||
<option v-for="t in assetTypes" :key="t.assettypeid" :value="t.assettypeid">
|
||||
{{ typeLabel(t) }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="muted">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Label</th>
|
||||
<th>Key</th>
|
||||
<th>Type</th>
|
||||
<th>On Detail</th>
|
||||
<th>On Form</th>
|
||||
<th>Order</th>
|
||||
<th>Active</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="f in visibleFields" :key="f.fieldid">
|
||||
<td><strong>{{ f.label }}</strong></td>
|
||||
<td class="mono">{{ f.fieldkey }}</td>
|
||||
<td>
|
||||
{{ f.datatype }}
|
||||
<span v-if="f.datatype === 'select' && f.options.length" class="muted">({{ f.options.join(', ') }})</span>
|
||||
</td>
|
||||
<td>{{ f.showondetail ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.showonform ? 'yes' : '-' }}</td>
|
||||
<td>{{ f.sortorder }}</td>
|
||||
<td>
|
||||
<span class="badge" :class="f.isactive ? 'badge-success' : 'badge-secondary'">{{ f.isactive ? 'yes' : 'no' }}</span>
|
||||
</td>
|
||||
<td class="actions">
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(f)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteField(f)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleFields.length === 0">
|
||||
<td colspan="8" style="text-align: center; color: var(--text-light);">No custom fields for this asset type</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' }} Field</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Label *</label>
|
||||
<input v-model="form.label" type="text" class="form-control" maxlength="150" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Data Type</label>
|
||||
<select v-model="form.datatype" class="form-control">
|
||||
<option value="text">Text</option>
|
||||
<option value="number">Number</option>
|
||||
<option value="date">Date</option>
|
||||
<option value="boolean">Yes / No</option>
|
||||
<option value="select">Dropdown</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" v-if="form.datatype === 'select'">
|
||||
<label>Options <span class="hint">(one per line or comma-separated)</span></label>
|
||||
<textarea v-model="form.options" class="form-control" rows="3" placeholder="Bronze Silver Gold"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.showondetail" /> Show on detail page</label>
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.showonform" /> Show on edit form</label>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>Sort Order</label>
|
||||
<input v-model.number="form.sortorder" type="number" class="form-control" style="width: 90px;" />
|
||||
</div>
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</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, computed, onMounted } from 'vue'
|
||||
import { assetsApi, customFieldsApi } from '../../api'
|
||||
|
||||
const assetTypes = ref([])
|
||||
const assettypeid = ref(null)
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleFields = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref(blankForm())
|
||||
|
||||
function blankForm() {
|
||||
return { label: '', datatype: 'text', options: '', showondetail: true, showonform: true, sortorder: 0, isactive: true }
|
||||
}
|
||||
|
||||
function typeLabel(t) {
|
||||
const name = t.assettype || t.typename || t.name || `Type ${t.assettypeid}`
|
||||
return name.charAt(0).toUpperCase() + name.slice(1).replace('_', ' ')
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await assetsApi.types.list()
|
||||
assetTypes.value = response.data.data || []
|
||||
if (assetTypes.value.length) {
|
||||
assettypeid.value = assetTypes.value[0].assettypeid
|
||||
await loadFields()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error loading asset types:', err)
|
||||
}
|
||||
})
|
||||
|
||||
async function loadFields() {
|
||||
if (!assettypeid.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await customFieldsApi.list({ assettypeid: assettypeid.value, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading fields:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? {
|
||||
label: item.label || '',
|
||||
datatype: item.datatype || 'text',
|
||||
options: (item.options || []).join('\n'),
|
||||
showondetail: item.showondetail !== false,
|
||||
showonform: item.showonform !== false,
|
||||
sortorder: item.sortorder || 0,
|
||||
isactive: item.isactive !== false,
|
||||
}
|
||||
: blankForm()
|
||||
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, assettypeid: assettypeid.value }
|
||||
if (editing.value) {
|
||||
await customFieldsApi.update(editing.value.fieldid, payload)
|
||||
} else {
|
||||
await customFieldsApi.create(payload)
|
||||
}
|
||||
closeModal()
|
||||
loadFields()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteField(f) {
|
||||
if (!confirm(`Delete field "${f.label}"? Stored values for it will be removed.`)) return
|
||||
try {
|
||||
await customFieldsApi.remove(f.fieldid)
|
||||
loadFields()
|
||||
} catch (err) {
|
||||
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
|
||||
.muted { color: var(--text-light); }
|
||||
.mono { font-family: monospace; font-size: 0.85rem; }
|
||||
.type-picker { max-width: 280px; }
|
||||
.form-row { display: flex; gap: 1.25rem; align-items: center; flex-wrap: wrap; margin-bottom: 0.75rem; }
|
||||
.checkbox-label { display: inline-flex; align-items: center; gap: 0.4rem; }
|
||||
</style>
|
||||
Reference in New Issue
Block a user