Brand-styled XLSX exports across Inbox, Candidates and Talent Pool

New shared lib/exportXlsx.js: every Export button now downloads a
dashboard-flavoured .xlsx - deep-green title band with lime text, mint
meta row (view, row count, date), ink-teal header row, zebra data rows,
frozen header and autofilter. exceljs is imported dynamically so its
~1MB chunk only downloads when an export is clicked.

- Inbox: exports the loaded view (DB-filtered; no extra request)
- Candidates: the Export button was a stub that only fired a toast -
  it now really exports the filtered account list
- Talent Pool: upgraded from plain CSV to the same styled workbook

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/61/head
Talha Ahmed 2026-09-02 20:40:43 +05:00
parent 56a05f840f
commit cc9d7c08d1
6 changed files with 1100 additions and 83 deletions

File diff suppressed because it is too large Load Diff

View File

@ -17,6 +17,7 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.101.4", "@tanstack/react-query": "^5.101.4",
"@tanstack/react-query-devtools": "^5.101.4", "@tanstack/react-query-devtools": "^5.101.4",
"exceljs": "^4.4.0",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-router-dom": "^7.6.0" "react-router-dom": "^7.6.0"

View File

@ -0,0 +1,100 @@
/* ============================================================
exportXlsx.js brand-styled spreadsheet exports.
One shared exporter so every "Export" button produces the same
dashboard-flavoured file: TalentFlow green title band, mint meta row,
ink-teal header, zebra data rows, frozen header + autofilter.
exceljs is ~1MB, so it is imported dynamically Vite splits it into
its own chunk that only ever downloads when an export is clicked.
============================================================ */
/** Utopia Brands palette (ARGB, from styles.css brand constants). */
const BRAND = {
green: 'FF004D43', // deep green — title band, like the sidebar/action colour
ink: 'FF1A3134', // ink teal — header row
lime: 'FFCEFF71', // signature lime — title text accent
mint: 'FFEAFFF4', // mint white — meta row fill
zebra: 'FFF1F7F4', // app background — alternating data rows
border: 'FFDBE8E2', // hairline borders
text: 'FF10231F',
sub: 'FF4A625C',
white: 'FFFFFFFF',
}
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 }],
})
ws.columns = columns.map((c) => ({ key: c.key, width: c.width ?? 18 }))
const span = columns.length
// Row 1 — brand title band
const titleRow = ws.addRow([title])
ws.mergeCells(1, 1, 1, span)
titleRow.height = 30
const titleCell = ws.getCell(1, 1)
titleCell.font = { name: 'Calibri', size: 14, bold: true, color: { argb: BRAND.lime } }
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
const metaCell = ws.getCell(2, 1)
metaCell.font = { name: 'Calibri', size: 10, color: { argb: BRAND.sub } }
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) => {
cell.font = { name: 'Calibri', size: 10, bold: true, color: { argb: BRAND.white } }
cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.ink } }
cell.alignment = { vertical: 'middle' }
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) => {
cell.font = { name: 'Calibri', size: 10, color: { argb: BRAND.text } }
if (i % 2 === 1) cell.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: BRAND.zebra } }
cell.border = { bottom: thin, right: thin }
cell.alignment = { vertical: 'middle', wrapText: false }
})
}
ws.autoFilter = { from: { row: 3, column: 1 }, to: { row: 3, column: span } }
const buf = await wb.xlsx.writeBuffer()
const blob = new Blob([buf], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `${filename}.xlsx`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(a.href)
}

View File

