ADR-013 Phase 4: extract the 11 plugin routes embedded in core.js
core.js still routed plugin-owned pages directly. Extracted all 11 into the owning plugin's route file + moved their views into plugins/<name>/frontend/: - computers: reports/pc-relationships, settings/pctypemapping - printers: reports/toner, settings/printertypes, settings/zabbix (toner/supply monitoring) - machines: settings/machinetypes - network: settings/networktypes - warranty: settings/dellwarranty - slides: settings/slides (its route file gains a default export; it was toplevel-only) - employees: NEW plugin frontend (employees/:sso + settings/employeedirectory) - employees had no route file before; its pages lived only in core.js. core.js now holds only core routes; all 14 bundled plugins are self-contained under plugins/<name>/frontend/. Verified live: the extracted Machine Types settings page renders in the settings rail from the machines plugin frontend. Build + 58 vitest + naming green.
This commit is contained in:
145
plugins/printers/frontend/views/PrinterTypesList.vue
Normal file
145
plugins/printers/frontend/views/PrinterTypesList.vue
Normal file
@@ -0,0 +1,145 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Printer Types</h2>
|
||||
<label class="show-inactive"><input type="checkbox" v-model="showInactive" /> Show inactive</label>
|
||||
<button class="btn btn-primary" @click="openModal()">+ Add Printer Type</button>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
<template v-else>
|
||||
<div class="table-container">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Type</th>
|
||||
<th>Description</th>
|
||||
<th>Color</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="t in visibleItems" :key="t.printertypeid">
|
||||
<td>{{ t.printertype }}</td>
|
||||
<td class="cell-truncate" :title="t.description">{{ t.description || '-' }}</td>
|
||||
<td><span class="badge" :style="colorStyle(t.color)">{{ t.color || 'auto' }}</span></td>
|
||||
<td class="actions">
|
||||
<span v-if="t.isactive === false" class="badge badge-secondary" style="margin-right:6px;">inactive</span>
|
||||
<button class="btn btn-secondary btn-sm" @click="openModal(t)">Edit</button>
|
||||
<button class="btn btn-danger btn-sm" @click="deleteType(t)">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="visibleItems.length === 0">
|
||||
<td colspan="4" style="text-align: center; color: var(--text-light);">No printer types found</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div v-if="showModal" class="modal-overlay" @click.self="closeModal">
|
||||
<div class="modal">
|
||||
<div class="modal-header"><h3>{{ editing ? 'Edit' : 'Add' }} Printer Type</h3></div>
|
||||
<form @submit.prevent="save">
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Printer Type *</label>
|
||||
<input v-model="form.printertype" type="text" class="form-control" required />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Description</label>
|
||||
<textarea v-model="form.description" class="form-control" rows="3"></textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Color <span class="hint">(map markers; blank = auto)</span></label>
|
||||
<ColorSwatchPicker v-model="form.color" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label"><input type="checkbox" v-model="form.isactive" /> Active</label>
|
||||
</div>
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" @click="closeModal">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary" :disabled="saving">{{ saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import ColorSwatchPicker from '@/components/ColorSwatchPicker.vue'
|
||||
import { colorStyle } from '@/utils/colorStyle'
|
||||
import { useToast } from '@/composables/toast'
|
||||
import { apiError } from '@/utils/apiError'
|
||||
const toast = useToast()
|
||||
|
||||
const items = ref([])
|
||||
const showInactive = ref(false)
|
||||
const visibleItems = computed(() => showInactive.value ? items.value : items.value.filter(x => x.isactive !== false))
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const editing = ref(null)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const form = ref({ printertype: '', description: '', color: '', isactive: true })
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData() {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await printersApi.types.list({ perpage: 200, active: false })
|
||||
items.value = response.data.data || []
|
||||
} catch (err) {
|
||||
console.error('Error loading printer types:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(item = null) {
|
||||
editing.value = item
|
||||
form.value = item
|
||||
? { printertype: item.printertype || '', description: item.description || '', color: item.color || '', isactive: item.isactive !== false }
|
||||
: { printertype: '', description: '', color: '', isactive: true }
|
||||
error.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() { showModal.value = false; editing.value = null }
|
||||
|
||||
async function save() {
|
||||
error.value = ''
|
||||
saving.value = true
|
||||
try {
|
||||
if (editing.value) {
|
||||
await printersApi.types.update(editing.value.printertypeid, form.value)
|
||||
} else {
|
||||
await printersApi.types.create(form.value)
|
||||
}
|
||||
closeModal()
|
||||
loadData()
|
||||
} catch (err) {
|
||||
error.value = apiError(err, 'Failed to save')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteType(t) {
|
||||
if (!confirm(`Delete printer type "${t.printertype}"?`)) return
|
||||
try {
|
||||
await printersApi.types.remove(t.printertypeid)
|
||||
loadData()
|
||||
} catch (err) {
|
||||
toast.error(apiError(err, 'Failed to delete'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
304
plugins/printers/frontend/views/TonerReport.vue
Normal file
304
plugins/printers/frontend/views/TonerReport.vue
Normal file
@@ -0,0 +1,304 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Toner Report</h1>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading && !error" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<EmailReportButton v-if="!loading && !error" subject="Toner / Supply Report"
|
||||
:columns="emailColumns" :rows="emailRows" />
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading supply data...</div>
|
||||
|
||||
<div v-else-if="error" class="card">
|
||||
<p class="text-danger">{{ error }}</p>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<!-- Summary Bar -->
|
||||
<div class="summary-bar">
|
||||
<div class="summary-stat card">
|
||||
<div class="stat-value">{{ summary.total_checked }}</div>
|
||||
<div class="stat-label">Printers Checked</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-low">
|
||||
<div class="stat-value">{{ summary.low }}</div>
|
||||
<div class="stat-label">Low Supply</div>
|
||||
</div>
|
||||
<div class="summary-stat card stat-critical">
|
||||
<div class="stat-value">{{ summary.critical }}</div>
|
||||
<div class="stat-label">Critical Supply</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Filter Buttons -->
|
||||
<div class="filters">
|
||||
<button
|
||||
v-for="f in filterOptions"
|
||||
:key="f.value"
|
||||
class="btn"
|
||||
:class="filter === f.value ? 'btn-primary' : 'btn-secondary'"
|
||||
@click="filter = f.value"
|
||||
>
|
||||
{{ f.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Printers Table -->
|
||||
<div class="card">
|
||||
<div class="table-container">
|
||||
<table v-if="filteredPrinters.length">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Printer Name</th>
|
||||
<th>Asset #</th>
|
||||
<th>Location</th>
|
||||
<th>IP Address</th>
|
||||
<th>Supplies</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="printer in filteredPrinters" :key="printer.printerid">
|
||||
<td>
|
||||
<router-link :to="`/printers/${printer.printerid}`">
|
||||
{{ printer.printername || 'Unknown' }}
|
||||
</router-link>
|
||||
</td>
|
||||
<td>{{ printer.assetnumber }}</td>
|
||||
<td>{{ printer.location || '-' }}</td>
|
||||
<td>{{ printer.ipaddress }}</td>
|
||||
<td class="supplies-cell">
|
||||
<div
|
||||
v-for="(supply, idx) in printer.supplies"
|
||||
:key="idx"
|
||||
class="supply-row"
|
||||
>
|
||||
<span class="supply-name">{{ supply.name }}</span>
|
||||
<div class="supply-bar-track">
|
||||
<div
|
||||
class="supply-bar-fill"
|
||||
:class="'supply-' + supply.status"
|
||||
:style="{ width: Math.max(supply.level, 2) + '%' }"
|
||||
></div>
|
||||
</div>
|
||||
<span class="supply-level" :class="'supply-text-' + supply.status">
|
||||
{{ supply.level }}%
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<p v-else class="empty-state">No printers match the selected filter.</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { printersApi } from '@/api'
|
||||
import EmailReportButton from '@/components/EmailReportButton.vue'
|
||||
|
||||
const emailColumns = [
|
||||
{ key: 'printer', label: 'Printer' },
|
||||
{ key: 'assetnumber', label: 'Asset #' },
|
||||
{ key: 'location', label: 'Location' },
|
||||
{ key: 'ipaddress', label: 'IP Address' },
|
||||
{ key: 'supply', label: 'Supply' },
|
||||
{ key: 'level', label: 'Level' },
|
||||
{ key: 'status', label: 'Status' },
|
||||
]
|
||||
|
||||
const loading = ref(true)
|
||||
const error = ref(null)
|
||||
const printers = ref([])
|
||||
const summary = ref({ total_checked: 0, low: 0, critical: 0 })
|
||||
const filter = ref('all')
|
||||
|
||||
const filterOptions = [
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Critical', value: 'critical' },
|
||||
{ label: 'Low', value: 'low' }
|
||||
]
|
||||
|
||||
const filteredPrinters = computed(() => {
|
||||
if (filter.value === 'all') return printers.value
|
||||
return printers.value.filter(p =>
|
||||
p.supplies.some(s => s.status === filter.value)
|
||||
)
|
||||
})
|
||||
|
||||
// One row per supply, honoring the active filter, for the emailed table.
|
||||
const emailRows = computed(() => {
|
||||
const rows = []
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push({
|
||||
printer: printer.printername || '',
|
||||
assetnumber: printer.assetnumber || '',
|
||||
location: printer.location || '',
|
||||
ipaddress: printer.ipaddress || '',
|
||||
supply: supply.name || '',
|
||||
level: supply.level + '%',
|
||||
status: supply.status || '',
|
||||
})
|
||||
}
|
||||
}
|
||||
return rows
|
||||
})
|
||||
|
||||
function exportCSV() {
|
||||
// one row per supply, honoring the active filter
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const rows = [['printer', 'assetnumber', 'location', 'ipaddress', 'supply', 'level', 'status']]
|
||||
for (const printer of filteredPrinters.value) {
|
||||
for (const supply of printer.supplies || []) {
|
||||
rows.push([
|
||||
printer.printername || '', printer.assetnumber || '', printer.location || '',
|
||||
printer.ipaddress || '', supply.name || '', supply.level, supply.status || ''
|
||||
])
|
||||
}
|
||||
}
|
||||
const csv = rows.map(row => row.map(quote).join(',')).join('\n')
|
||||
const link = document.createElement('a')
|
||||
link.href = URL.createObjectURL(new Blob([csv], { type: 'text/csv' }))
|
||||
link.download = 'toner_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await printersApi.lowSupplies()
|
||||
const data = response.data.data
|
||||
printers.value = data.printers || []
|
||||
summary.value = data.summary || { total_checked: 0, low: 0, critical: 0 }
|
||||
} catch (err) {
|
||||
console.error('Error loading toner report:', err)
|
||||
error.value = 'Failed to load supply data. Zabbix may not be configured or reachable.'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.summary-bar {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.summary-stat {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--text-light);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.stat-low .stat-value {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.stat-critical .stat-value {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.supplies-cell {
|
||||
min-width: 280px;
|
||||
}
|
||||
|
||||
.supply-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.supply-row:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.supply-name {
|
||||
flex: 0 0 120px;
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-light);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.supply-bar-track {
|
||||
flex: 1;
|
||||
height: 8px;
|
||||
background: var(--border);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.supply-bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.supply-ok {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.supply-low {
|
||||
background: var(--warning);
|
||||
}
|
||||
|
||||
.supply-critical {
|
||||
background: var(--danger);
|
||||
}
|
||||
|
||||
.supply-level {
|
||||
flex: 0 0 40px;
|
||||
text-align: right;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.supply-text-ok {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.supply-text-low {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.supply-text-critical {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
.text-danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
90
plugins/printers/frontend/views/ZabbixSettings.vue
Normal file
90
plugins/printers/frontend/views/ZabbixSettings.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h2>Zabbix Supplies</h2>
|
||||
</div>
|
||||
|
||||
<div class="section-card">
|
||||
<div class="setting-group">
|
||||
<p class="setting-description">
|
||||
Connect to Zabbix for real-time printer supply monitoring. When enabled, supply levels
|
||||
are fetched from Zabbix API and displayed on printer detail pages.
|
||||
</p>
|
||||
|
||||
<div class="setting-row">
|
||||
<label class="toggle-label">
|
||||
<span>Enable Zabbix Integration</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: settings.zabbix_enabled }"
|
||||
@click="toggleSetting('zabbix_enabled')"
|
||||
:disabled="saving"
|
||||
>
|
||||
<span class="toggle-slider"></span>
|
||||
</button>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.zabbix_enabled">
|
||||
<label>
|
||||
<span>Zabbix API URL</span>
|
||||
<input
|
||||
type="url"
|
||||
v-model="settings.zabbix_url"
|
||||
placeholder="http://zabbix.example.com:8080"
|
||||
@blur="saveSetting('zabbix_url', settings.zabbix_url)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="setting-row" v-if="settings.zabbix_enabled">
|
||||
<label>
|
||||
<span>Zabbix API Token</span>
|
||||
<input
|
||||
type="password"
|
||||
v-model="settings.zabbix_token"
|
||||
placeholder="Enter API token"
|
||||
@blur="saveSetting('zabbix_token', settings.zabbix_token)"
|
||||
:disabled="saving"
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="status-indicator" v-if="settings.zabbix_enabled">
|
||||
<span class="status-dot" :class="zabbixStatus"></span>
|
||||
<span>{{ zabbixMessage }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">{{ error }}</div>
|
||||
<div v-if="success" class="settings-success">{{ success }}</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, computed } from 'vue'
|
||||
import { useSystemSettings } from '@/composables/systemSettings'
|
||||
|
||||
const {
|
||||
settings, saving, error, success,
|
||||
loadSettings, saveSetting, toggleSetting,
|
||||
} = useSystemSettings()
|
||||
|
||||
// Per-page connection status (stays with the page, not the composable).
|
||||
const zabbixStatus = computed(() => {
|
||||
if (!settings.zabbix_enabled) return 'inactive'
|
||||
if (!settings.zabbix_url || !settings.zabbix_token) return 'warning'
|
||||
return 'pending'
|
||||
})
|
||||
|
||||
const zabbixMessage = computed(() => {
|
||||
if (!settings.zabbix_enabled) return 'Disabled'
|
||||
if (!settings.zabbix_url) return 'URL not configured'
|
||||
if (!settings.zabbix_token) return 'Token not configured'
|
||||
return 'Configured (connectivity checked on first use)'
|
||||
})
|
||||
|
||||
onMounted(loadSettings)
|
||||
</script>
|
||||
Reference in New Issue
Block a user