ADR-013 Phase 4: all 13 plugin frontends relocated + staging machinery

This commit is contained in:
2026-07-18 23:56:32 -04:00
parent d0c31d1c97
commit d1592b84c7
66 changed files with 201 additions and 198 deletions

View File

@@ -1,220 +0,0 @@
<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'
import { apiError } from '../../utils/apiError'
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 = apiError(err, '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

@@ -1,157 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Measuring Tool Types</h2>
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
<button class="btn btn-primary" @click="openModal()">+ Add 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>Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="mt in visibleTypes" :key="mt.measuringtooltypeid">
<td>{{ mt.name }}</td>
<td class="cell-truncate" :title="mt.description">{{ mt.description || '-' }}</td>
<td>
<span class="badge" :style="colorStyle(mt.color)">{{ mt.color || 'auto' }}</span>
</td>
<td class="actions">
<span v-if="mt.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
<button class="btn btn-secondary btn-sm" @click="openModal(mt)">Edit</button>
<button class="btn btn-danger btn-sm" @click="deleteType(mt)">Delete</button>
</td>
</tr>
<tr v-if="visibleTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No measuring tool types found
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Type' : 'Add Type' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="name">Type *</label>
<input id="name" v-model="form.name" type="text" class="form-control" required />
</div>
<div class="form-group">
<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">
<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 { measuringtoolsApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
const types = ref([])
const showInactive = ref(false)
const visibleTypes = computed(() => showInactive.value ? types.value : types.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: '', description: '', color: '', isactive: true })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await measuringtoolsApi.types.list({ perpage: 200, active: false })
types.value = response.data.data || []
} catch (err) {
console.error('Error loading measuring tool types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { name: item.name || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
: { name: '', description: '', color: '', isactive: true }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function deleteType(mt) {
if (!confirm(`Delete measuring tool type "${mt.name}"?`)) return
try {
await measuringtoolsApi.types.remove(mt.measuringtooltypeid)
loadData()
} catch (err) {
toast.error(apiError(err, 'Failed to delete'))
}
}
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await measuringtoolsApi.types.update(editing.value.measuringtooltypeid, form.value)
} else {
await measuringtoolsApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
</script>

View File

@@ -1,438 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Model Toners and Supplies</h2>
<span class="subtitle">Map toner, drum, and waste part numbers to each printer model.</span>
</div>
<div class="supplies-layout">
<!-- model picker -->
<div class="card model-panel">
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search models..."
@input="debouncedSearch"
/>
<label class="withsupplies">
<input v-model="onlyWithSupplies" type="checkbox" @change="loadModels" />
With supplies only
</label>
</div>
<div v-if="loadingModels" class="loading">Loading...</div>
<div v-else class="model-list">
<button
v-for="m in models"
:key="m.modelnumberid"
class="model-row"
:class="{ active: selectedModel && selectedModel.modelnumberid === m.modelnumberid }"
@click="selectModel(m)"
>
<span class="model-name">{{ m.modelnumber }}</span>
<span class="model-meta">
<span class="vendor">{{ m.vendor || 'No vendor' }}</span>
<span class="badge" :class="m.supplycount ? 'badge-success' : ''">
{{ m.supplycount }}
</span>
</span>
</button>
<p v-if="!models.length" class="empty-state">No models match.</p>
</div>
</div>
<!-- supplies for the selected model -->
<div class="card supply-panel">
<div v-if="!selectedModel" class="empty-state">
Select a model to view and edit its supplies.
</div>
<template v-else>
<div class="panel-header">
<h3>{{ selectedModel.modelnumber }}</h3>
<button class="btn btn-primary" @click="openModal()">+ Add Supply</button>
</div>
<div v-if="loadingSupplies" class="loading">Loading...</div>
<div v-else class="table-container">
<table v-if="supplies.length">
<thead>
<tr>
<th>Type</th>
<th>Color</th>
<th>Tier</th>
<th>Part Number</th>
<th>Name</th>
<th>Yield</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="s in supplies" :key="s.modelsupplyid">
<td>{{ s.supplytype }}</td>
<td>
<span v-if="s.color !== 'none'" class="color-dot" :class="'dot-' + s.color"></span>
{{ s.color === 'none' ? '-' : s.color }}
</td>
<td>{{ s.capacitytier }}</td>
<td class="partnumber">{{ s.partnumber }}</td>
<td>{{ s.marketingname || '-' }}</td>
<td>{{ s.pageyield ? s.pageyield.toLocaleString() : '-' }}</td>
<td class="actions">
<button class="btn btn-sm btn-secondary" @click="openModal(s)">Edit</button>
<button class="btn btn-sm btn-danger" @click="remove(s)">Delete</button>
</td>
</tr>
</tbody>
</table>
<p v-else class="empty-state">No supplies mapped yet. Add one above.</p>
</div>
</template>
</div>
</div>
<!-- add / edit modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<h3>{{ editing ? 'Edit Supply' : 'Add Supply' }}</h3>
<div class="form-grid">
<label>
Supply Type
<select v-model="form.supplytype" class="form-control" @change="onSupplyTypeChange">
<option v-for="t in meta.supplytypes" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label>
Color
<select v-model="form.color" class="form-control">
<option v-for="c in meta.colors" :key="c" :value="c">{{ c }}</option>
</select>
<small v-if="form.supplytype !== 'toner'" class="field-hint">Drums/waste are often colorless - leave as "none" unless per-color</small>
</label>
<label>
Capacity Tier
<select v-model="form.capacitytier" class="form-control">
<option v-for="t in meta.capacitytiers" :key="t" :value="t">{{ t }}</option>
</select>
</label>
<label>
Part Number
<input v-model="form.partnumber" type="text" class="form-control" placeholder="e.g. W2020A" />
</label>
<label class="full">
Marketing Name
<input v-model="form.marketingname" type="text" class="form-control" placeholder="e.g. 414A Black" />
</label>
<label>
Page Yield
<input v-model.number="form.pageyield" type="number" class="form-control" placeholder="e.g. 2400" />
</label>
</div>
<label class="full">
Notes
<input v-model="form.notes" type="text" class="form-control" />
</label>
<p v-if="formError" class="text-danger">{{ formError }}</p>
<div class="modal-actions">
<button class="btn btn-secondary" @click="closeModal">Cancel</button>
<button class="btn btn-primary" :disabled="saving" @click="save">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { printersApi } from '@/api'
import { apiError } from '../../utils/apiError'
const models = ref([])
const loadingModels = ref(true)
const search = ref('')
const onlyWithSupplies = ref(false)
const selectedModel = ref(null)
const supplies = ref([])
const loadingSupplies = ref(false)
const meta = ref({ supplytypes: [], colors: [], capacitytiers: [] })
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const formError = ref('')
const form = ref({})
let searchTimer = null
function debouncedSearch() {
clearTimeout(searchTimer)
searchTimer = setTimeout(loadModels, 300)
}
async function loadModels() {
loadingModels.value = true
try {
const params = { per_page: 100 }
if (search.value) params.search = search.value
if (onlyWithSupplies.value) params.withsupplies = 'true'
const response = await printersApi.modelSupplies.listModels(params)
models.value = response.data.data || []
} finally {
loadingModels.value = false
}
}
async function selectModel(model) {
selectedModel.value = model
loadingSupplies.value = true
try {
const response = await printersApi.modelSupplies.list(model.modelnumberid)
supplies.value = response.data.data.supplies || []
} finally {
loadingSupplies.value = false
}
}
function openModal(supply = null) {
editing.value = supply
formError.value = ''
if (supply) {
form.value = { ...supply }
} else {
form.value = {
supplytype: 'toner',
color: 'black',
capacitytier: 'standard',
partnumber: '',
marketingname: '',
pageyield: null,
notes: ''
}
}
showModal.value = true
}
function onSupplyTypeChange() {
// toner is color-specific; drum/waste/maintenance usually are not. Default
// the color sensibly on type change but keep it editable (per-color drums).
if (form.value.supplytype !== 'toner') {
if (!form.value.color || form.value.color === 'black') form.value.color = 'none'
} else if (form.value.color === 'none') {
form.value.color = 'black'
}
}
function closeModal() {
showModal.value = false
editing.value = null
}
async function save() {
if (!form.value.partnumber) {
formError.value = 'Part number is required.'
return
}
saving.value = true
formError.value = ''
try {
if (editing.value) {
await printersApi.modelSupplies.update(editing.value.modelsupplyid, form.value)
} else {
await printersApi.modelSupplies.create(selectedModel.value.modelnumberid, form.value)
}
closeModal()
await selectModel(selectedModel.value)
await loadModels()
} catch (err) {
formError.value = apiError(err, 'Save failed.')
} finally {
saving.value = false
}
}
async function remove(supply) {
if (!confirm(`Delete ${supply.partnumber}?`)) return
await printersApi.modelSupplies.delete(supply.modelsupplyid)
await selectModel(selectedModel.value)
await loadModels()
}
onMounted(async () => {
const metaResponse = await printersApi.modelSupplies.meta()
meta.value = metaResponse.data.data
await loadModels()
})
</script>
<style scoped>
.subtitle {
color: var(--text-light);
font-size: 0.9rem;
}
.supplies-layout {
display: grid;
grid-template-columns: 340px 1fr;
gap: 1.5rem;
align-items: start;
}
.model-panel {
padding: 1rem;
}
.withsupplies {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.85rem;
color: var(--text-light);
white-space: nowrap;
}
.model-list {
display: flex;
flex-direction: column;
max-height: 70vh;
overflow-y: auto;
}
.model-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.5rem;
background: none;
border: none;
border-bottom: 1px solid var(--border);
cursor: pointer;
text-align: left;
color: var(--text);
}
.model-row:hover {
background: var(--bg);
}
.model-row.active {
background: var(--primary);
color: white;
}
.model-row.active .vendor {
color: white;
}
.model-name {
font-weight: 600;
font-size: 0.9rem;
}
.model-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.vendor {
font-size: 0.8rem;
color: var(--text-light);
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.partnumber {
font-family: monospace;
}
.color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
margin-right: 0.35rem;
vertical-align: middle;
}
.dot-black { background: #222; }
.dot-cyan { background: #00b7eb; }
.dot-magenta { background: #d633a0; }
.dot-yellow { background: #f0c000; }
.actions {
display: flex;
gap: 0.4rem;
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.modal {
/* bg-card is intentionally translucent in dark mode; modals must be opaque */
background: var(--bg-card-solid);
padding: 1.5rem;
border-radius: 8px;
width: 520px;
max-width: 92vw;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin: 1rem 0;
}
.form-grid label,
.modal > label {
display: flex;
flex-direction: column;
gap: 0.25rem;
font-size: 0.85rem;
color: var(--text-light);
}
.form-grid .full,
.modal > label.full {
grid-column: 1 / -1;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 1rem;
}
.field-hint {
font-size: 0.75rem;
color: var(--text-light);
margin-top: 0.2rem;
}
.text-danger {
color: var(--danger);
}
.empty-state {
text-align: center;
padding: 2rem;
color: var(--text-light);
}
</style>

View File

@@ -1,228 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Operating Systems</h2>
<button class="btn btn-primary" @click="openModal()">+ Add OS</button>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>OS Name</th>
<th>Version</th>
<th>Architecture</th>
<th>End of Life</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="os in items" :key="os.osid">
<td>{{ os.osname }}</td>
<td>{{ os.osversion || '-' }}</td>
<td>{{ os.architecture || '-' }}</td>
<td>
<span v-if="os.endoflife" :class="{ 'text-danger': isPastEol(os.endoflife) }">
{{ os.endoflife }}
</span>
<span v-else>-</span>
</td>
<td class="actions">
<button class="btn btn-secondary btn-sm" @click="openModal(os)">Edit</button>
<button class="btn btn-danger btn-sm" @click="confirmDelete(os)">Delete</button>
</td>
</tr>
<tr v-if="items.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No operating systems found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit Operating System' : 'Add Operating System' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="osname">OS Name *</label>
<input id="osname" v-model="form.osname" type="text" class="form-control" required placeholder="Windows 11" />
</div>
<div class="form-row">
<div class="form-group">
<label for="osversion">Version</label>
<input id="osversion" v-model="form.osversion" type="text" class="form-control" placeholder="23H2" />
</div>
<div class="form-group">
<label for="architecture">Architecture</label>
<select id="architecture" v-model="form.architecture" class="form-control">
<option value="">Select...</option>
<option value="x64">x64</option>
<option value="x86">x86</option>
<option value="ARM64">ARM64</option>
</select>
</div>
</div>
<div class="form-group">
<label for="endoflife">End of Life Date</label>
<input id="endoflife" v-model="form.endoflife" type="date" class="form-control" />
</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>
<!-- Delete Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header"><h3>Delete Operating System</h3></div>
<div class="modal-body">
<p>Are you sure you want to delete <strong>{{ toDelete?.osname }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteItem">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { operatingsystemsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const items = ref([])
const loading = ref(true)
const { page, setPage } = useListQuery({ onChange: loadData })
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const toDelete = ref(null)
const form = ref({ osname: '', osversion: '', architecture: '', endoflife: '' })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await operatingsystemsApi.list({ page: page.value, perpage: perPage.value })
items.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading operating systems:', err)
} finally {
loading.value = false
}
}
function goToPage(p) { setPage(p); loadData() }
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadData()
}
function isPastEol(date) {
return new Date(date) < new Date()
}
function openModal(item = null) {
editing.value = item
form.value = item ? {
osname: item.osname || '',
osversion: item.osversion || '',
architecture: item.architecture || '',
endoflife: item.endoflife || ''
} : { osname: '', osversion: '', architecture: '', endoflife: '' }
error.value = ''
showModal.value = true
}
function closeModal() { showModal.value = false; editing.value = null }
async function save() {
error.value = ''
saving.value = true
try {
const data = { ...form.value }
if (!data.endoflife) data.endoflife = null
if (editing.value) {
await operatingsystemsApi.update(editing.value.osid, data)
} else {
await operatingsystemsApi.create(data)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
function confirmDelete(item) { toDelete.value = item; showDeleteModal.value = true }
async function deleteItem() {
try {
await operatingsystemsApi.delete(toDelete.value.osid)
showDeleteModal.value = false
toDelete.value = null
loadData()
} catch (err) {
toast.error('Failed to delete')
}
}
</script>
<style scoped>
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.text-danger {
color: var(--danger);
font-weight: 500;
}
</style>

View File

@@ -1,157 +0,0 @@
<template>
<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>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>PC Type</th>
<th>Description</th>
<th>Color</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<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="visiblePcTypes.length === 0">
<td colspan="4" style="text-align: center; color: var(--text-light);">
No PC types found
</td>
</tr>
</tbody>
</table>
</div>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editing ? 'Edit PC Type' : 'Add PC Type' }}</h3>
</div>
<form @submit.prevent="save">
<div class="modal-body">
<div class="form-group">
<label for="computertype">PC Type *</label>
<input id="computertype" v-model="form.computertype" type="text" class="form-control" required />
</div>
<div class="form-group">
<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">
<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 { computersApi } from '../../api'
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
import { colorStyle } from '@/utils/colorStyle'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
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)
const editing = ref(null)
const saving = ref(false)
const error = ref('')
const form = ref({ computertype: '', description: '', color: '', isactive: true })
onMounted(() => loadData())
async function loadData() {
loading.value = true
try {
const response = await computersApi.types.list({ perpage: 200, active: false })
pcTypes.value = response.data.data || []
} catch (err) {
console.error('Error loading PC types:', err)
} finally {
loading.value = false
}
}
function openModal(item = null) {
editing.value = item
form.value = item
? { 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) {
toast.error(apiError(err, 'Failed to delete'))
}
}
async function save() {
error.value = ''
saving.value = true
try {
if (editing.value) {
await computersApi.types.update(editing.value.computertypeid, form.value)
} else {
await computersApi.types.create(form.value)
}
closeModal()
loadData()
} catch (err) {
error.value = apiError(err, 'Failed to save')
} finally {
saving.value = false
}
}
</script>

View File

@@ -1,186 +0,0 @@
<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'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
const toast = useToast()
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 = apiError(err, '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) {
toast.error(apiError(err, '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

@@ -1,567 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>Subnets</h2>
<button class="btn btn-primary" @click="openModal()">+ Add Subnet</button>
</div>
<!-- Filters -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search subnets..."
@input="debouncedSearch"
/>
<select v-model="vlanFilter" class="form-control" @change="loadSubnets">
<option value="">All VLANs</option>
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
</option>
</select>
<select v-model="typeFilter" class="form-control" @change="loadSubnets">
<option value="">All Types</option>
<option value="ipv4">IPv4</option>
<option value="ipv6">IPv6</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>CIDR</th>
<th>Name</th>
<th>VLAN</th>
<th>Gateway</th>
<th>DHCP</th>
<th>Location</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="subnet in subnets" :key="subnet.subnetid">
<td class="mono">{{ subnet.cidr }}</td>
<td>{{ subnet.name }}</td>
<td>
<span v-if="subnet.vlanname">
VLAN {{ subnet.vlannumber }} - {{ subnet.vlanname }}
</span>
<span v-else>-</span>
</td>
<td class="mono">{{ subnet.gatewayip || '-' }}</td>
<td>
<span class="badge" :class="subnet.dhcpenabled ? 'badge-success' : 'badge-secondary'">
{{ subnet.dhcpenabled ? 'Enabled' : 'Disabled' }}
</span>
</td>
<td>{{ subnet.locationname || '-' }}</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(subnet)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(subnet)"
>
Delete
</button>
</td>
</tr>
<tr v-if="subnets.length === 0">
<td colspan="7" style="text-align: center; color: var(--text-light);">
No subnets found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal modal-lg">
<div class="modal-header">
<h3>{{ editingSubnet ? 'Edit Subnet' : 'Add Subnet' }}</h3>
</div>
<form @submit.prevent="saveSubnet">
<div class="modal-body">
<!-- Basic Info -->
<div class="form-section">
<h4>Basic Information</h4>
<div class="form-row">
<div class="form-group">
<label for="cidr">CIDR *</label>
<input
id="cidr"
v-model="form.cidr"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.0/24"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="vlanid">VLAN</label>
<select id="vlanid" v-model="form.vlanid" class="form-control">
<option value="">Select VLAN</option>
<option v-for="vlan in vlans" :key="vlan.vlanid" :value="vlan.vlanid">
VLAN {{ vlan.vlannumber }} - {{ vlan.name }}
</option>
</select>
</div>
<div class="form-group">
<label for="locationid">Location</label>
<select id="locationid" v-model="form.locationid" class="form-control">
<option value="">Select Location</option>
<option v-for="loc in locations" :key="loc.locationid" :value="loc.locationid">
{{ loc.location }}
</option>
</select>
</div>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="2"
></textarea>
</div>
</div>
<!-- Network Details -->
<div class="form-section">
<h4>Network Details</h4>
<div class="form-row">
<div class="form-group">
<label for="gatewayip">Gateway IP</label>
<input
id="gatewayip"
v-model="form.gatewayip"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.1"
/>
</div>
<div class="form-group">
<label for="subnetmask">Subnet Mask</label>
<input
id="subnetmask"
v-model="form.subnetmask"
type="text"
class="form-control"
placeholder="e.g., 255.255.255.0"
/>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label for="dns1">Primary DNS</label>
<input
id="dns1"
v-model="form.dns1"
type="text"
class="form-control"
/>
</div>
<div class="form-group">
<label for="dns2">Secondary DNS</label>
<input
id="dns2"
v-model="form.dns2"
type="text"
class="form-control"
/>
</div>
</div>
</div>
<!-- DHCP Settings -->
<div class="form-section">
<h4>DHCP Settings</h4>
<div class="form-group checkbox-group">
<label>
<input type="checkbox" v-model="form.dhcpenabled" />
<span>DHCP Enabled</span>
</label>
</div>
<div v-if="form.dhcpenabled" class="form-row">
<div class="form-group">
<label for="dhcprangestart">DHCP Range Start</label>
<input
id="dhcprangestart"
v-model="form.dhcprangestart"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.100"
/>
</div>
<div class="form-group">
<label for="dhcprangeend">DHCP Range End</label>
<input
id="dhcprangeend"
v-model="form.dhcprangeend"
type="text"
class="form-control"
placeholder="e.g., 10.1.1.200"
/>
</div>
</div>
</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>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete Subnet</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete subnet <strong>{{ subnetToDelete?.cidr }}</strong>?</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteSubnet">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute } from 'vue-router'
import { networkApi, locationsApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const route = useRoute()
const subnets = ref([])
const vlans = ref([])
const locations = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadSubnets })
const vlanFilter = ref('')
const typeFilter = ref('')
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingSubnet = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const subnetToDelete = ref(null)
const form = ref({
cidr: '',
name: '',
description: '',
gatewayip: '',
subnetmask: '',
vlanid: '',
locationid: '',
subnettype: '',
dhcpenabled: true,
dhcprangestart: '',
dhcprangeend: '',
dns1: '',
dns2: ''
})
let searchTimeout = null
onMounted(async () => {
// Check for vlanid query parameter
if (route.query.vlanid) {
vlanFilter.value = route.query.vlanid
}
await Promise.all([
loadVLANs(),
loadLocations()
])
await loadSubnets()
})
async function loadVLANs() {
try {
const response = await networkApi.vlans.list({ perpage: 100 })
vlans.value = response.data.data || []
} catch (err) {
console.error('Error loading VLANs:', err)
}
}
async function loadLocations() {
try {
const response = await locationsApi.list({ perpage: 100 })
locations.value = response.data.data || []
} catch (err) {
console.error('Error loading locations:', err)
}
}
async function loadSubnets() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (vlanFilter.value) params.vlanid = vlanFilter.value
if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.subnets.list(params)
subnets.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading subnets:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadSubnets()
}, 300)
}
function goToPage(p) {
setPage(p)
loadSubnets()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadSubnets()
}
function openModal(subnet = null) {
editingSubnet.value = subnet
if (subnet) {
form.value = {
cidr: subnet.cidr || '',
name: subnet.name || '',
description: subnet.description || '',
gatewayip: subnet.gatewayip || '',
subnetmask: subnet.subnetmask || '',
vlanid: subnet.vlanid || '',
locationid: subnet.locationid || '',
subnettype: subnet.subnettype || '',
dhcpenabled: subnet.dhcpenabled ?? true,
dhcprangestart: subnet.dhcprangestart || '',
dhcprangeend: subnet.dhcprangeend || '',
dns1: subnet.dns1 || '',
dns2: subnet.dns2 || ''
}
} else {
form.value = {
cidr: '',
name: '',
description: '',
gatewayip: '',
subnetmask: '',
vlanid: vlanFilter.value || '',
locationid: '',
subnettype: '',
dhcpenabled: true,
dhcprangestart: '',
dhcprangeend: '',
dns1: '',
dns2: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingSubnet.value = null
}
async function saveSubnet() {
error.value = ''
saving.value = true
try {
const payload = {
cidr: form.value.cidr,
name: form.value.name,
description: form.value.description || null,
gatewayip: form.value.gatewayip || null,
subnetmask: form.value.subnetmask || null,
vlanid: form.value.vlanid || null,
locationid: form.value.locationid || null,
subnettype: form.value.subnettype || null,
dhcpenabled: form.value.dhcpenabled,
dhcprangestart: form.value.dhcprangestart || null,
dhcprangeend: form.value.dhcprangeend || null,
dns1: form.value.dns1 || null,
dns2: form.value.dns2 || null
}
if (editingSubnet.value) {
await networkApi.subnets.update(editingSubnet.value.subnetid, payload)
} else {
await networkApi.subnets.create(payload)
}
closeModal()
loadSubnets()
} catch (err) {
console.error('Error saving subnet:', err)
error.value = apiError(err, 'Failed to save subnet')
} finally {
saving.value = false
}
}
function confirmDelete(subnet) {
subnetToDelete.value = subnet
showDeleteModal.value = true
}
async function deleteSubnet() {
try {
await networkApi.subnets.delete(subnetToDelete.value.subnetid)
showDeleteModal.value = false
subnetToDelete.value = null
loadSubnets()
} catch (err) {
console.error('Error deleting subnet:', err)
toast.error(apiError(err, 'Failed to delete subnet'))
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
flex-wrap: wrap;
}
.filters input {
flex: 1;
min-width: 200px;
}
.filters select {
width: auto;
min-width: 150px;
}
.modal-lg {
max-width: 700px;
}
.form-section {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
.form-section:last-of-type {
border-bottom: none;
margin-bottom: 0;
}
.form-section h4 {
margin: 0 0 1rem 0;
font-size: 0.95rem;
color: var(--text-light);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
.checkbox-group label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
}
.checkbox-group input[type="checkbox"] {
width: 1.125rem;
height: 1.125rem;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>

View File

@@ -1,377 +0,0 @@
<template>
<div>
<div class="page-header">
<h2>VLANs</h2>
<button class="btn btn-primary" @click="openModal()">+ Add VLAN</button>
</div>
<!-- Search -->
<div class="filters">
<input
v-model="search"
type="text"
class="form-control"
placeholder="Search VLANs..."
@input="debouncedSearch"
/>
<select v-model="typeFilter" class="form-control" @change="loadVLANs">
<option value="">All Types</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table>
<thead>
<tr>
<th>VLAN #</th>
<th>Name</th>
<th>Type</th>
<th>Description</th>
<th>Subnets</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<tr v-for="vlan in vlans" :key="vlan.vlanid">
<td class="mono">{{ vlan.vlannumber }}</td>
<td>{{ vlan.name }}</td>
<td>
<span v-if="vlan.vlantype" class="badge" :class="getTypeClass(vlan.vlantype)">
{{ vlan.vlantype }}
</span>
<span v-else>-</span>
</td>
<td class="cell-truncate" :title="vlan.description">{{ vlan.description || '-' }}</td>
<td>
<router-link
:to="{ path: '/settings/subnets', query: { vlanid: vlan.vlanid } }"
class="subnet-link"
>
View Subnets
</router-link>
</td>
<td class="actions">
<button
class="btn btn-secondary btn-sm"
@click="openModal(vlan)"
>
Edit
</button>
<button
class="btn btn-danger btn-sm"
@click="confirmDelete(vlan)"
>
Delete
</button>
</td>
</tr>
<tr v-if="vlans.length === 0">
<td colspan="6" style="text-align: center; color: var(--text-light);">
No VLANs found
</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<PaginationBar
:page="page"
:totalPages="totalPages"
:perPage="perPage"
@update:page="goToPage"
@update:perPage="changePerPage"
/>
</template>
</div>
<!-- Add/Edit Modal -->
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
<div class="modal">
<div class="modal-header">
<h3>{{ editingVLAN ? 'Edit VLAN' : 'Add VLAN' }}</h3>
</div>
<form @submit.prevent="saveVLAN">
<div class="modal-body">
<div class="form-row">
<div class="form-group">
<label for="vlannumber">VLAN Number *</label>
<input
id="vlannumber"
v-model.number="form.vlannumber"
type="number"
class="form-control"
min="1"
max="4094"
required
/>
</div>
<div class="form-group">
<label for="name">Name *</label>
<input
id="name"
v-model="form.name"
type="text"
class="form-control"
required
/>
</div>
</div>
<div class="form-group">
<label for="vlantype">VLAN Type</label>
<select id="vlantype" v-model="form.vlantype" class="form-control">
<option value="">Select Type</option>
<option value="data">Data</option>
<option value="voice">Voice</option>
<option value="management">Management</option>
<option value="guest">Guest</option>
<option value="iot">IoT</option>
</select>
</div>
<div class="form-group">
<label for="description">Description</label>
<textarea
id="description"
v-model="form.description"
class="form-control"
rows="3"
></textarea>
</div>
<div v-if="error" class="error-message">{{ error }}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
<button type="submit" class="btn btn-primary" :disabled="saving">
{{ saving ? 'Saving...' : 'Save' }}
</button>
</div>
</form>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div v-if="showDeleteModal" class="modal-overlay" @click.self="showDeleteModal = false">
<div class="modal">
<div class="modal-header">
<h3>Delete VLAN</h3>
</div>
<div class="modal-body">
<p>Are you sure you want to delete VLAN <strong>{{ vlanToDelete?.vlannumber }} ({{ vlanToDelete?.name }})</strong>?</p>
<p style="color: var(--text-light); font-size: 0.875rem;">
VLANs with associated subnets cannot be deleted.
</p>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @click="showDeleteModal = false">Cancel</button>
<button class="btn btn-danger" @click="deleteVLAN">Delete</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { networkApi } from '../../api'
import PaginationBar from '../../components/PaginationBar.vue'
import { useToast } from '../../composables/toast'
import { apiError } from '../../utils/apiError'
import { useListQuery } from '@/composables/listQuery'
const toast = useToast()
const vlans = ref([])
const loading = ref(true)
const { page, search, setPage, setSearch } = useListQuery({ onChange: loadVLANs })
const typeFilter = ref('')
const totalPages = ref(1)
const perPage = ref(20)
const showModal = ref(false)
const editingVLAN = ref(null)
const saving = ref(false)
const error = ref('')
const showDeleteModal = ref(false)
const vlanToDelete = ref(null)
const form = ref({
vlannumber: null,
name: '',
vlantype: '',
description: ''
})
let searchTimeout = null
onMounted(() => {
loadVLANs()
})
async function loadVLANs() {
loading.value = true
try {
const params = {
page: page.value,
perpage: perPage.value
}
if (search.value) params.search = search.value
if (typeFilter.value) params.type = typeFilter.value
const response = await networkApi.vlans.list(params)
vlans.value = response.data.data || []
totalPages.value = response.data.meta?.pagination?.totalpages || response.data.meta?.pagination?.total_pages || 1
} catch (err) {
console.error('Error loading VLANs:', err)
} finally {
loading.value = false
}
}
function debouncedSearch() {
clearTimeout(searchTimeout)
searchTimeout = setTimeout(() => {
setSearch(search.value)
loadVLANs()
}, 300)
}
function goToPage(p) {
setPage(p)
loadVLANs()
}
function changePerPage(newPerPage) {
perPage.value = newPerPage
setPage(1)
loadVLANs()
}
function getTypeClass(type) {
switch (type) {
case 'data': return 'badge-info'
case 'voice': return 'badge-success'
case 'management': return 'badge-warning'
case 'guest': return 'badge-secondary'
case 'iot': return 'badge-primary'
default: return 'badge-info'
}
}
function openModal(vlan = null) {
editingVLAN.value = vlan
if (vlan) {
form.value = {
vlannumber: vlan.vlannumber,
name: vlan.name || '',
vlantype: vlan.vlantype || '',
description: vlan.description || ''
}
} else {
form.value = {
vlannumber: null,
name: '',
vlantype: '',
description: ''
}
}
error.value = ''
showModal.value = true
}
function closeModal() {
showModal.value = false
editingVLAN.value = null
}
async function saveVLAN() {
error.value = ''
saving.value = true
try {
if (editingVLAN.value) {
await networkApi.vlans.update(editingVLAN.value.vlanid, form.value)
} else {
await networkApi.vlans.create(form.value)
}
closeModal()
loadVLANs()
} catch (err) {
console.error('Error saving VLAN:', err)
error.value = apiError(err, 'Failed to save VLAN')
} finally {
saving.value = false
}
}
function confirmDelete(vlan) {
vlanToDelete.value = vlan
showDeleteModal.value = true
}
async function deleteVLAN() {
try {
await networkApi.vlans.delete(vlanToDelete.value.vlanid)
showDeleteModal.value = false
vlanToDelete.value = null
loadVLANs()
} catch (err) {
console.error('Error deleting VLAN:', err)
toast.error(apiError(err, 'Failed to delete VLAN'))
}
}
</script>
<style scoped>
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.form-row {
display: grid;
grid-template-columns: 1fr 2fr;
gap: 1rem;
}
.filters {
display: flex;
gap: 0.75rem;
margin-bottom: 1rem;
}
.filters select {
width: auto;
min-width: 150px;
}
.subnet-link {
color: var(--link);
text-decoration: none;
}
.subnet-link:hover {
text-decoration: underline;
}
.badge-primary {
background: var(--primary);
color: white;
}
.badge-secondary {
background: var(--secondary);
color: var(--text);
}
</style>