Email report on every report page; kiosk long-name fix

This commit is contained in:
2026-08-27 11:01:28 -04:00
parent 4dfbf5f8ec
commit 5f069b1a5f
7 changed files with 233 additions and 12 deletions

View File

@@ -24,6 +24,27 @@ ADR-007 and ADR-002.
### Fixed
- **Every report offers to email itself, not just two of them.** `POST
/api/reports/email` has always taken any `{columns, rows}`, but the button was
on the toner and warranty pages only - because those two 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: the plugin reports through their declared columns, the 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, since a
column of hex codes is worse than no column. The button is hidden without the
`reports.export` permission the endpoint enforces, on all three pages: offering
somebody a button that can only answer 403 is worse than not offering it.
- **A long drive name pushed Take and Return out of the card.** On the USB kiosk
the name is a flex item, which defaults to `min-width: auto`, so it kept its
full intrinsic width and shoved the button through the side of the card -
leaving a drive nobody could take. Names now wrap, including the serial-style
ones that carry no spaces at all, and a description is clamped to two lines so
one long name cannot make every card in its row tall. The locker line is never
clipped: it is how somebody finds the thing.
- **Muted text on the USB kiosk and in guides was not muted.** Both named a
`--textmuted` variable this app has never defined, so the browser dropped the
declaration and the hints, subtitles and locker lines rendered at full

View File

