feat(reporting) : add Reporting dashboard front layer

LST-59 (3.1) front. Completes the Reporting module.

- New frontend/modules/reporting/ layer (auto-detected): /reporting page
  (admin middleware) consuming the 4 read-only report endpoints.
- Filters (period presets + custom dates, project, user). 4 sections (time per
  project, time per user, tasks by status, absences by type) each with a
  DataTable + a Chart.js chart (reused global registration).
- Front-side CSV export per section (useCsvExport: BOM UTF-8, ; separator).
- i18n keys (reporting.*, sidebar.admin.reporting).

nuxt build passes; /reporting routed; no route regression.
This commit is contained in:
Matthieu
2026-06-21 00:16:03 +02:00
parent b3b29fd753
commit f4ffc02028
9 changed files with 713 additions and 1 deletions
@@ -0,0 +1,44 @@
export type CsvColumn<T> = {
header: string
value: (row: T) => string | number
}
/** Escapes a single CSV cell (quotes wrapping + doubled inner quotes). */
function escapeCell(value: string | number): string {
const str = String(value ?? '')
if (/[",\n;]/.test(str)) {
return `"${str.replace(/"/g, '""')}"`
}
return str
}
export function useCsvExport() {
/** Builds a CSV string from rows + column definitions (semicolon separator, FR-friendly). */
function toCsv<T>(rows: T[], columns: CsvColumn<T>[]): string {
const header = columns.map(c => escapeCell(c.header)).join(';')
const lines = rows.map(row =>
columns.map(c => escapeCell(c.value(row))).join(';'),
)
return [header, ...lines].join('\n')
}
/** Triggers a browser download of the given CSV content. */
function downloadCsv<T>(filename: string, rows: T[], columns: CsvColumn<T>[]): void {
if (typeof window === 'undefined') {
return
}
const csv = toCsv(rows, columns)
// Prepend BOM so Excel detects UTF-8.
const blob = new Blob([`${csv}`], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename.endsWith('.csv') ? filename : `${filename}.csv`
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
URL.revokeObjectURL(url)
}
return { toCsv, downloadCsv }
}
@@ -0,0 +1,41 @@
export type ReportPeriodKey = 'thisMonth' | 'lastMonth' | 'custom'
export type ReportPeriodRange = {
from: string
to: string
}
/** Formats a Date as an ISO "YYYY-MM-DD" string (local time, no timezone shift). */
function toIsoDate(date: Date): string {
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${year}-${month}-${day}`
}
/** Returns the {from, to} range (inclusive) for a given month offset relative to now. */
function getMonthRange(offset: number): ReportPeriodRange {
const now = new Date()
const start = new Date(now.getFullYear(), now.getMonth() + offset, 1)
const end = new Date(now.getFullYear(), now.getMonth() + offset + 1, 0)
return { from: toIsoDate(start), to: toIsoDate(end) }
}
export function useReportingFilters() {
const thisMonth = (): ReportPeriodRange => getMonthRange(0)
const lastMonth = (): ReportPeriodRange => getMonthRange(-1)
/** Resolves a preset key to a concrete range. `custom` keeps the provided range. */
function rangeForPreset(key: ReportPeriodKey, current: ReportPeriodRange): ReportPeriodRange {
switch (key) {
case 'thisMonth':
return thisMonth()
case 'lastMonth':
return lastMonth()
case 'custom':
return current
}
}
return { thisMonth, lastMonth, rangeForPreset, toIsoDate }
}