Merge pull request 'Brand-styled XLSX exports (Inbox, Candidates, Talent Pool)' (#61) from Talha into main
Deploy to S3 / deploy (push) Successful in 32s
Details
Deploy to S3 / deploy (push) Successful in 32s
Details
commit
30123563e9
File diff suppressed because it is too large
Load Diff
|
|
@ -17,6 +17,7 @@
|
|||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
"@tanstack/react-query-devtools": "^5.101.4",
|
||||
"exceljs": "^4.4.0",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0",
|
||||
"react-router-dom": "^7.6.0"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ import { isHiringManager } from '../auth/permissions'
|
|||
import CandidateProfile from './CandidateProfile'
|
||||
import { useJobTitles } from './ScoredCandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
|
|
@ -484,7 +485,39 @@ function RecruiterCandidates() {
|
|||
title="Candidates"
|
||||
sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}</>}
|
||||
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
|
||||
</button>
|
||||
<button className="btn btn-secondary" onClick={() => navigate('/import')}>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ import { useToast } from '../ui/Toast'
|
|||
import { useAuth } from '../auth/AuthContext'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
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 })
|
||||
}
|
||||
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
/** CSV of the current view — channel, tab and search respected, unpaged. */
|
||||
async function exportCsv() {
|
||||
if (exporting) return
|
||||
setExporting(true)
|
||||
/** 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.
|
||||
Paged channels export the loaded page; the All channel and the "All"
|
||||
page size hold the whole view, so those export everything. */
|
||||
async function exportRows() {
|
||||
const rows = inbox
|
||||
if (!rows.length) {
|
||||
toast('Nothing to export in this view', 'info')
|
||||
return
|
||||
}
|
||||
try {
|
||||
const search = q.trim() ? { search: q.trim() } : {}
|
||||
const [emailRes, formRes] = await Promise.all([
|
||||
!isForms
|
||||
? fetchApplications({ ...tabFilter, ...search })
|
||||
: Promise.resolve({ rows: [] }),
|
||||
isForms || isAllChannel
|
||||
? fetchFormApplications({
|
||||
sheet: isAllChannel ? undefined : (formSheet || undefined),
|
||||
...formTabFilter,
|
||||
...search,
|
||||
})
|
||||
: Promise.resolve({ rows: [] }),
|
||||
])
|
||||
const rows = [...(emailRes.rows ?? []), ...(formRes.rows ?? [])]
|
||||
.sort((a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0))
|
||||
if (!rows.length) {
|
||||
toast('Nothing to export in this view', 'info')
|
||||
return
|
||||
}
|
||||
const esc = (v) => {
|
||||
const s = v == null ? '' : String(v)
|
||||
return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
|
||||
}
|
||||
const header = ['Name', 'Email', 'Phone', 'Position', 'Channel', 'Source', 'Received', 'Status', 'City', 'Notice period', 'ATS score', 'Assigned job']
|
||||
const lines = [header.join(',')]
|
||||
for (const r of rows) {
|
||||
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)
|
||||
const channelLabel = CHANNELS.find((c) => c.key === channel)?.label ?? channel
|
||||
await exportStyledXlsx({
|
||||
filename: `inbox-${channel}-${tab.toLowerCase().replaceAll(' ', '-')}-${new Date().toISOString().slice(0, 10)}`,
|
||||
title: 'Recruitment Inbox',
|
||||
subtitle: `${tab} · ${channelLabel} channel · ${rows.length} row${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
|
||||
columns: [
|
||||
{ header: 'Name', key: 'name', width: 26 },
|
||||
{ header: 'Email', key: 'email', width: 28 },
|
||||
{ header: 'Phone', key: 'phone', width: 15 },
|
||||
{ header: 'Position', key: 'position', width: 34 },
|
||||
{ header: 'Channel', key: 'channel', width: 12 },
|
||||
{ header: 'Source', key: 'source', width: 20 },
|
||||
{ header: 'Received', key: 'received', width: 12 },
|
||||
{ header: 'Status', key: 'status', width: 12 },
|
||||
{ header: 'City', key: 'city', width: 14 },
|
||||
{ header: 'Notice period', key: 'notice', width: 13 },
|
||||
{ header: 'ATS score', key: 'ats', width: 10 },
|
||||
{ header: 'Assigned job', key: 'job', width: 24 },
|
||||
],
|
||||
rows: rows.map((r) => ({
|
||||
name: r.name, email: r.email, phone: r.phone, position: r.position,
|
||||
channel: r.kind === 'form' ? 'Sheet Form' : 'Email',
|
||||
source: r.source,
|
||||
received: r.received ? r.received.toISOString().slice(0, 10) : '',
|
||||
status: r.processing, city: r.residingCity, notice: r.noticePeriod,
|
||||
ats: r.atsScore, job: r.assignedPost?.title,
|
||||
})),
|
||||
})
|
||||
toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success')
|
||||
} catch (err) {
|
||||
toast(friendlyAuthError(err, 'Export failed'), 'error')
|
||||
} finally {
|
||||
setExporting(false)
|
||||
} catch {
|
||||
toast('Export failed', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1192,8 +1181,8 @@ export default function Inbox() {
|
|||
onClick={() => sync.mutate()}
|
||||
/>
|
||||
)}
|
||||
<button className="btn btn-secondary" onClick={exportCsv} disabled={exporting}>
|
||||
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
|
||||
<button className="btn btn-secondary" onClick={exportRows}>
|
||||
<Icon name="download" /> Export
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||
<Icon name="upload" /> Upload CVs
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ import CandidateProfile from './CandidateProfile'
|
|||
import { AtsMatch } from './Candidates'
|
||||
import { seedQuery, useSeedMutation } from '../data/seedQueries'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { exportStyledXlsx } from '../lib/exportXlsx'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
|
|
@ -248,31 +249,39 @@ export default function TalentPool() {
|
|||
`pool` means the file always matches what the recruiter is looking at,
|
||||
search, job, and department filter included. Company/skills are seed-overlay
|
||||
values, same as the cards render. */
|
||||
function exportCsv() {
|
||||
async function exportCsv() {
|
||||
if (!list.length) {
|
||||
toast('Nothing to export — current filters match no candidates', 'warning')
|
||||
return
|
||||
}
|
||||
const esc = (v) => {
|
||||
const s = v == null ? '' : String(v)
|
||||
return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
|
||||
try {
|
||||
await exportStyledXlsx({
|
||||
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 (
|
||||
|
|
|
|||
Loading…
Reference in New Issue