@@ -0,0 +1,69 @@
// Turning a report payload into the one table shape everything else speaks.
//
// POST /api/reports/email has always taken {columns, rows} from any caller, but
// only two pages ever offered an Email report button - the two that happen to
// be their own routed pages. Every other report was reachable only from the
// reports page, which had no button at all. This builds that table from
// whatever shape the open report returned, so the offer is the same everywhere.
export function titleCase (text) {
return String(text || '')
.split(/[\s_-]+/)
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
// Presentation-only keys. A colour swatch and a row id mean nothing in an
// email, and a column of hex codes is worse than no column at all.
const EMAIL_SKIP = ['color', 'linkid', 'linkurl', 'id']
function emailcolumns (keys) {
return keys.filter(key => !EMAIL_SKIP.includes(key))
.map(key => ({ key, label: titleCase(key) }))
}
export function emailTableFrom (data) {
if (!data) return null
// Any report that speaks the {columns, rows} contract - every plugin report,
// and any core one that adopts it. Declared columns win; otherwise take them
// from the first row, the same way the on-screen table does.
if (Array.isArray(data.rows) && data.rows.length) {
const keys = (Array.isArray(data.columns) && data.columns.length)
? data.columns
: Object.keys(data.rows[0])
return { columns: emailcolumns(keys), rows: data.rows }
}
// The core reports, which return a flat list under `data`.
if (Array.isArray(data.data) && data.data.length) {
return { columns: emailcolumns(Object.keys(data.data[0])), rows: data.data }
}
// Asset inventory is three tables. One email, so they become one table with
// the grouping named in a column - sending a third of it would be worse.
if (data.data && typeof data.data === 'object') {
const rows = []
for (const [section, entries] of Object.entries(data.data)) {
if (!Array.isArray(entries)) continue
for (const entry of entries) {
rows.push({
section: titleCase(section),
name: entry.type || entry.status || entry.location || '',
count: entry.count,
})
}
}
if (!rows.length) return null
return {
columns: [{ key: 'section', label: 'Grouping' },
{ key: 'name', label: 'Name' },
{ key: 'count', label: 'Count' }],
rows,
}
}
// An empty report has nothing to send. The button hides rather than mailing
// somebody a table with no rows in it.
return null
}

View File

@@ -0,0 +1,84 @@
// Every shape a report on this app actually returns, because the Email report
// button now has to build a table out of all of them rather than only the two
// that had their own page.
import { describe, it, expect } from 'vitest'
import { emailTableFrom, titleCase } from './reportTable'
describe('emailTableFrom', () => {
it('has nothing to send for a report that has not run', () => {
expect(emailTableFrom(null)).toBe(null)
expect(emailTableFrom({})).toBe(null)
})
it('uses the declared columns of a {columns, rows} report', () => {
const table = emailTableFrom({
columns: ['timestamp', 'action', 'device_id'],
rows: [{ timestamp: 't', action: 'check-out', device_id: 'USB-3' }],
})
expect(table.columns).toEqual([
{ key: 'timestamp', label: 'Timestamp' },
{ key: 'action', label: 'Action' },
{ key: 'device_id', label: 'Device Id' },
])
expect(table.rows).toHaveLength(1)
})
it('falls back to the first row when a report declares no columns', () => {
const table = emailTableFrom({ rows: [{ item: 'a', takes: 2 }] })
expect(table.columns.map(c => c.key)).toEqual(['item', 'takes'])
})
it('builds a table from a core report that returns a flat list', () => {
const table = emailTableFrom({
report: 'machines_by_type',
total: 231,
data: [{ machinetype: 'Lathe', description: 'd', count: 12 }],
})
expect(table.columns.map(c => c.key)).toEqual(['machinetype', 'description', 'count'])
expect(table.rows[0].count).toBe(12)
})
it('drops the keys that only mean something on screen', () => {
const table = emailTableFrom({
data: [{ status: 'In Use', color: '#28a745', count: 572 }],
})
expect(table.columns.map(c => c.key)).toEqual(['status', 'count'])
const kb = emailTableFrom({
data: [{ linkid: 4, linkurl: 'http://x', shortdescription: 'How to', clicks: 9 }],
})
expect(kb.columns.map(c => c.key)).toEqual(['shortdescription', 'clicks'])
})
it('flattens the three tables of asset inventory into one, named', () => {
const table = emailTableFrom({
data: {
bytype: [{ type: 'computer', count: 289 }],
bystatus: [{ status: 'In Use', color: '#0f0', count: 572 }],
bylocation: [{ location: 'Cell A', count: 3 }],
},
})
expect(table.columns.map(c => c.key)).toEqual(['section', 'name', 'count'])
expect(table.rows).toEqual([
{ section: 'Bytype', name: 'computer', count: 289 },
{ section: 'Bystatus', name: 'In Use', count: 572 },
{ section: 'Bylocation', name: 'Cell A', count: 3 },
])
})
it('has nothing to send for a report that came back empty', () => {
expect(emailTableFrom({ data: [], total: 0 })).toBe(null)
expect(emailTableFrom({ columns: ['a'], rows: [] })).toBe(null)
expect(emailTableFrom({ data: { bytype: [], bystatus: [] } })).toBe(null)
})
})
describe('titleCase', () => {
it('turns a column key into a heading', () => {
expect(titleCase('locker_location')).toBe('Locker Location')
expect(titleCase('machines-by-type')).toBe('Machines By Type')
expect(titleCase('')).toBe('')
})
})

View File

@@ -43,6 +43,9 @@
<h2>{{ currentReport.name }}</h2>
<div class="report-actions">
<button class="btn btn-secondary" @click="exportCSV">Export CSV</button>
<EmailReportButton v-if="emailtable && canemail"
:subject="currentReport.name"
:columns="emailtable.columns" :rows="emailtable.rows" />
<button class="btn btn-secondary" @click="clearReport">Back to Reports</button>
</div>
</div>
@@ -266,9 +269,18 @@ import { ref, computed, onMounted, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import api, { reportsApi, businessunitsApi, assetsApi, locationsApi, applicationsApi } from '@/api'
import { apiError } from '@/utils/apiError'
import EmailReportButton from '@/components/EmailReportButton.vue'
import { emailTableFrom, titleCase } from '@/utils/reportTable'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const authStore = useAuthStore()
// Emailing a report is the reports.export permission, the same one the endpoint
// enforces. Offering the button to somebody who will only get a 403 is worse
// than not offering it.
const canemail = computed(() => authStore.hasPermission('reports.export'))
const reports = ref([])
const search = ref('')
@@ -321,6 +333,12 @@ const genericColumns = computed(() => {
return data.rows?.length ? Object.keys(data.rows[0]) : []
})
// The table to email, built from whatever the open report returned. Emailing
// was available on two pages and nowhere else, purely because those two were
// built as their own routed pages - the endpoint has always taken any
// {columns, rows}. Lives in its own module so the shapes can be tested.
const emailtable = computed(() => emailTableFrom(reportData.value))
const activeFilterFields = computed(() =>
currentReport.value ? (reportFilterFields[currentReport.value.id] || []) : []
)
@@ -391,12 +409,6 @@ async function loadReports() {
}
}
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(() => {

View File

@@ -4,7 +4,7 @@
<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"
<EmailReportButton v-if="!loading && !error && canemail" subject="Toner / Supply Report"
:columns="emailColumns" :rows="emailRows" />
<router-link to="/reports" class="btn btn-secondary">Back to Reports</router-link>
</div>
@@ -133,9 +133,16 @@
import { ref, computed, onMounted } from 'vue'
import { printersApi } from '@/api'
import EmailReportButton from '@/components/EmailReportButton.vue'
import { useAuthStore } from '@/stores/auth'
import LocationMapTooltip from '@/components/LocationMapTooltip.vue'
import { getPrinterHostnameTemplate } from '@/utils/siteSettings'
// 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 emailColumns = [
{ key: 'printer', label: 'Printer' },
{ key: 'hostname', label: 'Hostname' },

View File

@@ -50,7 +50,7 @@
@click="act(drive, 'checkin')">
<span class="drive-name">
{{ drive.device_id }}
<small v-if="drive.device_desc">{{ drive.device_desc }}</small>
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
@@ -83,7 +83,7 @@
@click="act(drive, 'checkout')">
<span class="drive-name">
{{ drive.device_id }}
<small v-if="drive.device_desc">{{ drive.device_desc }}</small>
<small v-if="drive.device_desc" class="drive-desc">{{ drive.device_desc }}</small>
<small v-if="drive.locker_location">Locker {{ drive.locker_location }}</small>
</span>
<span class="drive-action take">Take</span>
@@ -112,7 +112,7 @@
@click="act(drive, 'checkin', true)">
<span class="drive-name">
{{ drive.device_id }}
<small>{{ drive.holdername || 'somebody else' }}</small>
<small class="drive-desc">{{ drive.holdername || 'somebody else' }}</small>
</span>
<span class="drive-action return">Return</span>
</button>
@@ -436,8 +436,29 @@ function reset () {
cursor: pointer;
}
.drive-row:disabled { opacity: 0.5; }
.drive-name { display: flex; flex-direction: column; gap: 0.15rem; }
/* The name has to YIELD. A flex item defaults to min-width:auto, so a long
label kept its intrinsic width and shoved Take or Return out through the side
of the card - a drive nobody could take. `anywhere` breaks the labels that
carry no spaces at all, which a serial-style name often does not. */
.drive-name {
display: flex;
flex-direction: column;
gap: 0.15rem;
flex: 1 1 auto;
min-width: 0;
overflow-wrap: anywhere;
}
.drive-name small { color: var(--text-light); font-size: 0.85rem; }
/* Two lines of description is enough to tell two drives apart; more just makes
one card tall and every card in its row tall with it. The locker line below
is never clipped - it is how somebody finds the thing. */
.drive-desc {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.drive-action {
flex: 0 0 auto;
padding: 0.5rem 1.1rem;

View File

@@ -4,7 +4,7 @@
<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"
<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>
@@ -65,8 +65,15 @@
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({})