POST /api/reports/email has always taken any {columns, rows} from any caller,
but the Email report button existed on the toner and warranty pages only - not
because those reports are special, but because they happen to be their own
routed pages and the reports page had no button at all. It is now beside Export
CSV for every report the page can build a table from, which is all of them:
plugin reports through their declared columns, core reports through the flat
list they return, and the asset inventory summary by flattening its three
tables into one with the grouping named in a column. Presentation-only fields -
a status colour, a link id - are left out; a column of hex codes is worse than
no column. The shapes live in a module with tests rather than in the view.
The button is now hidden without reports.export on all three pages. Offering
somebody a button that can only answer 403 is worse than not offering it.
Separately, on the USB kiosk a long drive name pushed Take or Return out
through the side of its card, leaving a drive nobody could press. The name is a
flex item and so defaulted to min-width:auto, keeping its full intrinsic width;
it now yields, wraps mid-word for the serial-style names that carry no spaces,
and clamps its description to two lines so one long name cannot make every card
in the row tall. The locker line is never clipped - it is how somebody finds
the drive.
172 lines
6.4 KiB
Vue
172 lines
6.4 KiB
Vue
<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 && canemail" 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>
|
|
<th>Machine #</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>
|
|
<td>
|
|
<template v-for="a in w.assets" :key="`m-${a.assetid}`">
|
|
<MachineMapChip v-if="a.machine" :machine="a.machine" />
|
|
</template>
|
|
<span v-if="!w.assets.some(a => a.machine)" 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'
|
|
import { useAuthStore } from '@/stores/auth'
|
|
import MachineMapChip from '../components/MachineMapChip.vue'
|
|
|
|
// Emailing a report is the reports.export permission the endpoint enforces.
|
|
// Offering the button to somebody who will only get a 403 is worse than not
|
|
// offering it.
|
|
const authStore = useAuthStore()
|
|
const canemail = computed(() => authStore.hasPermission('reports.export'))
|
|
|
|
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>
|