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,219 @@
<template>
<div>
<div class="page-header">
<h1>PC Access Protocols</h1>
<div class="actions">
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
<button class="btn btn-primary" @click="openNew">New Protocol</button>
</div>
</div>
<div class="card">
<p class="hint">
Remote-access protocols offered on PCs. Links are built as
<code>{{ '{scheme}://{hostname}.<pc_access_domain>:{port}' }}</code> from
each protocol's template. Set the domain in
<router-link to="/settings/site">Site &amp; Facility</router-link>.
</p>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Scheme</th>
<th>Default port</th>
<th>Link template</th>
<th>Active</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="p in protocols" :key="p.protocolid">
<td><strong>{{ p.name }}</strong></td>
<td class="mono">{{ p.scheme }}</td>
<td>{{ p.defaultport ?? '-' }}</td>
<td class="mono">{{ p.linktemplate }}</td>
<td>
<span class="badge" :class="p.isactive ? 'badge-success' : 'badge-secondary'">
{{ p.isactive ? 'yes' : 'no' }}
</span>
</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openEdit(p)">Edit</button>
<button class="btn btn-sm btn-danger" @click="remove(p)">Delete</button>
</td>
</tr>
<tr v-if="!loading && !protocols.length">
<td colspan="6" class="muted" style="text-align:center;">No protocols.</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="editing" class="modal-overlay" @click.self="close">
<div class="modal-panel">
<h2>{{ form.protocolid ? 'Edit' : 'New' }} Protocol</h2>
<div class="form-grid">
<label class="field">
<span>Name</span>
<input v-model="form.name" type="text" maxlength="50" placeholder="e.g. VNC" />
</label>
<label class="field">
<span>Scheme</span>
<input v-model="form.scheme" type="text" maxlength="20" placeholder="vnc / rdp / https / ssh" />
</label>
<label class="field">
<span>Default port</span>
<input v-model.number="form.defaultport" type="number" min="1" max="65535" placeholder="5900" />
</label>
<label class="field">
<span>Link template</span>
<input v-model="form.linktemplate" type="text" maxlength="255" placeholder="vnc://{host}:{port}" />
<small class="muted">Placeholders: <code>{host}</code>, <code>{port}</code>, <code>{scheme}</code></small>
</label>
<label class="field checkbox">
<input v-model="form.isactive" type="checkbox" />
<span>Active</span>
</label>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="close">Cancel</button>
<button class="btn btn-primary" :disabled="saving || !formValid" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { computersApi } from '@/api'
const protocols = ref([])
const loading = ref(true)
const editing = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
const formValid = computed(() =>
form.value.name && form.value.scheme && form.value.linktemplate
)
async function load() {
loading.value = true
try {
const response = await computersApi.protocols.list({ active: false })
protocols.value = response.data.data || []
} catch (err) {
console.error('Error loading protocols:', err)
} finally {
loading.value = false
}
}
function openNew() {
error.value = ''
form.value = { name: '', scheme: '', defaultport: null, linktemplate: '', isactive: true }
editing.value = true
}
function openEdit(p) {
error.value = ''
form.value = {
protocolid: p.protocolid,
name: p.name,
scheme: p.scheme,
defaultport: p.defaultport ?? null,
linktemplate: p.linktemplate,
isactive: p.isactive !== false
}
editing.value = true
}
function close() {
editing.value = false
}
async function save() {
saving.value = true
error.value = ''
try {
if (form.value.protocolid) {
await computersApi.protocols.update(form.value.protocolid, form.value)
} else {
await computersApi.protocols.create(form.value)
}
editing.value = false
await load()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Save failed.'
} finally {
saving.value = false
}
}
async function remove(p) {
if (!confirm(`Delete protocol "${p.name}"? (kept but deactivated if any PC uses it)`)) return
try {
await computersApi.protocols.remove(p.protocolid)
await load()
} catch (err) {
console.error('Error deleting protocol:', err)
}
}
onMounted(load)
</script>
<style scoped>
.hint {
color: var(--text-light);
font-size: 0.9rem;
margin: 0 0 14px;
}
.mono { font-family: monospace; font-size: 0.85rem; }
.muted { color: var(--text-light); }
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 40px 16px;
overflow-y: auto;
z-index: 1000;
}
.modal-panel {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
width: 100%;
max-width: 520px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.4);
}
.modal-panel h2 { margin: 0 0 18px; }
.form-grid { display: flex; flex-direction: column; gap: 14px; }
.field { display: flex; flex-direction: column; gap: 4px; }
.field > span { font-size: 0.85rem; color: var(--text-light); }
.field input[type="text"],
.field input[type="number"] {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.field.checkbox { flex-direction: row; align-items: center; gap: 8px; }
.field.checkbox > span { color: var(--text); font-size: 1rem; }
.error { color: var(--danger); margin: 12px 0 0; }
.modal-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
</style>

View File

@@ -0,0 +1,119 @@
<template>
<div>
<div class="page-header">
<h2>Asset Type Colors</h2>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<p class="hint">
Top-level asset categories are defined by their plugins, so you can set
their color + description here (used for map markers/legend), but not add
or remove them.
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in items" :key="t.assettypeid">
<td><strong>{{ t.assettype }}</strong></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
</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>Edit {{ form.assettype }}</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</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 { assetsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const loading = ref(true)
const showModal = ref(false)
const saving = ref(false)
const error = ref('')
const form = ref({})
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await assetsApi.types.list()
items.value = response.data.data || []
} catch (err) {
console.error('Error loading asset types:', err)
} finally {
loading.value = false
}
}
function openModal(t) {
form.value = { assettypeid: t.assettypeid, assettype: t.assettype, description: t.description || '', color: t.color || '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false }
async function save() {
error.value = ''
saving.value = true
try {
await assetsApi.types.update(form.value.assettypeid, { description: form.value.description, color: form.value.color })
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
</script>
<style scoped>
.hint { color: var(--text-light); font-size: 0.9rem; margin: 0 0 14px; }
.muted { color: var(--text-light); }
</style>

View 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&#10;Silver&#10;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>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Equipment Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Equipment Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Equipment Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.equipmenttypeid">
<td>{{ t.equipmenttype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No equipment types found</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' }} Equipment Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Equipment Type *</label>
<input v-model="form.equipmenttype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<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 { equipmentApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ equipmenttype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await equipmentApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading equipment types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { equipmenttype: item.equipmenttype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { equipmenttype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await equipmentApi.types.update(editing.value.equipmenttypeid, form.value)
} else {
await equipmentApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete equipment type "${t.equipmenttype}"?`)) return
try {
await equipmentApi.types.remove(t.equipmenttypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Location Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Location Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Location Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.locationtypeid">
<td>{{ t.locationtype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No location types found</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' }} Location Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Location Type *</label>
<input v-model="form.locationtype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<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 { locationsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ locationtype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await locationsApi.types.list({ active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading location types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { locationtype: item.locationtype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { locationtype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await locationsApi.types.update(editing.value.locationtypeid, form.value)
} else {
await locationsApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete location type "${t.locationtype}"?`)) return
try {
await locationsApi.types.remove(t.locationtypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Network Device Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Network Device Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Network Device Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.networkdevicetypeid">
<td>{{ t.networkdevicetype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No network device types found</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' }} Network Device Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Network Device Type *</label>
<input v-model="form.networkdevicetype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<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 { networkApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ networkdevicetype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await networkApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading network device types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { networkdevicetype: item.networkdevicetype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { networkdevicetype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await networkApi.types.update(editing.value.networkdevicetypeid, form.value)
} else {
await networkApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete network device type "${t.networkdevicetype}"?`)) return
try {
await networkApi.types.remove(t.networkdevicetypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -2,6 +2,7 @@
<div>
<div class="page-header">
<h2>PC Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add PC Type</button>
</div>
@@ -15,19 +16,25 @@
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="pt in pcTypes" :key="pt.computertypeid">
<tr v-for="pt in visiblePcTypes" :key="pt.computertypeid">
<td>{{ pt.computertype }}</td>
<td class="cell-truncate" :title="pt.description">{{ pt.description || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(pt.color)">{{ pt.color || 'auto' }}</span>
</td>
<td class="actions">
<span v-if="pt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(pt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(pt)">Delete</button>
</td>
</tr>
<tr v-if="pcTypes.length === 0">
<td colspan="3" style="text-align: center; color: var(--text-light);">
<tr v-if="visiblePcTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
@@ -53,6 +60,13 @@
<label for="description">Description</label>
<textarea id="description" v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<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">
@@ -68,10 +82,14 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { computersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const pcTypes = ref([])
const showInactive = ref(false)
const visiblePcTypes = computed(() => showInactive.value ? pcTypes.value : pcTypes.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
@@ -79,14 +97,14 @@ const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ computertype: '', description: '' })
const form = ref({ computertype: '', description: '', color: '', isactive: true })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.list({ perpage: 100 })
const response = await computersApi.types.list({ perpage: 200, active: false })
pcTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading PC types:', err)
@@ -98,14 +116,24 @@ async function loadData() {
function openModal(item = null) {
editing.value = item
form.value = item
? { computertype: item.computertype || '', description: item.description || '' }
: { computertype: '', description: '' }
? { computertype: item.computertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { computertype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function deleteType(pt) {
if (!confirm(`Delete PC type "${pt.computertype}"?`)) return
try {
await computersApi.types.remove(pt.computertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
async function save() {
error.value = ''
saving.value = true

View File

@@ -0,0 +1,183 @@
<template>
<div>
<div class="page-header">
<h2>Printer Drivers</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Driver</button>
</div>
<div class="card">
<p class="hint">
Each driver is a name + a link to the driver package - an SMB path
(<code>\\server\share\driver</code>) or an HTTP URL. HTTP links open;
SMB paths are shown for copy-paste (browsers block file:// SMB).
</p>
<div v-if="loading" class="muted">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Name</th>
<th>Printer Model</th>
<th>Location</th>
<th>Description</th>
<th>Active</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="d in visibleItems" :key="d.driverid">
<td><strong>{{ d.name }}</strong></td>
<td>{{ d.modelname || '-' }}</td>
<td>
<a v-if="isHttp(d.location)" :href="d.location" target="_blank" class="mono">{{ d.location }}</a>
<span v-else class="mono">{{ d.location }}</span>
</td>
<td class="cell-truncate" :title="d.description">{{ d.description || '-' }}</td>
<td>
<span class="badge" :class="d.isactive ? 'badge-success' : 'badge-secondary'">{{ d.isactive ? 'yes' : 'no' }}</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(d)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteDriver(d)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">No drivers</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' }} Driver</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Name *</label>
<input v-model="form.name" type="text" class="form-control" maxlength="150" required />
</div>
<div class="form-group">
<label>Printer Model <span class="hint">(links driver to a model so it shows on matching printers)</span></label>
<select v-model="form.modelnumberid" class="form-control">
<option :value="null">-- none --</option>
<option v-for="m in models" :key="m.modelnumberid" :value="m.modelnumberid">
{{ m.modelnumber }}<template v-if="m.vendorname"> ({{ m.vendorname }})</template>
</option>
</select>
</div>
<div class="form-group">
<label>Location * <span class="hint">(SMB path or HTTP URL)</span></label>
<input v-model="form.location" type="text" class="form-control" maxlength="500"
placeholder="\\server\share\driver or https://..." required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="2"></textarea>
</div>
<div class="form-group">
<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 { printersApi } from '../../api'
const items = ref([])
const models = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ name: '', location: '', description: '', modelnumberid: null, isactive: true })
function isHttp(loc) {
return typeof loc === 'string' && /^https?:\/\//i.test(loc)
}
onMounted(() => { loadData(); loadModels() })
async function loadData() {
loading.value = true
try {
const response = await printersApi.drivers.list({ active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading drivers:', err)
} finally {
loading.value = false
}
}
async function loadModels() {
try {
const response = await printersApi.modelSupplies.listModels({ perpage: 100 })
models.value = response.data.data || []
} catch (err) {
console.error('Error loading models:', err)
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { name: item.name || '', location: item.location || '', description: item.description || '', modelnumberid: item.modelnumberid || null, isactive: item.isactive !== false }
: { name: '', location: '', description: '', modelnumberid: null, isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await printersApi.drivers.update(editing.value.driverid, form.value)
} else {
await printersApi.drivers.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteDriver(d) {
if (!confirm(`Delete driver "${d.name}"?`)) return
try {
await printersApi.drivers.delete(d.driverid)
loadData()
} 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; word-break: break-all; }
</style>

View File

@@ -0,0 +1,142 @@
<template>
<div>
<div class="page-header">
<h2>Printer Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Printer Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Printer Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.printertypeid">
<td>{{ t.printertype }}</td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No printer types found</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' }} Printer Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Printer Type *</label>
<input v-model="form.printertype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<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 { printersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ printertype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await printersApi.types.list({ perpage: 200, active: false })
items.value = response.data.data || []
} catch (err) {
console.error('Error loading printer types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { printertype: item.printertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { printertype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await printersApi.types.update(editing.value.printertypeid, form.value)
} else {
await printersApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete printer type "${t.printertype}"?`)) return
try {
await printersApi.types.remove(t.printertypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>

View File

@@ -0,0 +1,146 @@
<template>
<div>
<div class="page-header">
<h2>Relationship Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add Relationship Type</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>Relationship Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="t in visibleItems" :key="t.relationshiptypeid">
<td><span class="badge" :style="colorStyle(t.color)">{{ t.relationshiptype }}</span></td>
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
<td><span class="mono">{{ t.color || 'auto' }}</span></td>
<td class="actions">
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
</td>
</tr>
<tr v-if="visibleItems.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">No relationship types found</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' }} Relationship Type</h3></div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label>Relationship Type *</label>
<input v-model="form.relationshiptype" type="text" class="form-control" required />
</div>
<div class="form-group">
<label>Description</label>
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<label>Color <span class="hint">(relationship badges; blank = auto)</span></label>
<ColorSwatchPicker v-model="form.color" />
</div>
<div class="form-group">
<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 { relationshipTypesApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
const items = ref([])
const showInactive = ref(false)
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
const loading = ref(true)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ relationshiptype: '', description: '', color: '', isactive: true })
onMounted(loadData)
async function loadData() {
loading.value = true
try {
const response = await relationshipTypesApi.list()
items.value = response.data.data || []
} catch (err) {
console.error('Error loading relationship types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { relationshiptype: item.relationshiptype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { relationshiptype: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await relationshipTypesApi.update(editing.value.relationshiptypeid, form.value)
} else {
await relationshipTypesApi.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = err.response?.data?.error?.message || err.response?.data?.message || 'Failed to save'
} finally {
saving.value = false
}
}
async function deleteType(t) {
if (!confirm(`Delete relationship type "${t.relationshiptype}"?`)) return
try {
await relationshipTypesApi.remove(t.relationshiptypeid)
loadData()
} catch (err) {
alert(err.response?.data?.error?.message || err.response?.data?.message || 'Failed to delete')
}
}
</script>
<style scoped>
.mono { font-family: monospace; font-size: 0.85rem; }
</style>

View File

@@ -1,212 +1,92 @@
<template>
<div class="settings-page">
<h1>Settings</h1>
<div class="settings-search">
<input v-model="search" type="text" class="search-input"
placeholder="Search settings (vendors, vlans, plugins...)" />
</div>
<!-- Search results: flat grid of matches across all groups -->
<div v-if="search.trim()" class="settings-grid">
<router-link
v-for="card in searchResults"
:key="card.to"
:to="card.to"
class="settings-card"
>
<div class="card-icon"><component :is="card.icon" :size="28" /></div>
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
</router-link>
<p v-if="!searchResults.length" class="no-results">No matching settings</p>
</div>
<!-- Normal: group tabs on the left, that group's cards on the right -->
<div v-else class="settings-layout">
<nav class="settings-tabs">
<button
v-for="group in groups"
:key="group.title"
class="settings-tab"
:class="{ active: activeGroup === group.title }"
@click="activeGroup = group.title"
>{{ group.title }}</button>
</nav>
<div class="settings-grid">
<router-link
v-for="card in activeCards"
:key="card.to"
:to="card.to"
class="settings-card"
>
<div class="card-icon"><component :is="card.icon" :size="28" /></div>
<h3>{{ card.title }}</h3>
<p>{{ card.description }}</p>
</router-link>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle } from 'lucide-vue-next'
// Settings grouped by purpose so the index stays scannable as it grows.
const groups = [
{
title: 'Asset Reference Data',
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors' },
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
],
},
{
title: 'Locations & Organization',
cards: [
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
],
},
{
title: 'Network',
cards: [
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
],
},
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},
{
title: 'Access & Audit',
cards: [
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
],
},
]
const search = ref('')
const activeGroup = ref(groups[0].title)
const activeCards = computed(() =>
groups.find(g => g.title === activeGroup.value)?.cards || [])
// Flat search across every card's title + description.
const searchResults = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return []
return groups.flatMap(g => g.cards).filter(c =>
c.title.toLowerCase().includes(term) ||
c.description.toLowerCase().includes(term))
})
</script>
<style scoped>
.settings-page h1 {
margin-bottom: 1.5rem;
}
.settings-search {
margin-bottom: 1rem;
}
.search-input {
width: 100%;
max-width: 420px;
padding: 0.5rem 0.75rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
}
.settings-layout {
display: flex;
gap: 1.5rem;
align-items: flex-start;
}
.settings-tabs {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 200px;
position: sticky;
top: 1rem;
}
.settings-tab {
text-align: left;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 6px;
background: transparent;
color: var(--text);
cursor: pointer;
font-size: 0.95rem;
}
.settings-tab:hover { background: var(--bg); }
.settings-tab.active { background: var(--primary); color: #fff; }
.no-results {
color: var(--text-light);
font-size: 0.9rem;
}
.settings-grid {
flex: 1;
min-width: 0;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
gap: 1rem;
}
.settings-card {
display: block;
padding: 1.5rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.2s, border-color 0.2s;
}
.settings-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0,0,0,0.15);
}
.card-icon {
font-size: 2rem;
margin-bottom: 0.5rem;
}
.settings-card h3 {
margin: 0 0 0.5rem 0;
color: var(--text);
}
.settings-card p {
margin: 0;
color: var(--text-light);
font-size: 0.9rem;
}
</style>
<template>
<div class="settings-landing">
<p class="landing-intro">
Pick a section from the left, or choose one below.
</p>
<section v-for="group in groups" :key="group.title" class="landing-section">
<h2 class="section-heading">{{ group.title }}</h2>
<div class="landing-grid">
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="landing-card"
>
<div class="card-head">
<span class="card-icon"><component :is="card.icon" :size="18" /></span>
<h3>{{ card.title }}</h3>
</div>
<p>{{ card.description }}</p>
</router-link>
</div>
</section>
</div>
</template>
<script setup>
import { settingsGroups as groups } from './settingsNav'
</script>
<style scoped>
.landing-intro {
margin: 0 0 1.5rem 0;
color: var(--text-light);
}
.landing-section {
margin-bottom: 1.75rem;
}
.section-heading {
margin: 0 0 0.6rem 0;
padding-bottom: 0.35rem;
border-bottom: 1px solid var(--border);
font-size: 0.72rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
}
.landing-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
gap: 0.7rem;
}
.landing-card {
display: block;
padding: 0.8rem 0.9rem;
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 8px;
text-decoration: none;
color: inherit;
transition: box-shadow 0.15s, border-color 0.15s;
}
.landing-card:hover {
border-color: var(--primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
}
.card-head {
display: flex;
align-items: center;
gap: 0.45rem;
margin-bottom: 0.3rem;
}
.card-icon {
display: inline-flex;
color: var(--primary);
}
.landing-card h3 {
margin: 0;
font-size: 0.92rem;
color: var(--text);
}
.landing-card p {
margin: 0;
color: var(--text-light);
font-size: 0.82rem;
line-height: 1.35;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<div class="settings-shell">
<!-- Left rail: grouped, searchable nav that stays put while the right pane swaps -->
<aside class="settings-rail">
<h1 class="rail-title">Settings</h1>
<div class="rail-search">
<input v-model="search" type="text" class="search-input"
placeholder="Search settings..." />
</div>
<nav class="rail-nav">
<template v-for="group in visibleGroups" :key="group.title">
<div class="rail-group-heading">{{ group.title }}</div>
<router-link
v-for="card in group.cards"
:key="card.to"
:to="card.to"
class="rail-link"
:class="{ active: isActive(card.to) }"
>
<span class="rail-icon"><component :is="card.icon" :size="16" /></span>
<span class="rail-label">{{ card.title }}</span>
</router-link>
</template>
<p v-if="!visibleGroups.length" class="rail-empty">No matching settings</p>
</nav>
</aside>
<!-- Right pane: the selected settings page renders here -->
<section class="settings-content">
<router-view />
</section>
</div>
</template>
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { settingsGroups as groups } from './settingsNav'
const route = useRoute()
const search = ref('')
// Filter the rail by title/description; drop groups that end up empty.
const visibleGroups = computed(() => {
const term = search.value.trim().toLowerCase()
if (!term) return groups
return groups
.map(g => ({
title: g.title,
cards: g.cards.filter(c =>
c.title.toLowerCase().includes(term) ||
c.description.toLowerCase().includes(term)),
}))
.filter(g => g.cards.length)
})
// A rail link is active when the current path matches its base path
// (ignoring query, so /settings/system?tab=map and /settings/system stay distinct
// only by their own comparison below).
function isActive(to) {
const base = to.split('?')[0]
const query = to.includes('?') ? to.split('?')[1] : ''
if (route.path !== base) return false
// Floor Map shares /settings/system with System Settings; disambiguate by tab.
if (base === '/settings/system') {
const wantMap = query.includes('tab=map')
const onMap = route.query.tab === 'map'
return wantMap === onMap
}
return true
}
</script>
<style scoped>
.settings-shell {
display: flex;
align-items: flex-start;
gap: 1.5rem;
}
.settings-rail {
flex: 0 0 250px;
position: sticky;
top: 1rem;
max-height: calc(100vh - 2rem);
overflow-y: auto;
}
.rail-title {
margin: 0 0 0.75rem 0;
font-size: 1.5rem;
}
.rail-search {
margin-bottom: 0.75rem;
}
.search-input {
width: 100%;
padding: 0.45rem 0.6rem;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg-card);
color: var(--text);
font-size: 0.85rem;
}
.rail-group-heading {
margin: 1rem 0 0.3rem 0;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-light);
}
.rail-group-heading:first-child {
margin-top: 0;
}
.rail-link {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0.55rem;
border-radius: 6px;
text-decoration: none;
color: var(--text);
font-size: 0.88rem;
border-left: 2px solid transparent;
}
.rail-link:hover {
background: var(--bg);
}
.rail-link.active {
background: var(--bg);
border-left-color: var(--primary);
color: var(--primary);
font-weight: 600;
}
.rail-icon {
display: inline-flex;
color: var(--text-light);
}
.rail-link.active .rail-icon {
color: var(--primary);
}
.rail-label {
line-height: 1.2;
}
.rail-empty {
color: var(--text-light);
font-size: 0.85rem;
}
.settings-content {
flex: 1 1 auto;
min-width: 0;
}
@media (max-width: 820px) {
.settings-shell {
flex-direction: column;
}
.settings-rail {
position: static;
flex-basis: auto;
width: 100%;
max-height: none;
}
}
</style>

View File

@@ -0,0 +1,118 @@
<template>
<div>
<div class="page-header">
<h1>Site &amp; Facility</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<div v-if="loading" class="muted">Loading...</div>
<div v-else class="form-grid">
<label v-for="s in items" :key="s.key" class="field">
<span>{{ prettyLabel(s.key) }}</span>
<input v-model="s.value" type="text" :placeholder="s.description" />
<small class="muted">{{ s.description }}</small>
</label>
<div v-if="!items.length" class="muted">No site settings found.</div>
<div class="actions">
<button class="btn btn-primary" :disabled="saving || !items.length" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
<span v-if="saved" class="muted">Saved.</span>
<span v-if="error" class="error">{{ error }}</span>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { settingsApi } from '@/api'
const items = ref([])
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
const LABELS = {
site_base_url: 'Site URL / FQDN',
facility_name: 'Facility Name',
pc_access_domain: 'PC Access Domain'
}
function prettyLabel(key) {
return LABELS[key] || key
}
async function load() {
loading.value = true
try {
const response = await settingsApi.list()
const all = response.data?.data || response.data || []
items.value = all.filter(s => s.category === 'site')
} catch (err) {
console.error('Error loading site settings:', err)
error.value = 'Could not load settings.'
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
for (const s of items.value) {
await settingsApi.update(s.key, s.value)
}
saved.value = true
} catch (err) {
error.value = err.response?.data?.error?.message || 'Save failed.'
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<style scoped>
.form-grid {
display: flex;
flex-direction: column;
gap: 16px;
max-width: 640px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.field > span {
font-weight: 600;
}
.field input {
padding: 8px 10px;
border: 1px solid var(--border);
border-radius: 6px;
background: var(--bg);
color: var(--text);
}
.muted {
color: var(--text-light);
font-size: 0.85rem;
}
.error {
color: var(--danger);
}
.actions {
display: flex;
align-items: center;
gap: 12px;
margin-top: 8px;
}
</style>

View File

@@ -0,0 +1,209 @@
<template>
<div>
<div class="page-header">
<h1>Slides</h1>
<router-link to="/settings" class="btn btn-secondary">Back to Settings</router-link>
</div>
<div class="card">
<div class="tabs">
<button
v-for="s in surfaces"
:key="s.key"
class="tab"
:class="{ active: surface === s.key }"
@click="switchSurface(s.key)"
>{{ s.label }}</button>
</div>
<div class="toolbar">
<label class="btn btn-primary upload-btn">
{{ uploading ? 'Uploading...' : 'Upload Images' }}
<input type="file" accept="image/*" multiple hidden :disabled="uploading" @change="onUpload" />
</label>
<button
class="btn btn-danger"
:disabled="!selected.length"
@click="deleteSelected"
>Delete Selected ({{ selected.length }})</button>
<span class="hint">Order top-to-bottom is play order. Images show on the {{ surfaceLabel }}.</span>
</div>
<div v-if="loading" class="muted">Loading...</div>
<div v-else-if="!slides.length" class="muted empty">No slides yet. Upload some images.</div>
<div v-else class="slide-grid">
<div v-for="(slide, idx) in slides" :key="slide.slideid" class="slide-tile">
<label class="pick">
<input type="checkbox" :value="slide.filename" v-model="selected" />
</label>
<img :src="slide.url" :alt="slide.filename" class="thumb" />
<div class="slide-meta">
<span class="fname" :title="slide.filename">{{ slide.filename }}</span>
<div class="move">
<button class="btn btn-sm btn-secondary" :disabled="idx === 0" @click="move(idx, -1)" title="Move up">&uarr;</button>
<button class="btn btn-sm btn-secondary" :disabled="idx === slides.length - 1" @click="move(idx, 1)" title="Move down">&darr;</button>
</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { slidesApi } from '@/api'
const surfaces = [
{ key: 'lobby', label: 'Lobby Display' },
{ key: 'shopfloor', label: 'Shopfloor Screensaver' }
]
const surface = ref('lobby')
const slides = ref([])
const selected = ref([])
const loading = ref(true)
const uploading = ref(false)
const surfaceLabel = computed(() => surfaces.find(s => s.key === surface.value)?.label || surface.value)
async function load() {
loading.value = true
selected.value = []
try {
const response = await slidesApi.list(surface.value)
slides.value = response.data.data || []
} catch (err) {
console.error('Error loading slides:', err)
} finally {
loading.value = false
}
}
function switchSurface(key) {
if (key === surface.value) return
surface.value = key
load()
}
async function onUpload(event) {
const files = Array.from(event.target.files || [])
if (!files.length) return
uploading.value = true
try {
const formData = new FormData()
files.forEach(f => formData.append('files', f))
await slidesApi.upload(surface.value, formData)
await load()
} catch (err) {
console.error('Upload failed:', err)
} finally {
uploading.value = false
event.target.value = ''
}
}
async function move(idx, delta) {
const target = idx + delta
if (target < 0 || target >= slides.value.length) return
const arr = slides.value.slice()
const [item] = arr.splice(idx, 1)
arr.splice(target, 0, item)
slides.value = arr
try {
await slidesApi.reorder(surface.value, arr.map(s => s.filename))
} catch (err) {
console.error('Reorder failed:', err)
await load()
}
}
async function deleteSelected() {
if (!selected.value.length) return
if (!confirm(`Delete ${selected.value.length} slide(s)?`)) return
try {
await slidesApi.remove(surface.value, selected.value)
await load()
} catch (err) {
console.error('Delete failed:', err)
}
}
onMounted(load)
</script>
<style scoped>
.tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
margin-bottom: 16px;
}
.tab {
padding: 10px 18px;
border: none;
background: none;
color: var(--text-light);
font-weight: 600;
cursor: pointer;
border-bottom: 3px solid transparent;
}
.tab.active {
color: var(--text);
border-bottom-color: var(--primary);
}
.toolbar {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 18px;
flex-wrap: wrap;
}
.upload-btn { position: relative; cursor: pointer; }
.hint { color: var(--text-light); font-size: 0.85rem; }
.muted { color: var(--text-light); }
.empty { padding: 30px 0; text-align: center; }
.slide-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 14px;
}
.slide-tile {
position: relative;
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
background: var(--bg);
}
.slide-tile .pick {
position: absolute;
top: 6px;
left: 6px;
background: rgba(0, 0, 0, 0.5);
border-radius: 4px;
padding: 2px 4px;
}
.thumb {
width: 100%;
height: 130px;
object-fit: cover;
display: block;
background: #000;
}
.slide-meta {
display: flex;
align-items: center;
justify-content: space-between;
gap: 6px;
padding: 6px 8px;
}
.fname {
font-size: 0.78rem;
font-family: monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.move { display: flex; gap: 4px; flex-shrink: 0; }
</style>

View File

@@ -86,21 +86,8 @@
</div>
<div class="form-group">
<label for="color">Color</label>
<div class="color-input-row">
<input
id="color"
v-model="form.color"
type="color"
class="color-picker"
/>
<input
v-model="form.color"
type="text"
class="form-control"
placeholder="#000000"
/>
</div>
<label>Color</label>
<ColorSwatchPicker v-model="form.color" />
<small class="form-hint">Used in UI to visually distinguish statuses</small>
</div>
@@ -151,6 +138,7 @@
import { ref, onMounted } from 'vue'
import { assetsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
const statuses = ref([])
const loading = ref(true)

View File

@@ -263,6 +263,79 @@
</div>
</div>
<!-- Floor Map Section -->
<div class="section-card" v-show="isVisible('map')">
<h2 class="section-title">Floor Map</h2>
<div class="setting-group">
<h3>Facility Blueprint</h3>
<p class="setting-description">
The floor-plan image and its pixel dimensions for this facility. Map
markers are positioned against these dimensions, so the width and
height must match the native size of the blueprint image. Leave the
image paths at their defaults to use the bundled sitemap.
</p>
<div class="setting-row">
<label>
<span>Blueprint image URL (light theme)</span>
<input
type="text"
v-model="settings.map_blueprint_light"
placeholder="/static/images/sitemap2025-light.png"
@blur="saveSetting('map_blueprint_light', settings.map_blueprint_light)"
:disabled="saving"
>
<small class="input-hint">Path or URL to the light-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint image URL (dark theme)</span>
<input
type="text"
v-model="settings.map_blueprint_dark"
placeholder="/static/images/sitemap2025-dark.png"
@blur="saveSetting('map_blueprint_dark', settings.map_blueprint_dark)"
:disabled="saving"
>
<small class="input-hint">Path or URL to the dark-theme floor plan</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint width (pixels)</span>
<input
type="number"
v-model="settings.map_width"
min="1"
placeholder="3300"
@blur="saveSetting('map_width', settings.map_width)"
:disabled="saving"
>
<small class="input-hint">Native pixel width of the blueprint image</small>
</label>
</div>
<div class="setting-row">
<label>
<span>Blueprint height (pixels)</span>
<input
type="number"
v-model="settings.map_height"
min="1"
placeholder="2550"
@blur="saveSetting('map_height', settings.map_height)"
:disabled="saving"
>
<small class="input-hint">Native pixel height of the blueprint image</small>
</label>
</div>
</div>
</div>
<!-- Authentication Section -->
<div class="section-card" v-show="isVisible('auth')">
<h2 class="section-title">Authentication</h2>
@@ -501,6 +574,7 @@
<script setup>
import { ref, reactive, onMounted, computed, watch } from 'vue'
import { useRoute } from 'vue-router'
import { settingsApi, computersApi } from '../../api'
import { setIdentifierFlag } from '../../composables/identifierSettings'
@@ -513,10 +587,17 @@ const SETTINGS_TABS = [
{ key: 'auth', label: 'Authentication', keywords: 'auth saml sso login users idp' },
{ key: 'identifiers', label: 'Asset Identifiers', keywords: 'identifier gauge lab maintenance fqdn hostname asset' },
{ key: 'search', label: 'Global Search', keywords: 'search results domains' },
{ key: 'map', label: 'Floor Map', keywords: 'map floor plan blueprint image facility site dimensions width height' },
{ key: 'pctype', label: 'PC Type Mapping', keywords: 'pc type mapping collector enrollment shopfloor computer type' },
]
const settingsSearch = ref('')
const activeTab = ref('integrations')
const route = useRoute()
// Allow deep-linking to a tab, e.g. /settings/system?tab=map (Floor Map)
const activeTab = ref(
route.query.tab && SETTINGS_TABS.some(t => t.key === route.query.tab)
? String(route.query.tab)
: 'integrations'
)
const visibleTabs = computed(() => {
const term = settingsSearch.value.trim().toLowerCase()
@@ -556,6 +637,11 @@ const settings = reactive({
alert_recipients: '',
// Audit
audit_retention_days: 90,
// Floor map blueprint (per-facility)
map_blueprint_light: '',
map_blueprint_dark: '',
map_width: 3300,
map_height: 2550,
// SAML
saml_enabled: false,
saml_idp_metadata_url: '',

View File

@@ -0,0 +1,85 @@
// Shared settings navigation catalog.
// Used by SettingsLayout (left rail) and SettingsIndex (landing overview) so the
// grouping lives in one place.
import { Factory, MapPin, Tag, Package, Droplets, Monitor, MonitorSmartphone, Laptop, Cog, Building, Globe, Link, Settings, FileText, Users, Puzzle, Bell, Network, Home, Wrench, Printer, Router, Palette, SlidersHorizontal } from 'lucide-vue-next'
export const settingsGroups = [
{
title: 'Site & Facility',
cards: [
{ to: '/settings/site', icon: Home, title: 'Site & Facility', description: 'Site URL/FQDN, facility name, and PC access domain' },
{ to: '/settings/system?tab=map', icon: MapPin, title: 'Floor Map', description: 'Facility floor-plan blueprint and dimensions' },
],
},
{
title: 'General Reference',
cards: [
{ to: '/settings/vendors', icon: Factory, title: 'Vendors', description: 'Manage equipment vendors and manufacturers' },
{ to: '/settings/models', icon: Package, title: 'Models', description: 'Manage equipment models by vendor' },
{ to: '/settings/statuses', icon: Tag, title: 'Statuses', description: 'Manage asset status types' },
{ to: '/settings/relationshiptypes', icon: Link, title: 'Relationship Types', description: 'Manage asset relationship types (Controls, Contains...) + colors' },
{ to: '/settings/assettypes', icon: Palette, title: 'Asset Type Colors', description: 'Map colors for the top-level asset categories' },
{ to: '/settings/customfields', icon: SlidersHorizontal, title: 'Custom Fields', description: 'Define extra attributes per asset type (shown on detail + forms)' },
],
},
{
title: 'PCs',
cards: [
{ to: '/settings/pctypes', icon: Laptop, title: 'PC Types', description: 'Manage PC form factors + map colors' },
{ to: '/settings/operatingsystems', icon: Cog, title: 'Operating Systems', description: 'Manage OS versions and EOL dates' },
{ to: '/settings/accessprotocols', icon: Network, title: 'PC Access Protocols', description: 'Remote-access protocols (VNC, RDP, WinRM) and link templates' },
],
},
{
title: 'Printers',
cards: [
{ to: '/settings/printertypes', icon: Printer, title: 'Printer Types', description: 'Manage printer subtypes + map colors' },
{ to: '/settings/modelsupplies', icon: Droplets, title: 'Model Toners and Supplies', description: 'Map toner, drum, and waste part numbers to printer models' },
{ to: '/settings/printerdrivers', icon: Printer, title: 'Printer Drivers', description: 'Named SMB / HTTP links to printer driver packages' },
],
},
{
title: 'Equipment',
cards: [
{ to: '/settings/equipmenttypes', icon: Wrench, title: 'Equipment Types', description: 'Manage equipment subtypes + map colors' },
{ to: '/settings/machinetypes', icon: Monitor, title: 'Machine Types', description: 'Manage machine type categories' },
],
},
{
title: 'Locations & Organization',
cards: [
{ to: '/settings/locations', icon: MapPin, title: 'Locations', description: 'Manage physical locations and sites' },
{ to: '/settings/locationtypes', icon: Tag, title: 'Location Types', description: 'Manage location types + colors' },
{ to: '/settings/businessunits', icon: Building, title: 'Business Units', description: 'Manage organizational units' },
],
},
{
title: 'Network',
cards: [
{ to: '/settings/networktypes', icon: Router, title: 'Network Device Types', description: 'Manage network device subtypes + map colors' },
{ to: '/settings/vlans', icon: Globe, title: 'VLANs', description: 'Manage virtual LANs' },
{ to: '/settings/subnets', icon: Link, title: 'Subnets', description: 'Manage IP subnets and DHCP' },
],
},
{
title: 'Displays & Kiosks',
cards: [
{ to: '/settings/dashboarddefaults', icon: MonitorSmartphone, title: 'Dashboard Defaults', description: 'Map kiosk IPs to a default business unit' },
{ to: '/settings/notificationtypes', icon: Bell, title: 'Notification Types', description: 'Manage notification types, display styles, colors, and auto-expiry' },
],
},
{
title: 'System',
cards: [
{ to: '/settings/system', icon: Settings, title: 'System Settings', description: 'Integrations, identifiers, search, and PC-type mapping' },
{ to: '/settings/plugins', icon: Puzzle, title: 'Plugins', description: 'Enable or disable installed plugins' },
],
},
{
title: 'Access & Audit',
cards: [
{ to: '/settings/users', icon: Users, title: 'Users & Roles', description: 'Manage user accounts and permissions' },
{ to: '/settings/auditlogs', icon: FileText, title: 'Audit Logs', description: 'View system activity and change history' },
],
},
]