@ -23,6 +23,7 @@ import { isHiringManager } from '../auth/permissions'
import CandidateProfile from './CandidateProfile' import CandidateProfile from './CandidateProfile'
import { useJobTitles } from './ScoredCandidateProfile' import { useJobTitles } from './ScoredCandidateProfile'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
@ -438,7 +439,39 @@ function RecruiterCandidates() {
title="Candidates" title="Candidates"
sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>} sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>}
actions={<> actions={<>
<button className="btn btn-secondary" onClick={() => toast('Candidates exported', 'success')}> <button
className="btn btn-secondary"
onClick={async () => {
if (!rows.length) {
toast('Nothing to export — current filters match no candidates', 'warning')
return
}
try {
await exportStyledXlsx({
filename: `candidates-${new Date().toISOString().slice(0, 10)}`,
title: 'Candidates',
subtitle: `${rows.length} candidate account${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
columns: [
{ header: 'Name', key: 'name', width: 26 },
{ header: 'Email', key: 'email', width: 30 },
{ header: 'Role', key: 'role', width: 22 },
{ header: 'Applied', key: 'applied', width: 12 },
{ header: 'Source', key: 'source', width: 14 },
{ header: 'Account', key: 'account', width: 12 },
],
rows: rows.map((c) => ({
name: c.name, email: c.email, role: c.roleName,
applied: c.applied ? c.applied.toLocaleDateString() : '',
source: c.source,
account: c.isActive ? 'Active' : 'Unconfirmed',
})),
})
toast(`Exported ${rows.length} candidate${rows.length === 1 ? '' : 's'}`, 'success')
} catch {
toast('Export failed', 'error')
}
}}
>
<Icon name="download" /> Export <Icon name="download" /> Export
</button> </button>
<button className="btn btn-secondary" onClick={() => navigate('/import')}> <button className="btn btn-secondary" onClick={() => navigate('/import')}>

View File

@ -25,6 +25,7 @@ import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
import { seedQuery, useSeedMutation } from '../data/seedQueries' import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox' import * as inboxApi from '../api/inbox'
import * as sheetApi from '../api/sheet' import * as sheetApi from '../api/sheet'
@ -1044,60 +1045,48 @@ export default function Inbox() {
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind }) markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind })
} }
const [exporting, setExporting] = useState(false) /** Styled XLSX of the rows already loaded in this view the DB filtered
them when the list was fetched (channel, tab, search); no extra request.
/** CSV of the current view — channel, tab and search respected, unpaged. */ Paged channels export the loaded page; the All channel and the "All"
async function exportCsv() { page size hold the whole view, so those export everything. */
if (exporting) return async function exportRows() {
setExporting(true) const rows = inbox
if (!rows.length) {
toast('Nothing to export in this view', 'info')
return
}
try { try {
const search = q.trim() ? { search: q.trim() } : {} const channelLabel = CHANNELS.find((c) => c.key === channel)?.label ?? channel
const [emailRes, formRes] = await Promise.all([ await exportStyledXlsx({
!isForms filename: `inbox-${channel}-${tab.toLowerCase().replaceAll(' ', '-')}-${new Date().toISOString().slice(0, 10)}`,
? fetchApplications({ ...tabFilter, ...search }) title: 'Recruitment Inbox',
: Promise.resolve({ rows: [] }), subtitle: `${tab} · ${channelLabel} channel · ${rows.length} row${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
isForms || isAllChannel columns: [
? fetchFormApplications({ { header: 'Name', key: 'name', width: 26 },
sheet: isAllChannel ? undefined : (formSheet || undefined), { header: 'Email', key: 'email', width: 28 },
...formTabFilter, { header: 'Phone', key: 'phone', width: 15 },
...search, { header: 'Position', key: 'position', width: 34 },
}) { header: 'Channel', key: 'channel', width: 12 },
: Promise.resolve({ rows: [] }), { header: 'Source', key: 'source', width: 20 },
]) { header: 'Received', key: 'received', width: 12 },
const rows = [...(emailRes.rows ?? []), ...(formRes.rows ?? [])] { header: 'Status', key: 'status', width: 12 },
.sort((a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0)) { header: 'City', key: 'city', width: 14 },
if (!rows.length) { { header: 'Notice period', key: 'notice', width: 13 },
toast('Nothing to export in this view', 'info') { header: 'ATS score', key: 'ats', width: 10 },
return { header: 'Assigned job', key: 'job', width: 24 },
} ],
const esc = (v) => { rows: rows.map((r) => ({
const s = v == null ? '' : String(v) name: r.name, email: r.email, phone: r.phone, position: r.position,
return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s channel: r.kind === 'form' ? 'Sheet Form' : 'Email',
} source: r.source,
const header = ['Name', 'Email', 'Phone', 'Position', 'Channel', 'Source', 'Received', 'Status', 'City', 'Notice period', 'ATS score', 'Assigned job'] received: r.received ? r.received.toISOString().slice(0, 10) : '',
const lines = [header.join(',')] status: r.processing, city: r.residingCity, notice: r.noticePeriod,
for (const r of rows) { ats: r.atsScore, job: r.assignedPost?.title,
lines.push([ })),
r.name, r.email, r.phone, r.position, })
r.kind === 'form' ? 'Sheet Form' : 'Email',
r.source,
r.received ? r.received.toISOString().slice(0, 10) : '',
r.processing, r.residingCity, r.noticePeriod, r.atsScore,
r.assignedPost?.title,
].map(esc).join(','))
}
// BOM so Excel opens it as UTF-8 rather than mangling names.
const blob = new Blob([String.fromCharCode(0xFEFF) + lines.join('\n')], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a')
a.href = URL.createObjectURL(blob)
a.download = `inbox-${channel}-${tab.toLowerCase().replaceAll(' ', '-')}-${new Date().toISOString().slice(0, 10)}.csv`
a.click()
URL.revokeObjectURL(a.href)
toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success') toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success')
} catch (err) { } catch {
toast(friendlyAuthError(err, 'Export failed'), 'error') toast('Export failed', 'error')
} finally {
setExporting(false)
} }
} }
@ -1192,8 +1181,8 @@ export default function Inbox() {
onClick={() => sync.mutate()} onClick={() => sync.mutate()}
/> />
)} )}
<button className="btn btn-secondary" onClick={exportCsv} disabled={exporting}> <button className="btn btn-secondary" onClick={exportRows}>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'} <Icon name="download" /> Export
</button> </button>
<button className="btn btn-primary" onClick={() => navigate('/import')}> <button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs <Icon name="upload" /> Upload CVs

