diff --git a/frontend/src/lib/exportXlsx.js b/frontend/src/lib/exportXlsx.js index d5fb919..6198d7a 100644 --- a/frontend/src/lib/exportXlsx.js +++ b/frontend/src/lib/exportXlsx.js @@ -24,30 +24,15 @@ const BRAND = { const thin = { style: 'thin', color: { argb: BRAND.border } } -/** - * Build and download a styled .xlsx. - * - * @param {object} opts - * @param {string} opts.filename without extension - * @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox" - * @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026" - * @param {Array<{header: string, key: string, width?: number}>} opts.columns - * @param {Array} opts.rows keyed by columns[].key; null/undefined print blank - */ -export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) { - const ExcelJS = (await import('exceljs')).default - const wb = new ExcelJS.Workbook() - wb.creator = 'TalentFlow ATS' - wb.created = new Date() - - const ws = wb.addWorksheet(title.slice(0, 31) || 'Export', { - views: [{ state: 'frozen', ySplit: 3 }], - }) +function sanitizeSheetName(name) { + const cleaned = String(name || 'Export').replace(/[:\\/?*[\]]/g, ' ').trim() + return cleaned.slice(0, 31) || 'Export' +} +function paintSheet(ws, { title, subtitle, columns, rows }) { ws.columns = columns.map((c) => ({ key: c.key, width: c.width ?? 18 })) - const span = columns.length + const span = Math.max(1, columns.length) - // Row 1 — brand title band const titleRow = ws.addRow([title]) ws.mergeCells(1, 1, 1, span) titleRow.height = 30 @@ -56,7 +41,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row titleCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.green } } titleCell.alignment = { vertical: 'middle', indent: 1 } - // Row 2 — meta line const metaRow = ws.addRow([subtitle]) ws.mergeCells(2, 1, 2, span) metaRow.height = 20 @@ -65,7 +49,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row metaCell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.mint } } metaCell.alignment = { vertical: 'middle', indent: 1 } - // Row 3 — column headers const headRow = ws.addRow(columns.map((c) => c.header)) headRow.height = 22 headRow.eachCell((cell) => { @@ -75,7 +58,6 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row cell.border = { bottom: { style: 'medium', color: { argb: BRAND.lime } } } }) - // Data — zebra rows with hairline borders for (const [i, r] of rows.entries()) { const row = ws.addRow(columns.map((c) => r[c.key] ?? '')) row.eachCell({ includeEmpty: true }, (cell) => { @@ -87,7 +69,9 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row } ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: span } } +} +async function downloadWorkbook(wb, filename) { const buf = await wb.xlsx.writeBuffer() const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const a = document.createElement('a') @@ -98,3 +82,54 @@ export async function exportStyledXlsx({ filename, title, subtitle, columns, row a.remove() URL.revokeObjectURL(a.href) } + +/** + * Build and download a styled multi-sheet .xlsx. + * + * @param {object} opts + * @param {string} opts.filename without extension + * @param {Array<{name?: string, title: string, subtitle: string, columns: Array<{header: string, key: string, width?: number}>, rows: Array}>} opts.sheets + */ +export async function exportStyledWorkbook({ filename, sheets }) { + const ExcelJS = (await import('exceljs')).default + const wb = new ExcelJS.Workbook() + wb.creator = 'TalentFlow ATS' + wb.created = new Date() + + const used = new Set() + for (const sheet of sheets) { + if (!sheet?.columns?.length) continue + let name = sanitizeSheetName(sheet.name || sheet.title) + if (used.has(name)) { + const base = name.slice(0, 28) + let n = 2 + while (used.has(`${base} ${n}`)) n += 1 + name = `${base} ${n}` + } + used.add(name) + const ws = wb.addWorksheet(name, { + views: [{ state: 'frozen', ySplit: 3 }], + }) + paintSheet(ws, sheet) + } + + if (!used.size) throw new Error('Nothing to export') + await downloadWorkbook(wb, filename) +} + +/** + * Build and download a styled .xlsx. + * + * @param {object} opts + * @param {string} opts.filename without extension + * @param {string} opts.title big brand-band line, e.g. "Recruitment Inbox" + * @param {string} opts.subtitle meta line, e.g. "All Applications · 512 rows · 02/09/2026" + * @param {Array<{header: string, key: string, width?: number}>} opts.columns + * @param {Array} opts.rows keyed by columns[].key; null/undefined print blank + */ +export async function exportStyledXlsx({ filename, title, subtitle, columns, rows }) { + return exportStyledWorkbook({ + filename, + sheets: [{ name: title, title, subtitle, columns, rows }], + }) +} diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index aa90eaa..8b0c1ff 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -31,11 +31,14 @@ import Charts from '../lib/charts' import ChartCard, { widgetError } from '../ui/ChartCard' import PageHeader from '../ui/PageHeader' import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives' +import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { isHiringManager } from '../auth/permissions' import { qk } from '../lib/queryKeys' +import { exportStyledWorkbook } from '../lib/exportXlsx' import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges' -import { fmtWeekdayDate, fmtShort } from '../lib/format' +import { fmtWeekdayDate, fmtShort, fmtDate } from '../lib/format' +import { friendlyAuthError } from '../lib/errors' import { money } from '../data/seed' import * as activityApi from '../api/activity' import * as analyticsApi from '../api/analytics' @@ -102,6 +105,261 @@ function fmtWhen(iso) { return fmtShort(iso) || '—' } +function starvingCountOf(jobs) { + return asList(jobs).filter((r) => r.requisition_status === 'open' && !r.count).length +} + +function kpiValue(kpis, key, { round = false } = {}) { + if (!kpis || kpis[key] == null || kpis[key] === '') return '' + const n = Number(kpis[key]) + if (!Number.isFinite(n)) return kpis[key] + return round ? Math.round(n) : n +} + +function kpiChange(kpis, key, priorKey, kind) { + if (!kpis) return '' + const text = kind === 'days' + ? dayDelta(kpis[key], kpis[priorKey]) + : pctDelta(kpis[key], kpis[priorKey]) + return text || '' +} + +async function settle(promise) { + try { + return await promise + } catch { + return null + } +} + +const KPI_EXPORT_ROWS = [ + { label: 'Open Jobs', key: 'open_jobs', prior: 'open_jobs_prior', delta: 'pct' }, + { label: 'Applications', key: 'total_candidates', prior: 'total_candidates_prior', delta: 'pct' }, + { label: 'Hires', key: 'hires', prior: 'hires_prior', delta: 'pct' }, + { label: 'Offers Sent', key: 'offers_sent', prior: 'offers_sent_prior', delta: 'pct' }, + { label: 'Offers Accepted', key: 'offers_accepted', prior: 'offers_accepted_prior', delta: 'pct' }, + { label: 'Interviews Today', key: 'interviews_today' }, + { label: 'Interviews Upcoming', key: 'interviews_upcoming' }, + { label: 'Time to Hire (days)', key: 'time_to_hire', prior: 'time_to_hire_prior', delta: 'days', round: true }, + { label: 'Cost per Hire', key: 'cost_per_hire', prior: 'cost_per_hire_prior', delta: 'pct', round: true }, +] + +async function fetchRangeSnapshot(rangeKey, department) { + const span = rangeWindow(rangeKey) + const filters = { ...span, department: department || undefined } + const [kpisRes, jobsRes, funnelRes] = await Promise.all([ + analyticsApi.kpis(filters), + analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters }), + analyticsApi.funnel(filters), + ]) + return { + key: rangeKey, + label: rangeLabel(rangeKey), + fromDate: span.fromDate, + toDate: span.toDate, + kpis: asObject(kpisRes?.data), + jobs: asList(jobsRes?.data), + funnel: asList(funnelRes?.data), + } +} + +function buildDashboardSheets({ + department, + snapshots, + offers, + inboxCounts, + activity, + trend, + exportedAt, +}) { + const deptLabel = department || 'All Departments' + const stamp = fmtDate(exportedAt) || exportedAt.toISOString().slice(0, 10) + const windowNote = `Week / Month / Quarter / Year · ${deptLabel} · exported ${stamp}` + const periodCols = RANGES.flatMap((r) => ([ + { header: r.label, key: r.key, width: 12 }, + { header: `${r.label} vs prior`, key: `${r.key}_delta`, width: 16 }, + ])) + + const overview = { + name: 'Overview', + title: 'Dashboard Export', + subtitle: windowNote, + columns: [ + { header: 'Field', key: 'field', width: 28 }, + { header: 'Value', key: 'value', width: 48 }, + ], + rows: [ + { field: 'Exported at', value: stamp }, + { field: 'Department', value: deptLabel }, + ...snapshots.map((s) => ({ + field: `${s.label} window`, + value: `${fmtDate(s.fromDate) || s.fromDate} – ${fmtDate(s.toDate) || s.toDate}`, + })), + ], + } + + const kpis = { + name: 'KPIs', + title: 'Dashboard KPIs', + subtitle: `Headline tiles across every window · ${windowNote}`, + columns: [ + { header: 'Metric', key: 'metric', width: 28 }, + ...periodCols, + ], + rows: [ + ...KPI_EXPORT_ROWS.map((spec) => { + const row = { metric: spec.label } + for (const s of snapshots) { + row[s.key] = kpiValue(s.kpis, spec.key, { round: spec.round }) + row[`${s.key}_delta`] = spec.prior + ? kpiChange(s.kpis, spec.key, spec.prior, spec.delta) + : '' + } + return row + }), + (() => { + const row = { metric: 'Open jobs with no applications' } + for (const s of snapshots) { + row[s.key] = starvingCountOf(s.jobs) + row[`${s.key}_delta`] = '' + } + return row + })(), + ], + } + + const jobRows = snapshots.flatMap((s) => ( + asList(s.jobs).map((j) => ({ + period: s.label, + title: j.title || '', + department: j.department || '', + status: jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status || '', + vacancies: j.vacancies ?? '', + applications: j.count ?? 0, + })) + )) + + const jobs = { + name: 'Applications per Job', + title: 'Applications per Job', + subtitle: `Same source as the dashboard hero · ${windowNote}`, + columns: [ + { header: 'Period', key: 'period', width: 12 }, + { header: 'Job Title', key: 'title', width: 36 }, + { header: 'Department', key: 'department', width: 22 }, + { header: 'Status', key: 'status', width: 14 }, + { header: 'Vacancies', key: 'vacancies', width: 12 }, + { header: 'Applications', key: 'applications', width: 14 }, + ], + rows: jobRows, + } + + const pipelineRows = snapshots.flatMap((s) => { + const rows = analyticsApi.toBoardStageRows(s.funnel, { includeRejected: true }) + const total = rows.reduce((sum, r) => sum + r.count, 0) + return rows.map((r) => ({ + period: s.label, + stage: r.stage, + count: r.count, + share: total ? `${Math.round((r.count / total) * 100)}%` : '0%', + })) + }) + + const pipeline = { + name: 'Pipeline', + title: 'Candidate Pipeline', + subtitle: `Active by stage · ${windowNote}`, + columns: [ + { header: 'Period', key: 'period', width: 12 }, + { header: 'Stage', key: 'stage', width: 16 }, + { header: 'Count', key: 'count', width: 12 }, + { header: 'Share', key: 'share', width: 12 }, + ], + rows: pipelineRows, + } + + const offerCounts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0])) + for (const o of asList(offers)) { + if (o.status in offerCounts) offerCounts[o.status] += 1 + } + const offerBook = { + name: 'Offer Book', + title: 'Offer Book', + subtitle: `All offers by status · point-in-time · exported ${stamp}`, + columns: [ + { header: 'Status', key: 'status', width: 18 }, + { header: 'Count', key: 'count', width: 12 }, + ], + rows: offers == null + ? [{ status: 'Offers not visible', count: '' }] + : offersApi.OFFER_STATUSES.map((s) => ({ + status: offersApi.OFFER_STATUS_LABEL[s], + count: offerCounts[s], + })), + } + + const counts = inboxCounts ?? {} + const attention = { + name: 'Needs Attention', + title: 'Needs Attention', + subtitle: `Work queued right now · exported ${stamp}`, + columns: [ + { header: 'Item', key: 'item', width: 36 }, + { header: 'Count', key: 'count', width: 12 }, + { header: 'Scope', key: 'scope', width: 22 }, + ], + rows: [ + { item: 'Unread applications', count: counts.unread ?? '', scope: 'Now' }, + { item: 'Not assigned to a job', count: counts.unassigned ?? '', scope: 'Now' }, + { item: 'Flagged duplicates', count: counts.duplicates ?? '', scope: 'Now' }, + ...snapshots.map((s) => ({ + item: 'Open jobs with no applications', + count: starvingCountOf(s.jobs), + scope: s.label, + })), + ], + } + + const activitySheet = { + name: 'Recent Activity', + title: 'Recent Activity', + subtitle: `Latest across the org · exported ${stamp}`, + columns: [ + { header: 'When', key: 'when', width: 18 }, + { header: 'Type', key: 'type', width: 22 }, + { header: 'Actor', key: 'actor', width: 22 }, + { header: 'Status', key: 'status', width: 16 }, + { header: 'Description', key: 'description', width: 48 }, + ], + rows: asList(activity).map((a) => ({ + when: fmtWhen(a.activity_date), + type: a.activity_type || 'Activity', + actor: a.actor_name || '', + status: a.activity_status || '', + description: a.description || '', + })), + } + + const labels = asList(trend?.labels) + const trendSheet = { + name: 'Hiring Trend', + title: 'Hiring Trend', + subtitle: `Last ${TREND_MONTHS} months · sparkline source · exported ${stamp}`, + columns: [ + { header: 'Month', key: 'month', width: 16 }, + { header: 'Applications', key: 'applications', width: 14 }, + { header: 'Hires', key: 'hires', width: 12 }, + ], + rows: labels.map((month, i) => ({ + month, + applications: asList(trend?.applications)[i] ?? 0, + hires: asList(trend?.hires)[i] ?? 0, + })), + } + + return [overview, kpis, jobs, pipeline, offerBook, attention, activitySheet, trendSheet] +} + function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) { if (query.isPending) { return ( @@ -136,12 +394,14 @@ export default function Dashboard() { function DashboardHome() { const navigate = useNavigate() + const { toast } = useToast() const { user } = useAuth() const firstName = (user?.name || 'there').split(' ')[0] const todayLabel = formatDashDate() const [rangeKey, setRangeKey] = useState('month') const [department, setDepartment] = useState('') + const [exporting, setExporting] = useState(false) // rangeWindow returns fresh ISO strings on every call — recompute only when // the key changes, or every render would churn the query keys below. const span = useMemo(() => rangeWindow(rangeKey), [rangeKey]) @@ -353,6 +613,38 @@ function DashboardHome() { }, ] + async function exportDashboard() { + if (exporting) return + setExporting(true) + try { + const [snapshots, offersRes, inboxCounts, activityRes, trendRes] = await Promise.all([ + Promise.all(RANGES.map((r) => fetchRangeSnapshot(r.key, department))), + settle(offersApi.list({ top: 500 })), + settle(inboxApi.fetchCounts()), + settle(activityApi.feed({ top: 8 })), + settle(analyticsApi.hiringTrend({ months: TREND_MONTHS })), + ]) + const deptSlug = (department || 'all').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'all' + await exportStyledWorkbook({ + filename: `dashboard-${deptSlug}-${new Date().toISOString().slice(0, 10)}`, + sheets: buildDashboardSheets({ + department, + snapshots, + offers: offersRes == null ? null : asList(offersRes?.data), + inboxCounts, + activity: asList(activityRes?.data), + trend: asObject(trendRes?.data) || { labels: [], applications: [], hires: [] }, + exportedAt: new Date(), + }), + }) + toast('Dashboard exported to Excel', 'success') + } catch (err) { + toast(friendlyAuthError(err, 'Could not export the dashboard'), 'error') + } finally { + setExporting(false) + } + } + return (
All Departments {departments.map((d) => )} - - Export - + Create Job