ADR-013 Phase 4: all 13 plugin frontends relocated + staging machinery
This commit is contained in:
@@ -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 & 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user