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:
170
frontend/src/views/settings/SettingsLayout.vue
Normal file
170
frontend/src/views/settings/SettingsLayout.vue
Normal 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>
|
||||
Reference in New Issue
Block a user