Plugin framework maturation, reports overhaul, theming, and USB frontend repair
Framework: - Per-plugin Alembic migration chains (ADR-008): every bundled plugin carries its own chain with a stamp-only anchor at the ownership cutover; new plugin schema lands in plugins/<name>/migrations/, never the core chain. Deploys add flask plugin upgrade-all. Fixed a latent bug in the shared alembic template (engine URL resolution) and taught the metadata filter to include FK-referenced core tables. - Frontend plugin route gating (ADR-009): plugin routes carry meta.plugin; a disabled plugin's pages redirect to the dashboard via a cached, fail-open check against the new public GET /api/plugins/enabled. - get_reports() plugin hook (contract 0.5.0 -> 0.6.0): plugins contribute report cards; warranty and toner cards moved off the hardcoded list. Reports: - Hub grouped by category with search; inline reports render at the top, are URL-backed (?report=id, back-button and deep links work), expose their server-side filter params as controls, and export CSV. Warranty and Toner pages gained CSV export. - Deleted the dead legacy Warranty Status report (always-zero buckets from a retired column). Theming and fonts: - Inter (variable) bundled locally via @fontsource, replacing the Google Fonts Roboto import - air-gapped installs now render correctly; tables use tabular numerals. - Optional brand_primary_dark_color, brand_accent_color, brand_sidebar_color settings applied to CSS vars at bootstrap. USB frontend repair (views were reading a dead legacy shape): - List/detail/form and the employee profile USB panels remapped to the real API shape (device_id/device_desc/checkinoutlog); employee panels now use /usb/checkouts endpoints; external-mode /usb/checkouts/active honors the badge filter; dead client methods pruned. Also: warranties list page no longer requires login (matches app convention); collector doc rewritten with a GE-Enforce integration guide and paste-ready PowerShell reporter; ADR index and CHANGELOG updated. Verified: 323 tests pass, naming/style green, frontend builds, plugin migration dry-run green on scratch MySQL. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,288 +1,513 @@
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Reports</h1>
|
||||
</div>
|
||||
|
||||
<div class="reports-grid">
|
||||
<div class="report-card card" @click="router.push('/reports/toner')">
|
||||
<h3>Toner Report</h3>
|
||||
<p>View printers with low or critical toner/supply levels</p>
|
||||
<span class="badge">Printers</span>
|
||||
</div>
|
||||
<div class="report-card card" @click="router.push('/reports/warranty')">
|
||||
<h3>Warranty Report</h3>
|
||||
<p>Assets bucketed by coverage: expired, expiring soon, active</p>
|
||||
<span class="badge">Warranty</span>
|
||||
</div>
|
||||
<div v-for="report in reports" :key="report.id" class="report-card card" @click="runReport(report)">
|
||||
<h3>{{ report.name }}</h3>
|
||||
<p>{{ report.description }}</p>
|
||||
<span class="badge">{{ report.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Results -->
|
||||
<div v-if="currentReport" class="report-results card">
|
||||
<div class="report-header">
|
||||
<h2>{{ currentReport.name }}</h2>
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<button class="btn btn-secondary" @click="clearReport">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading report...</div>
|
||||
<div v-else-if="reportData">
|
||||
<!-- Equipment by Type -->
|
||||
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Equipment Type</th>
|
||||
<th>Description</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.equipmenttype">
|
||||
<td>{{ item.equipmenttype }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Assets by Status -->
|
||||
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.status">
|
||||
<td>
|
||||
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
|
||||
</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- KB Popularity -->
|
||||
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Article</th>
|
||||
<th>Application</th>
|
||||
<th>Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.linkid">
|
||||
<td>
|
||||
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
|
||||
<span v-else>{{ item.shortdescription }}</span>
|
||||
</td>
|
||||
<td>{{ item.application || '-' }}</td>
|
||||
<td>{{ item.clicks }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Asset Inventory -->
|
||||
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
|
||||
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
|
||||
|
||||
<h3>By Type</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bytype" :key="item.type">
|
||||
<td>{{ item.type }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Status</h3>
|
||||
<table>
|
||||
<thead><tr><th>Status</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bystatus" :key="item.status">
|
||||
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Location</h3>
|
||||
<table>
|
||||
<thead><tr><th>Location</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bylocation" :key="item.location">
|
||||
<td>{{ item.location }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Generic fallback -->
|
||||
<div v-else class="report-content">
|
||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { reportsApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const reports = ref([])
|
||||
const currentReport = ref(null)
|
||||
const reportData = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReports()
|
||||
})
|
||||
|
||||
async function loadReports() {
|
||||
try {
|
||||
const response = await reportsApi.list()
|
||||
reports.value = response.data.data.reports
|
||||
} catch (error) {
|
||||
console.error('Error loading reports:', error)
|
||||
}
|
||||
}
|
||||
|
||||
async function runReport(report) {
|
||||
// PC Relationships has its own dedicated page
|
||||
if (report.id === 'pc-relationships') {
|
||||
router.push('/reports/pc-relationships')
|
||||
return
|
||||
}
|
||||
|
||||
currentReport.value = report
|
||||
loading.value = true
|
||||
reportData.value = null
|
||||
|
||||
try {
|
||||
let response
|
||||
switch (report.id) {
|
||||
case 'equipment-by-type':
|
||||
response = await reportsApi.equipmentByType()
|
||||
break
|
||||
case 'assets-by-status':
|
||||
response = await reportsApi.assetsByStatus()
|
||||
break
|
||||
case 'kb-popularity':
|
||||
response = await reportsApi.kbPopularity()
|
||||
break
|
||||
case 'warranty-status':
|
||||
response = await reportsApi.warrantyStatus()
|
||||
break
|
||||
case 'software-compliance':
|
||||
response = await reportsApi.softwareCompliance()
|
||||
break
|
||||
case 'asset-inventory':
|
||||
response = await reportsApi.assetInventory()
|
||||
break
|
||||
default:
|
||||
console.error('Unknown report:', report.id)
|
||||
return
|
||||
}
|
||||
reportData.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error running report:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (!currentReport.value) return
|
||||
window.open(`/api/reports/${currentReport.value.id}?format=csv`, '_blank')
|
||||
}
|
||||
|
||||
function clearReport() {
|
||||
currentReport.value = null
|
||||
reportData.value = null
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.report-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-card p {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-results {
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
</style>
|
||||
<template>
|
||||
<div class="page-header">
|
||||
<h1>Reports</h1>
|
||||
</div>
|
||||
|
||||
<!-- Card grid hides while a report is open so results sit at the top. -->
|
||||
<template v-if="!currentReport">
|
||||
<div class="filters">
|
||||
<input
|
||||
v-model="search"
|
||||
type="text"
|
||||
class="form-control"
|
||||
placeholder="Search reports..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- All report cards, grouped by category. Cards come from GET /api/reports,
|
||||
which merges core reports with plugin-contributed cards (get_reports). -->
|
||||
<div v-for="group in groupedReports" :key="group.category" class="report-group">
|
||||
<h2 class="group-title">{{ titleCase(group.category) }}</h2>
|
||||
<div class="reports-grid">
|
||||
<div
|
||||
v-for="report in group.reports"
|
||||
:key="report.id"
|
||||
class="report-card card"
|
||||
@click="openReport(report)"
|
||||
>
|
||||
<h3>{{ report.name }}</h3>
|
||||
<p>{{ report.description }}</p>
|
||||
<span class="badge">{{ report.category }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!groupedReports.length" class="empty-state">
|
||||
No reports match your search.
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Inline report results (endpoint-backed reports) -->
|
||||
<div v-if="currentReport" class="report-results card">
|
||||
<div class="report-header">
|
||||
<h2>{{ currentReport.name }}</h2>
|
||||
<div class="report-actions">
|
||||
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Server-side filters this report accepts (see reportFilterFields) -->
|
||||
<div v-if="activeFilterFields.length" class="filters">
|
||||
<select
|
||||
v-if="activeFilterFields.includes('businessunit')"
|
||||
v-model="filterState.businessunitid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All business units</option>
|
||||
<option v-for="bu in filterOptions.businessunits" :key="bu.businessunitid" :value="bu.businessunitid">
|
||||
{{ bu.businessunit }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('assettype')"
|
||||
v-model="filterState.assettypeid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All asset types</option>
|
||||
<option v-for="at in filterOptions.assettypes" :key="at.assettypeid" :value="at.assettypeid">
|
||||
{{ titleCase(at.assettype) }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('location')"
|
||||
v-model="filterState.locationid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All locations</option>
|
||||
<option v-for="loc in filterOptions.locations" :key="loc.locationid" :value="loc.locationid">
|
||||
{{ loc.locationname }}
|
||||
</option>
|
||||
</select>
|
||||
<select
|
||||
v-if="activeFilterFields.includes('application')"
|
||||
v-model="filterState.appid"
|
||||
class="form-control"
|
||||
@change="refreshReport"
|
||||
>
|
||||
<option value="">All required applications</option>
|
||||
<option v-for="app in filterOptions.applications" :key="app.appid" :value="app.appid">
|
||||
{{ app.appname }}
|
||||
</option>
|
||||
</select>
|
||||
<input
|
||||
v-if="activeFilterFields.includes('limit')"
|
||||
v-model.number="filterState.limit"
|
||||
type="number"
|
||||
min="1"
|
||||
max="100"
|
||||
class="form-control limit-input"
|
||||
placeholder="Limit (20)"
|
||||
@change="refreshReport"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading report...</div>
|
||||
<div v-else-if="reportData">
|
||||
<!-- Equipment by Type -->
|
||||
<div v-if="currentReport.id === 'equipment-by-type'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Equipment Type</th>
|
||||
<th>Description</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.equipmenttype">
|
||||
<td>{{ item.equipmenttype }}</td>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Assets by Status -->
|
||||
<div v-else-if="currentReport.id === 'assets-by-status'" class="report-content">
|
||||
<p class="report-summary">Total: {{ reportData.total }}</p>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.status">
|
||||
<td>
|
||||
<span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span>
|
||||
</td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- KB Popularity -->
|
||||
<div v-else-if="currentReport.id === 'kb-popularity'" class="report-content">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Article</th>
|
||||
<th>Application</th>
|
||||
<th>Clicks</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data" :key="item.linkid">
|
||||
<td>
|
||||
<a v-if="item.linkurl" :href="item.linkurl" target="_blank">{{ item.shortdescription }}</a>
|
||||
<span v-else>{{ item.shortdescription }}</span>
|
||||
</td>
|
||||
<td>{{ item.application || '-' }}</td>
|
||||
<td>{{ item.clicks }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Asset Inventory -->
|
||||
<div v-else-if="currentReport.id === 'asset-inventory'" class="report-content">
|
||||
<p class="report-summary">Total Assets: {{ reportData.total }}</p>
|
||||
|
||||
<h3>By Type</h3>
|
||||
<table>
|
||||
<thead><tr><th>Type</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bytype" :key="item.type">
|
||||
<td>{{ item.type }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Status</h3>
|
||||
<table>
|
||||
<thead><tr><th>Status</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bystatus" :key="item.status">
|
||||
<td><span class="badge" :style="{ backgroundColor: item.color }">{{ item.status }}</span></td>
|
||||
<td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<h3>By Location</h3>
|
||||
<table>
|
||||
<thead><tr><th>Location</th><th>Count</th></tr></thead>
|
||||
<tbody>
|
||||
<tr v-for="item in reportData.data.bylocation" :key="item.location">
|
||||
<td>{{ item.location }}</td><td>{{ item.count }}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Generic fallback -->
|
||||
<div v-else class="report-content">
|
||||
<pre>{{ JSON.stringify(reportData, null, 2) }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
const reports = ref([])
|
||||
const search = ref('')
|
||||
const currentReport = ref(null)
|
||||
const reportData = ref(null)
|
||||
const loading = ref(false)
|
||||
|
||||
// Preferred category order; anything else falls in after, sorted alphabetically.
|
||||
const categoryOrder = ['inventory', 'compliance', 'usage']
|
||||
|
||||
// server-side filters each inline report accepts (query params on its endpoint)
|
||||
const reportFilterFields = {
|
||||
'equipment-by-type': ['businessunit'],
|
||||
'assets-by-status': ['assettype', 'businessunit'],
|
||||
'asset-inventory': ['businessunit', 'location'],
|
||||
'kb-popularity': ['limit'],
|
||||
'software-compliance': ['application']
|
||||
}
|
||||
|
||||
const filterState = ref({ businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' })
|
||||
const filterOptions = ref({ businessunits: [], assettypes: [], locations: [], applications: [] })
|
||||
|
||||
const activeFilterFields = computed(() =>
|
||||
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
|
||||
)
|
||||
|
||||
function resetFilters() {
|
||||
filterState.value = { businessunitid: '', assettypeid: '', locationid: '', appid: '', limit: '' }
|
||||
}
|
||||
|
||||
// fetch dropdown options once, only for the fields the open report needs
|
||||
async function loadFilterOptions(fields) {
|
||||
try {
|
||||
if (fields.includes('businessunit') && !filterOptions.value.businessunits.length) {
|
||||
const response = await businessunitsApi.list({ perpage: 100 })
|
||||
filterOptions.value.businessunits = response.data.data || []
|
||||
}
|
||||
if (fields.includes('assettype') && !filterOptions.value.assettypes.length) {
|
||||
const response = await assetsApi.types.list()
|
||||
filterOptions.value.assettypes = response.data.data || []
|
||||
}
|
||||
if (fields.includes('location') && !filterOptions.value.locations.length) {
|
||||
const response = await locationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.locations = response.data.data || []
|
||||
}
|
||||
if (fields.includes('application') && !filterOptions.value.applications.length) {
|
||||
const response = await applicationsApi.list({ perpage: 100 })
|
||||
filterOptions.value.applications = response.data.data || []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading filter options:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function filterParams() {
|
||||
const params = {}
|
||||
const state = filterState.value
|
||||
if (state.businessunitid) params.businessunitid = state.businessunitid
|
||||
if (state.assettypeid) params.assettypeid = state.assettypeid
|
||||
if (state.locationid) params.locationid = state.locationid
|
||||
if (state.appid) params.appid = state.appid
|
||||
if (state.limit) params.limit = state.limit
|
||||
return params
|
||||
}
|
||||
|
||||
function refreshReport() {
|
||||
if (currentReport.value) runReport(currentReport.value)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadReports()
|
||||
// Honor a deep link / refresh with ?report=<id> already in the URL.
|
||||
applyQuery(route.query.report)
|
||||
})
|
||||
|
||||
async function loadReports() {
|
||||
try {
|
||||
const response = await reportsApi.list()
|
||||
reports.value = response.data.data.reports
|
||||
} catch (error) {
|
||||
console.error('Error loading reports:', error)
|
||||
}
|
||||
}
|
||||
|
||||
function titleCase(text) {
|
||||
return String(text || '')
|
||||
.split(/[\s_-]+/)
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
// Filter by name/description/category, then group by category in the fixed order.
|
||||
const groupedReports = computed(() => {
|
||||
const term = search.value.trim().toLowerCase()
|
||||
const matches = reports.value.filter(report => {
|
||||
if (!term) return true
|
||||
return (
|
||||
(report.name || '').toLowerCase().includes(term) ||
|
||||
(report.description || '').toLowerCase().includes(term) ||
|
||||
(report.category || '').toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
|
||||
const byCategory = {}
|
||||
for (const report of matches) {
|
||||
const category = report.category || 'other'
|
||||
if (!byCategory[category]) byCategory[category] = []
|
||||
byCategory[category].push(report)
|
||||
}
|
||||
|
||||
const categories = Object.keys(byCategory).sort((a, b) => {
|
||||
const ai = categoryOrder.indexOf(a)
|
||||
const bi = categoryOrder.indexOf(b)
|
||||
if (ai !== -1 || bi !== -1) {
|
||||
return (ai === -1 ? Infinity : ai) - (bi === -1 ? Infinity : bi)
|
||||
}
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
|
||||
return categories.map(category => ({ category, reports: byCategory[category] }))
|
||||
})
|
||||
|
||||
function openReport(report) {
|
||||
// Cards with a dedicated frontend page carry a route; navigate to it.
|
||||
if (report.route) {
|
||||
router.push(report.route)
|
||||
return
|
||||
}
|
||||
// PC Relationships has its own dedicated page.
|
||||
if (report.id === 'pc-relationships') {
|
||||
router.push('/reports/pc-relationships')
|
||||
return
|
||||
}
|
||||
// Inline reports live in the URL query so browser back/forward works:
|
||||
// back pops the query and returns to the card grid.
|
||||
router.push({ query: { report: report.id } })
|
||||
}
|
||||
|
||||
// The query param is the source of truth for the open inline report.
|
||||
watch(() => route.query.report, applyQuery)
|
||||
|
||||
function applyQuery(id) {
|
||||
if (!id) {
|
||||
currentReport.value = null
|
||||
reportData.value = null
|
||||
return
|
||||
}
|
||||
const report = reports.value.find(r => r.id === id)
|
||||
if (report) {
|
||||
resetFilters()
|
||||
loadFilterOptions(reportFilterFields[id] || [])
|
||||
runReport(report)
|
||||
}
|
||||
}
|
||||
|
||||
async function runReport(report) {
|
||||
currentReport.value = report
|
||||
loading.value = true
|
||||
reportData.value = null
|
||||
window.scrollTo(0, 0)
|
||||
|
||||
const params = filterParams()
|
||||
try {
|
||||
let response
|
||||
switch (report.id) {
|
||||
case 'equipment-by-type':
|
||||
response = await reportsApi.equipmentByType(params)
|
||||
break
|
||||
case 'assets-by-status':
|
||||
response = await reportsApi.assetsByStatus(params)
|
||||
break
|
||||
case 'kb-popularity':
|
||||
response = await reportsApi.kbPopularity(params)
|
||||
break
|
||||
case 'software-compliance':
|
||||
response = await reportsApi.softwareCompliance(params)
|
||||
break
|
||||
case 'asset-inventory':
|
||||
response = await reportsApi.assetInventory(params)
|
||||
break
|
||||
default:
|
||||
console.error('Unknown report:', report.id)
|
||||
return
|
||||
}
|
||||
reportData.value = response.data.data
|
||||
} catch (error) {
|
||||
console.error('Error running report:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
if (!currentReport.value) return
|
||||
const params = new URLSearchParams({ format: 'csv', ...filterParams() })
|
||||
window.open(`/api/reports/${currentReport.value.id}?${params}`, '_blank')
|
||||
}
|
||||
|
||||
function clearReport() {
|
||||
// Drop the query param; the watcher clears the panel state.
|
||||
router.push({ query: {} })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.report-group {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
margin: 0 0 1rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text);
|
||||
border-bottom: 1px solid var(--border);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.reports-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.report-card {
|
||||
cursor: pointer;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.report-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.report-card h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-card p {
|
||||
color: var(--text-light);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
color: var(--text-light);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
.report-results {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.report-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.report-header h2 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.report-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.report-summary {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.report-content h3 {
|
||||
margin-top: 1.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.report-content table {
|
||||
width: 100%;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.limit-input {
|
||||
max-width: 140px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<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>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
@@ -118,6 +119,26 @@ const filteredPrinters = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
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()
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
<div>
|
||||
<div class="page-header">
|
||||
<h1>Warranty Report</h1>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
<div class="header-actions">
|
||||
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
|
||||
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="loading">Loading...</div>
|
||||
@@ -71,6 +74,26 @@ function assetLink(a) {
|
||||
return (map[a.assettypename] || '/assets/') + a.assetid
|
||||
}
|
||||
|
||||
function exportCSV() {
|
||||
// one row per warranty, covered assets joined with ;
|
||||
const quote = value => `"${String(value ?? '').replace(/"/g, '""')}"`
|
||||
const rows = [['bucket', 'vendor', 'servicelevel', 'enddate', 'assets']]
|
||||
for (const b of bucketOrder) {
|
||||
for (const w of buckets.value[b.key] || []) {
|
||||
rows.push([
|
||||
b.label, w.vendor || '', w.servicelevel || '', w.enddate || '',
|
||||
(w.assets || []).map(a => a.assetnumber).join('; ')
|
||||
])
|
||||
}
|
||||
}
|
||||
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 = 'warranty_report.csv'
|
||||
link.click()
|
||||
URL.revokeObjectURL(link.href)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const response = await warrantyApi.report()
|
||||
@@ -100,5 +123,6 @@ onMounted(async () => {
|
||||
background: var(--bg); border-radius: 12px; font-size: 0.8rem; text-decoration: none; color: var(--text);
|
||||
}
|
||||
.muted { color: var(--text-light); }
|
||||
.header-actions { display: flex; gap: 0.5rem; }
|
||||
.servicelevel-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user