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:
cproudlock
2026-07-11 10:01:47 -04:00
parent b8c22244a1
commit 22e623c1f6
85 changed files with 3274 additions and 972 deletions

View File

@@ -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>

View File

@@ -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()

View File

@@ -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>