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

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