dashboard sheet added
parent
5b6588f90a
commit
41f46c2c39
|
|
@ -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<object>} 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<object>}>} 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<object>} 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 }],
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
|
|
@ -377,9 +669,14 @@ function DashboardHome() {
|
|||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
<Link className="btn btn-secondary" to="/reports">
|
||||
<Icon name="download" /> Export
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={exportDashboard}
|
||||
disabled={exporting}
|
||||
>
|
||||
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
|
||||
<Icon name="plus" /> Create Job
|
||||
</Link>
|
||||
|
|
|
|||
Loading…
Reference in New Issue