View File

@ -41,6 +41,7 @@ import CandidateProfile from './CandidateProfile'
import { AtsMatch } from './Candidates' import { AtsMatch } from './Candidates'
import { seedQuery, useSeedMutation } from '../data/seedQueries' import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { exportStyledXlsx } from '../lib/exportXlsx'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates' import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts' import * as jobPostsApi from '../api/jobPosts'
@ -232,31 +233,39 @@ export default function TalentPool() {
`pool` means the file always matches what the recruiter is looking at, `pool` means the file always matches what the recruiter is looking at,
search and department filter included. Company/skills are seed-overlay search and department filter included. Company/skills are seed-overlay
values, same as the cards render. */ values, same as the cards render. */
function exportCsv() { async function exportCsv() {
if (!list.length) { if (!list.length) {
toast('Nothing to export — current filters match no candidates', 'warning') toast('Nothing to export — current filters match no candidates', 'warning')
return return
} }
const esc = (v) => { try {
const s = v == null ? '' : String(v) await exportStyledXlsx({
return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s filename: `talent-pool-${new Date().toISOString().slice(0, 10)}`,
title: 'Talent Pool',
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
columns: [
{ header: 'Name', key: 'name', width: 24 },
{ header: 'Email', key: 'email', width: 28 },
{ header: 'Current Title', key: 'title', width: 24 },
{ header: 'Company', key: 'company', width: 20 },
{ header: 'Departments', key: 'departments', width: 22 },
{ header: 'Stage', key: 'stage', width: 13 },
{ header: 'Experience (yrs)', key: 'experience', width: 14 },
{ header: 'Source', key: 'source', width: 16 },
{ header: 'AI Score', key: 'aiScore', width: 10 },
{ header: 'Skills', key: 'skills', width: 40 },
],
rows: list.map((c) => ({
name: c.name, email: c.email, title: c.currentTitle, company: c.currentCompany,
departments: (c.departments || []).join('; '), stage: c.stage,
experience: c.experience, source: c.source, aiScore: c.aiScore ?? '',
skills: (c.skills || []).join('; '),
})),
})
toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'}`, 'success')
} catch {
toast('Export failed', 'error')
} }
const header = ['Name', 'Email', 'Current Title', 'Company', 'Departments', 'Stage', 'Experience (yrs)', 'Source', 'AI Score', 'Skills']
const lines = list.map((c) => [
c.name, c.email, c.currentTitle, c.currentCompany,
(c.departments || []).join('; '), c.stage, c.experience,
c.source, c.aiScore ?? '', (c.skills || []).join('; '),
].map(esc).join(','))
const blob = new Blob([[header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `talent-pool-${new Date().toISOString().slice(0, 10)}.csv`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'} to CSV`, 'success')
} }
return ( return (