Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,101 @@
<template>
<div>
<div class="page-header">
<h1>Calibration Due</h1>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="summary-row">
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
<span class="summary-label">{{ b.label }}</span>
</div>
</div>
<template v-for="b in bucketOrder" :key="b.key">
<div class="bucket card" v-if="(buckets[b.key] || []).length">
<h3 class="bucket-title">
<span class="dot" :style="{ background: b.color }"></span>
{{ b.label }} ({{ (buckets[b.key] || []).length }})
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Asset #</th>
<th>Name</th>
<th>Type</th>
<th>Next Calibration</th>
<th>Provider</th>
</tr>
</thead>
<tbody>
<tr v-for="row in buckets[b.key]" :key="row.assetid">
<td>
<router-link :to="`/measuringtools/${row.measuringtool.measuringtoolid}`" class="asset-chip">
{{ row.assetnumber }}
</router-link>
</td>
<td>{{ row.name || '-' }}</td>
<td>{{ row.measuringtool.measuringtooltypename || '-' }}</td>
<td>{{ row.measuringtool.nextcalibrationdate ? formatDate(row.measuringtool.nextcalibrationdate) : '-' }}</td>
<td>{{ row.measuringtool.calibrationprovider || '-' }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { measuringtoolsApi } from '../../api'
const loading = ref(true)
const counts = ref({})
const buckets = ref({})
const bucketOrder = [
{ key: 'overdue', label: 'Overdue', color: '#F44336' },
{ key: 'duesoon', label: 'Due Soon', color: '#FF9800' },
{ key: 'current', label: 'Current', color: '#4CAF50' },
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
]
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
onMounted(async () => {
try {
const response = await measuringtoolsApi.calibrationReport()
counts.value = response.data.data.counts || {}
buckets.value = response.data.data.buckets || {}
} catch (err) {
console.error('Error loading calibration report:', err)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.summary-card {
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
}
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
.summary-label { font-size: 0.85rem; color: var(--text-light); }
.bucket { margin-bottom: 1.25rem; }
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.asset-chip {
display: inline-block; padding: 0.15rem 0.55rem;
background: var(--bg); border-radius: 12px; font-size: 0.85rem; text-decoration: none; color: var(--text);
}
</style>

View File

@@ -0,0 +1,144 @@
<template>
<div>
<div class="page-header">
<h2>PC-Machine Relationships</h2>
<div class="header-actions">
<button class="btn btn-primary" @click="copyTable">Copy Table</button>
<button class="btn btn-secondary" @click="copyCSV">Copy CSV</button>
<button class="btn btn-secondary" @click="copyJSON">Copy JSON</button>
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
</div>
<p class="text-muted">PCs with relationships to shop floor machines</p>
<div v-if="copied" class="copy-toast">{{ copiedFormat }} copied to clipboard!</div>
<div class="card">
<div v-if="loading" class="loading">Loading...</div>
<template v-else>
<div class="table-container">
<table id="relationshipsTable">
<thead>
<tr>
<th>Machine #</th>
<th>Vendor</th>
<th>Model</th>
<th>PC Hostname</th>
<th>PC IP</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in data" :key="idx">
<td>{{ row.machine_number }}</td>
<td>{{ row.vendor }}</td>
<td>{{ row.model }}</td>
<td class="mono">{{ row.hostname }}</td>
<td class="mono">{{ row.ip }}</td>
</tr>
<tr v-if="data.length === 0">
<td colspan="5" style="text-align: center; color: var(--text-light);">
No relationships found
</td>
</tr>
</tbody>
</table>
</div>
<p class="record-count">{{ data.length }} records found</p>
</template>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { reportsApi } from '../../api'
const loading = ref(true)
const data = ref([])
const copied = ref(false)
const copiedFormat = ref('')
onMounted(async () => {
try {
const response = await reportsApi.pcRelationships()
data.value = response.data.data?.data || []
} catch (error) {
console.error('Error loading report:', error)
} finally {
loading.value = false
}
})
function showCopied(format) {
copiedFormat.value = format
copied.value = true
setTimeout(() => { copied.value = false }, 2000)
}
function copyTable() {
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
let text = headers.join('\t') + '\n'
for (const row of data.value) {
text += [row.machine_number, row.vendor, row.model, row.hostname, row.ip].join('\t') + '\n'
}
navigator.clipboard.writeText(text).then(() => showCopied('Table'))
}
function copyCSV() {
const headers = ['Machine #', 'Vendor', 'Model', 'PC Hostname', 'PC IP']
let csv = headers.map(h => `"${h}"`).join(',') + '\n'
for (const row of data.value) {
csv += [row.machine_number, row.vendor, row.model, row.hostname, row.ip]
.map(v => `"${v}"`)
.join(',') + '\n'
}
navigator.clipboard.writeText(csv).then(() => showCopied('CSV'))
}
function copyJSON() {
const jsonData = data.value.map(row => ({
Name: row.hostname,
IpAddress: row.ip,
Group: null
}))
navigator.clipboard.writeText(JSON.stringify(jsonData, null, 2)).then(() => showCopied('JSON'))
}
</script>
<style scoped>
.text-muted {
color: var(--text-light);
margin-bottom: 1rem;
}
.mono {
font-family: 'SF Mono', 'Monaco', 'Consolas', monospace;
}
.record-count {
color: var(--text-light);
margin-top: 1rem;
font-size: 0.875rem;
}
.copy-toast {
position: fixed;
top: 20px;
right: 20px;
background: #28a745;
color: white;
padding: 0.75rem 1.5rem;
border-radius: 6px;
font-weight: 500;
z-index: 9999;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(-10px); }
to { opacity: 1; transform: translateY(0); }
}
</style>

View File

@@ -0,0 +1,514 @@
<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">
<!-- Machines by Type -->
<div v-if="currentReport.id === 'machines-by-type'" class="report-content">
<p class="report-summary">Total: {{ reportData.total }}</p>
<table>
<thead>
<tr>
<th>Machine Type</th>
<th>Description</th>
<th>Count</th>
</tr>
</thead>
<tbody>
<tr v-for="item in reportData.data" :key="item.machinetype">
<td>{{ item.machinetype }}</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'
import { withBase } from '@/utils/basePath'
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 = {
'machines-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 'machines-by-type':
response = await reportsApi.machinesByType(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(withBase(`/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

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

View File

@@ -0,0 +1,156 @@
<template>
<div>
<div class="page-header">
<h1>Warranty Report</h1>
<div class="header-actions">
<button v-if="!loading" class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="!loading" subject="Warranty 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...</div>
<template v-else>
<div class="summary-row">
<div v-for="b in bucketOrder" :key="b.key" class="summary-card" :style="cardStyle(b.color)">
<span class="summary-count">{{ counts[b.key] || 0 }}</span>
<span class="summary-label">{{ b.label }}</span>
</div>
</div>
<template v-for="b in bucketOrder" :key="b.key">
<div class="bucket card" v-if="(buckets[b.key] || []).length">
<h3 class="bucket-title">
<span class="dot" :style="{ background: b.color }"></span>
{{ b.label }} ({{ (buckets[b.key] || []).length }})
</h3>
<div class="table-container">
<table>
<thead>
<tr>
<th>Vendor</th>
<th>Service Level</th>
<th>Ends</th>
<th>Covers</th>
</tr>
</thead>
<tbody>
<tr v-for="w in buckets[b.key]" :key="w.warrantyid">
<td><strong>{{ w.vendor }}</strong></td>
<td class="servicelevel-cell" :title="w.servicelevel">{{ w.servicelevel || '-' }}</td>
<td>{{ w.enddate ? formatDate(w.enddate) : '-' }}</td>
<td>
<router-link v-for="a in w.assets" :key="a.assetid" :to="assetLink(a)" class="asset-chip">{{ a.assetnumber }}</router-link>
<span v-if="!w.assets.length" class="muted">-</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
</template>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { warrantyApi } from '../../api'
import EmailReportButton from '../../components/EmailReportButton.vue'
const loading = ref(true)
const counts = ref({})
const buckets = ref({})
const emailColumns = [
{ key: 'bucket', label: 'Status' },
{ key: 'vendor', label: 'Vendor' },
{ key: 'servicelevel', label: 'Service Level' },
{ key: 'enddate', label: 'Ends' },
{ key: 'assets', label: 'Covers' },
]
// Flatten the buckets into one row per warranty for the emailed table.
const emailRows = computed(() => {
const rows = []
for (const b of bucketOrder) {
for (const w of buckets.value[b.key] || []) {
rows.push({
bucket: b.label,
vendor: w.vendor || '',
servicelevel: w.servicelevel || '',
enddate: w.enddate || '',
assets: (w.assets || []).map(a => a.assetnumber).join(', '),
})
}
}
return rows
})
const bucketOrder = [
{ key: 'expired', label: 'Expired', color: '#F44336' },
{ key: 'expiring', label: 'Expiring Soon', color: '#FF9800' },
{ key: 'active', label: 'Active', color: '#4CAF50' },
{ key: 'unknown', label: 'Unknown', color: '#9E9E9E' },
]
function formatDate(d) { return new Date(d + 'T00:00:00').toLocaleDateString() }
function cardStyle(color) { return { borderTop: `3px solid ${color}` } }
function assetLink(a) {
const map = { computer: '/pcs/', printer: '/printers/', network_device: '/network/', machine: '/machines/', measuring_tool: '/measuringtools/by-asset/' }
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()
counts.value = response.data.data.counts || {}
buckets.value = response.data.data.buckets || {}
} catch (err) {
console.error('Error loading warranty report:', err)
} finally {
loading.value = false
}
})
</script>
<style scoped>
.summary-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.5rem; }
.summary-card {
flex: 1; min-width: 140px; padding: 1rem 1.25rem; background: var(--bg-card);
border: 1px solid var(--border); border-radius: 8px; display: flex; flex-direction: column; gap: 0.25rem;
}
.summary-count { font-size: 1.8rem; font-weight: 700; color: var(--text); }
.summary-label { font-size: 0.85rem; color: var(--text-light); }
.bucket { margin-bottom: 1.25rem; }
.bucket-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.75rem; }
.dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; }
.asset-chip {
display: inline-block; padding: 0.15rem 0.55rem; margin: 0 0.25rem 0.25rem 0;
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>