From d1fa58ecc302874a463ff771ba45eb77daa4e94b Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 17:24:23 +0500 Subject: [PATCH 01/12] Inbox: surface the All toggle at the top of the queue The unpaged switch lived only in the bottom Per-page dropdown and went unnoticed. The bulk bar now carries a Show all (N) / Show paged button (hidden while rows are ticked, since the bar swaps to selection actions); it drives the same pageSize state, so the dropdown stays in sync. The bar wraps now - four controls no longer fit one line in the 34%-wide split column at laptop widths. Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 21 ++++++++++++++++++++- frontend/src/styles/styles.css | 6 +++++- 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 0c8e999..1bf403e 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -607,7 +607,7 @@ const READ_TICK_MS = 1000 * `rows` rather than a count, because the bar needs each row's read state, and * `selectedIds` narrows it to what the selected pair of buttons would touch. */ -function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) { +function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel, showAll, onToggleShowAll, tabTotal }) { const { selectedIds, toggleAll, clear, allSelected } = selection const n = selectedIds.size const total = rows.length @@ -658,6 +658,17 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, ? `${n} selected` : `${total}${unreadCount ? ` · ${unreadCount}` : ''}`} + {/* Same switch as the Per-page "All" entry below, surfaced at the top of + the queue where the eye actually is. */} + {onToggleShowAll && n === 0 && ( + + )}
{n > 0 ? ( <> @@ -1112,6 +1123,14 @@ export default function Inbox() { busy={setRead.isPending || setReadAll.isPending} canEdit={canEdit} scopeLabel={scopeLabel} + showAll={showAll} + tabTotal={total} + onToggleShowAll={() => { + setPageSize(showAll ? DEFAULT_PAGE_SIZE : 'all') + setSkip(0) + setSelectedId(null) + selection.clear() + }} /> )}
diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 04c6693..e3676ca 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1084,7 +1084,10 @@ canvas { width: 100%; max-width: 100%; display: block; } gap: 8px; padding: 8px 12px; border-bottom: 1px solid var(--border); - flex-wrap: nowrap; + /* wrap: the Show all toggle joined the row, and in the 34%-wide split + column the four controls no longer fit on one line at laptop widths. */ + flex-wrap: wrap; + row-gap: 6px; min-width: 0; } .inbox-bulk-count { @@ -1582,6 +1585,7 @@ canvas { width: 100%; max-width: 100%; display: block; } /* When the Ask button wraps under the input, let it take the full row. */ .ask-form .btn { flex: 1 1 auto; } + /* Find Talent: stack the toolbar controls edge to edge. */ .talent-controls .tc-job, .talent-controls .tc-loc, .talent-controls .tc-custom, .talent-controls .btn { From ec8a4af52d407f8ad76bfff7f39b18453eb8694e Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 17:36:52 +0500 Subject: [PATCH 02/12] Inbox: All channel - Email and Sheet Forms merged in one queue Third channel pill beside Email / Sheet Forms. Both sources are fetched unpaged (their endpoints read a missing top/limit as no LIMIT), merged newest-first, and paged client-side - per-source skip/top cannot compose into a correct global page. Rows carry kind (email/form) so the detail pane, mark-read (email rows only get checkboxes; sheet rows have no mailbox read state) and the kind-aware state/duplicate mutations all pick the right endpoint per row. Tabs collapse to the shared set (no Unread), counts and totals sum both sources, and the sheet filter spans every sheet tab on this channel. Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 129 ++++++++++++++++++++++----------- 1 file changed, 87 insertions(+), 42 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 1bf403e..42207ce 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -59,10 +59,11 @@ function storeSyncRunId(id) { } } -/** Inbox channel: Outlook email queue vs imported Google Form rows. */ +/** Inbox channel: Outlook email queue, imported Google Form rows, or both. */ const CHANNELS = [ { key: 'email', label: 'Email', icon: 'mail' }, { key: 'forms', label: 'Sheet Forms', icon: 'layers' }, + { key: 'all', label: 'All', icon: 'inbox' }, ] const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026' @@ -303,6 +304,7 @@ async function fetchMessageDetail(recordId) { if (!row) return null const name = row.sender_name || row.fromEmail || 'Unknown' return { + kind: 'email', id: String(row.id), name, initials: initialsOf(name), @@ -356,6 +358,7 @@ async function fetchApplications(params) { rows: rows.map((row) => { const name = row.name || row.email || 'Unknown' return { + kind: 'email', id: String(row.id), name, initials: initialsOf(name), @@ -737,25 +740,31 @@ export default function Inbox() { const [noting, setNoting] = useState(null) const isForms = channel === 'forms' - const channelTabs = isForms ? FORM_TABS : TABS + // Combined channel: both sources fetched UNPAGED (each endpoint reads a + // missing top/limit as no LIMIT), merged by date, and paged client-side — + // per-source skip/top cannot compose into a correct global page. + const isAllChannel = channel === 'all' + const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS const tabFilter = TAB_FILTERS[tab] ?? {} const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} const listParams = useMemo(() => ({ ...tabFilter, - // 'all' drops the param entirely — the endpoint reads a missing top as unpaged. - top: pageSize === 'all' ? undefined : pageSize, - skip, + // 'all' page size and the All channel drop the param entirely — the + // endpoint reads a missing top as unpaged. + top: pageSize === 'all' || isAllChannel ? undefined : pageSize, + skip: isAllChannel ? 0 : skip, ...(q.trim() ? { search: q.trim() } : {}), - }), [tabFilter, skip, pageSize, q]) + }), [tabFilter, skip, pageSize, q, isAllChannel]) const formParams = useMemo(() => ({ - sheet: formSheet || undefined, - offset: skip, - limit: pageSize === 'all' ? undefined : pageSize, + // All channel spans every sheet tab, not just the selected one. + sheet: isAllChannel ? undefined : (formSheet || undefined), + offset: isAllChannel ? 0 : skip, + limit: pageSize === 'all' || isAllChannel ? undefined : pageSize, ...formTabFilter, ...(q.trim() ? { search: q.trim() } : {}), - }), [formSheet, skip, pageSize, q, formTabFilter]) + }), [formSheet, skip, pageSize, q, formTabFilter, isAllChannel]) const applicationsQuery = useQuery({ queryKey: qk.mailbox.applications(listParams), @@ -776,7 +785,7 @@ export default function Inbox() { const formQuery = useQuery({ queryKey: qk.mailbox.formData(formParams), queryFn: () => fetchFormApplications(formParams), - enabled: isForms, + enabled: isForms || isAllChannel, }) const countsQuery = useQuery({ @@ -785,13 +794,14 @@ export default function Inbox() { enabled: !isForms, }) + const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined) const formCountsQuery = useQuery({ - queryKey: qk.mailbox.formCounts({ sheet: formSheet || undefined }), + queryKey: qk.mailbox.formCounts({ sheet: formCountsSheet }), queryFn: async () => { - const res = await sheetApi.fetchFormCounts({ sheet: formSheet || undefined }) + const res = await sheetApi.fetchFormCounts({ sheet: formCountsSheet }) return res?.data ?? {} }, - enabled: isForms, + enabled: isForms || isAllChannel, }) const emailTotalQuery = useQuery({ @@ -805,12 +815,12 @@ export default function Inbox() { }) const formTotalQuery = useQuery({ - queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }), + queryKey: qk.mailbox.formTotal({ sheet: formCountsSheet }), queryFn: async () => { - const res = await sheetApi.countFormData({ sheet: formSheet || undefined }) + const res = await sheetApi.countFormData({ sheet: formCountsSheet }) return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0) }, - enabled: isForms, + enabled: isForms || isAllChannel, staleTime: Infinity, }) @@ -829,27 +839,55 @@ export default function Inbox() { } }, [isForms, formSheetsQuery.data, formSheet]) - const activeQuery = isForms ? formQuery : applicationsQuery + // All channel: both sources arrive unpaged; merge newest-first and let the + // pager slice the merged array below. + const mergedRows = useMemo(() => { + if (!isAllChannel) return null + const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : [] + const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : [] + return [...emails, ...forms].sort( + (a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0), + ) + }, [isAllChannel, applicationsQuery.data, formQuery.data]) + + const activeQuery = isAllChannel + ? { + isPending: applicationsQuery.isPending || formQuery.isPending, + // one healthy source still renders; error only when both are down + isError: applicationsQuery.isError && formQuery.isError, + isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess, + error: applicationsQuery.error ?? formQuery.error, + data: { rows: mergedRows ?? [], total: mergedRows?.length ?? 0 }, + } + : (isForms ? formQuery : applicationsQuery) const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : [] - const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {}) const counts = useMemo( () => { const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) + const e = countsQuery.data ?? {} + const f = formCountsQuery.data ?? {} + const pick = (key) => (isAllChannel + ? n(e[key]) + n(f[key]) + : n((isForms ? f : e)[key])) return { - 'All Applications': n(serverCounts.all), - Unread: n(serverCounts.unread), - Processed: n(serverCounts.processed), - Rejected: n(serverCounts.rejected), - Duplicates: n(serverCounts.duplicates), + 'All Applications': pick('all'), + Unread: pick('unread'), + Processed: pick('processed'), + Rejected: pick('rejected'), + Duplicates: pick('duplicates'), } }, - [serverCounts], + [countsQuery.data, formCountsQuery.data, isForms, isAllChannel], ) - const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0) + const poolTotal = isAllChannel + ? (emailTotalQuery.data ?? 0) + (formTotalQuery.data ?? 0) + : (isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0)) const tabTotal = counts[tab] ?? 0 - const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess + const countsReady = isAllChannel + ? countsQuery.isSuccess && formCountsQuery.isSuccess + : (isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess) const total = q.trim() ? (activeQuery.data?.total ?? 0) : (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0))) @@ -867,11 +905,15 @@ export default function Inbox() { setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize)) }, [total, pageSize, skip, showAll]) - const list = inbox + // All channel pages the merged array client-side; single channels page on the server. + const list = isAllChannel && !showAll ? inbox.slice(skip, skip + pageSize) : inbox + // Mixed rows: the row's own kind picks the detail endpoint, not the channel. + const selectedKind = inbox.find((i) => i.id === selectedId)?.kind + ?? (isForms ? 'form' : 'email') const detailQuery = useQuery({ - queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), - queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), + queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), + queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), enabled: Boolean(selectedId), }) @@ -902,8 +944,8 @@ export default function Inbox() { setSkip(0) setSelectedId(null) setQ('') - // Unread is email-only; leave it behind when opening Sheet Forms. - if (next === 'forms' && tab === 'Unread') setTab('All Applications') + // Unread is email-only; leave it behind when opening Sheet Forms or All. + if (next !== 'email' && tab === 'Unread') setTab('All Applications') selection.clear() } @@ -924,11 +966,12 @@ export default function Inbox() { setReadAll.mutate({ read, filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) }, - ids: list.map((i) => i.id), + // sheet rows carry no mailbox read state — email rows only + ids: list.filter((i) => i.kind !== 'form').map((i) => i.id), }) return } - const ids = list.map((i) => i.id) + const ids = list.filter((i) => i.kind !== 'form').map((i) => i.id) if (!ids.length) return setRead.mutate({ ids, read }, { onSuccess: () => { @@ -975,8 +1018,8 @@ export default function Inbox() { function select(id) { setSelectedId(id) - if (isForms) return const item = inbox.find((i) => i.id === id) + if (item?.kind === 'form') return // sheet rows have no mailbox read state if (item?.unread) setRead.mutate({ ids: [id], read: true }) } @@ -1058,9 +1101,11 @@ export default function Inbox() {
@@ -1184,7 +1229,7 @@ export default function Inbox() { className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`} onClick={() => select(i.id)} > - {!isForms && ( + {i.kind !== 'form' && ( selection.toggle(i.id)} @@ -1207,7 +1252,7 @@ export default function Inbox() { {i.atsScore != null && (
)} - {isForms && i.noticePeriod && ( + {i.kind === 'form' && i.noticePeriod && (
{i.noticePeriod}
)}
@@ -1216,7 +1261,7 @@ export default function Inbox() { {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {i.applicationStatus} )} - {isForms && i.residingCity && ( + {i.kind === 'form' && i.residingCity && ( {i.residingCity} )}
@@ -1262,7 +1307,7 @@ export default function Inbox() { {friendlyAuthError(detailQuery.error, 'Request failed')}
- ) : isForms ? ( + ) : selected?.kind === 'form' ? ( Date: Wed, 2 Sep 2026 17:52:03 +0500 Subject: [PATCH 03/12] Inbox: All channel first and default The combined stream leads the channel pills (All / Email / Sheet Forms) and is the channel the inbox opens on. Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 42207ce..945501e 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -59,11 +59,11 @@ function storeSyncRunId(id) { } } -/** Inbox channel: Outlook email queue, imported Google Form rows, or both. */ +/** Inbox channel: both sources combined (default), Outlook email, or Google Form rows. */ const CHANNELS = [ + { key: 'all', label: 'All', icon: 'inbox' }, { key: 'email', label: 'Email', icon: 'mail' }, { key: 'forms', label: 'Sheet Forms', icon: 'layers' }, - { key: 'all', label: 'All', icon: 'inbox' }, ] const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026' @@ -729,7 +729,7 @@ export default function Inbox() { const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const updateInbox = useSeedMutation('inbox') - const [channel, setChannel] = useState('email') + const [channel, setChannel] = useState('all') const [formSheet, setFormSheet] = useState(DEFAULT_FORM_SHEET) const [tab, setTab] = useState('All Applications') const [skip, setSkip] = useState(0) From 59afcadec9ce3e21617345da32f1a385a2da6219 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 17:57:27 +0500 Subject: [PATCH 04/12] Inbox: drop the Microsoft Graph API status chip from the header Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 945501e..a77371a 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1122,9 +1122,6 @@ export default function Inbox() { ))} - {!isForms && ( - Microsoft Graph API · Connected - )} {isForms && ( Google Sheets · Form data )} From 2e66c91d635fc785047533cc41e40fc5ea837c6b Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 19:13:42 +0500 Subject: [PATCH 05/12] Inbox: drop the Google Sheets status chip too Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 3 --- 1 file changed, 3 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index a77371a..877caf0 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1122,9 +1122,6 @@ export default function Inbox() { ))} - {isForms && ( - Google Sheets · Form data - )} {!isForms && ( Date: Wed, 2 Sep 2026 19:20:49 +0500 Subject: [PATCH 06/12] Inbox: tidy the queue pagination The stacked pager read as three loose rows with 'of N' printed twice. Queue-scoped: drop the duplicate page-size total (the Showing line already carries it), wrap page-controls with even gaps, and slim the page buttons so all nine nav controls (4 chevrons + up to 5 numbers) fit the ~350px split column on one row. Co-Authored-By: Claude Fable 5 --- frontend/src/styles/styles.css | 21 ++++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index e3676ca..ac1de41 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1072,12 +1072,27 @@ canvas { width: 100%; max-width: 100%; display: block; } overflow: visible; } .inbox-queue .page-info { width: 100%; } -.inbox-queue .page-controls { flex-wrap: wrap; } +/* "of N" repeats the Showing line one row up — drop it in the queue column. */ +.inbox-queue .page-size-total { display: none; } +.inbox-queue .page-size { margin-right: 0; } +.inbox-queue .page-controls { + flex-wrap: wrap; + justify-content: space-between; + row-gap: 8px; +} .inbox-queue .page-nav { justify-content: flex-start; - flex: 1 1 100%; - width: 100%; + flex: 1 1 auto; + width: auto; + min-width: 0; + flex-wrap: wrap; + gap: 3px; + row-gap: 4px; } +.inbox-queue .page-nums { flex-wrap: wrap; gap: 3px; row-gap: 4px; } +/* Slimmer buttons: nine controls (4 chevrons + up to 5 numbers) fit the + ~350px split column on one row. */ +.inbox-queue .page-btn { min-width: 31px; height: 31px; padding: 0 6px; } .inbox-bulk-bar { display: flex; align-items: center; From fbfcbb4eb836cfc7e6016f1154f8a87e7a2c2159 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 19:40:03 +0500 Subject: [PATCH 07/12] Inbox: redesign the form applicant detail pane Standard card language instead of the email-head strips: Contact & application, Profile and Matching roles are now proper cards with card-heads. The roles panel collapses three overlapping controls into one flow - a single Browse roles action, with Assign appearing only once a selection exists (a permanently disabled primary button read as broken UI). Sheet provenance moves off the floating header corner into the Imported from ... row N caption, and the action bar gains a divider (email pane too, for consistency). Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 213 ++++++++++++++++----------------- 1 file changed, 101 insertions(+), 112 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 877caf0..059503c 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1492,12 +1492,6 @@ function FormApplicantDetail({ {loading && Loading details…} - {i.rowNumber != null && ( -
-
Sheet row
-
{i.rowNumber}
-
- )} {(resumeHref || profileHref) && ( @@ -1570,132 +1564,127 @@ function FormApplicantDetail({ marginBottom: 20, }} > -
-
Contact & application
-
-
Email
{orDash(i.email)}
-
Phone
{orDash(i.phone)}
-
Applied
{i.received ? fmtDate(i.received) : '—'}
-
Source
{orDash(i.source)}
-
Screened by
{orDash(i.screenedBy)}
-
Notice period
{orDash(i.noticePeriod)}
+
+
+

Contact & application

+
+
+
Email
{orDash(i.email)}
+
Phone
{orDash(i.phone)}
+
Applied
{i.received ? fmtDate(i.received) : '—'}
+
Source
{orDash(i.source)}
+
Screened by
{orDash(i.screenedBy)}
+
Notice period
{orDash(i.noticePeriod)}
+
+
-
Profile
-
-
Gender
{orDash(i.gender)}
-
Date of birth
{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}
-
CNIC
{orDash(i.cnic)}
-
Marital status
{orDash(i.maritalStatus)}
-
Location
{orDash(location)}
-
Education
{orDash(education)}
-
Graduation
{orDash(i.graduationYear)}
-
Other university
{orDash(i.universityOther)}
-
Current salary
{orDash(i.currentSalary)}
-
Expected salary
{orDash(i.expectedSalary)}
+
+

Profile

+
+
+
Gender
{orDash(i.gender)}
+
Date of birth
{i.dateOfBirth ? fmtDate(i.dateOfBirth) : '—'}
+
CNIC
{orDash(i.cnic)}
+
Marital status
{orDash(i.maritalStatus)}
+
Location
{orDash(location)}
+
Education
{orDash(education)}
+
Graduation
{orDash(i.graduationYear)}
+
Other university
{orDash(i.universityOther)}
+
Current salary
{orDash(i.currentSalary)}
+
Expected salary
{orDash(i.expectedSalary)}
+
+
{i.hrComments && ( -
+
HR comment

{i.hrComments}

)} - - {i.sheet && ( -
- Imported from {i.sheet} -
- )}
-
-
Matching roles
-
- Matched by position applied for: {orDash(i.position)} +
+
+
+

Matching roles

+ Matched by position: {orDash(i.position)} +
- {matchCards.length === 0 && !manualPost ? ( - -

No job post title matches this position. Choose a role manually.

-
+
+ {matchCards.length === 0 && !manualPost ? ( + +

No job post title matches this position — pick one manually.

+
+ ) : ( + <> + {matchCards.map(({ rank, post }) => ( + setSelection(String(id))} + /> + ))} + {manualPost && ( + setSelection(String(id))} + /> + )} + + )} +
+ + {/* Assign only renders once there is a selection to act on — + a permanently disabled primary button read as broken UI. */} + {selection && selection !== i.assignedId && ( -
- - ) : ( - matchCards.map(({ rank, post }) => ( - setSelection(String(id))} - /> - )) - )} - {manualPost && ( - setSelection(String(id))} - /> - )} - - + )} +
+
-
+ {i.sheet && ( +
+ Imported from {i.sheet}{i.rowNumber != null ? ` · row ${i.rowNumber}` : ''} +
+ )} + +
@@ -2081,7 +2070,7 @@ function ApplicationDetail({
-
+
From 2abc28fc14eabcf4a78044a35d3225e918afcdd5 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 20:12:18 +0500 Subject: [PATCH 08/12] Inbox: single page scroll on phones, detail takes over from the list The stacked split nested two scroll wells (list and detail each carried max-height + overflow-y auto) inside the page scroll - scrollbars within scrollbars on small screens. At <=900px both panes now flow naturally in the one page scroll, and selecting an application swaps the list out for the detail pane with a Back to list button (standard master-detail collapse); selection also scrolls to the top so the detail header is in view. Desktop keeps the two-pane split unchanged. Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 16 +++++++++++++++- frontend/src/styles/styles.css | 12 ++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 059503c..7516af3 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1018,6 +1018,11 @@ export default function Inbox() { function select(id) { setSelectedId(id) + // Phones swap the list for the detail pane — bring its top into view. + if (window.matchMedia?.('(max-width: 900px)').matches) { + window.scrollTo(0, 0) + document.querySelector('.content')?.scrollTo?.(0, 0) + } const item = inbox.find((i) => i.id === id) if (item?.kind === 'form') return // sheet rows have no mailbox read state if (item?.unread) setRead.mutate({ ids: [id], read: true }) @@ -1151,7 +1156,7 @@ export default function Inbox() { />
-
+
{!isForms && applicationsQuery.isSuccess && (
+ {selected && ( + + )} {!selected ? (
diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index ac1de41..49e13be 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -1027,6 +1027,18 @@ canvas { width: 100%; max-width: 100%; display: block; } so the detail pane keeps a readable width in the 980-1200px band where the split has not collapsed to one column yet. */ .inbox-split { grid-template-columns: minmax(280px, 34%) minmax(0, 1fr); } +/* Desktop keeps the two-pane split; only the phone layout below shows it. */ +.inbox-back { display: none; } +/* Phones: the stacked split nested two scroll wells inside the page scroll. + Instead: one page scroll, and a selected application takes over from the + list — the Back button returns to it (standard master-detail collapse). */ +@media (max-width: 900px) { + .inbox-split .split-list, + .inbox-split .split-detail { max-height: none; overflow: visible; } + .inbox-split.has-selection .split-list { display: none; } + .inbox-split:not(.has-selection) .split-detail { display: none; } + .inbox-back { display: inline-flex; margin: 10px 12px 0; } +} .inbox-queue { overflow-x: hidden; min-width: 0; } .inbox-queue .inbox-item { min-width: 0; } /* Name + time on row 1, subject on row 2, chips span the full width under From 56a05f840f1e4b65832426031555b6d36b790e44 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 20:20:36 +0500 Subject: [PATCH 09/12] Inbox: Export CSV button; graceful partial failure on the All channel Export downloads the current view - channel, tab and search respected, fetched unpaged - as UTF-8 CSV (BOM for Excel) with both sources' columns: name, contact, position, channel, source, received, status, city, notice period, ATS score, assigned job. All channel resilience: when exactly one source fails, the healthy list stays with a one-line notice naming the gap; the full error state now renders only when there are no rows at all (cached rows beat a scary banner over a working list). Co-Authored-By: Claude Fable 5 --- frontend/src/screens/Inbox.jsx | 82 +++++++++++++++++++++++++++++++++- 1 file changed, 81 insertions(+), 1 deletion(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 7516af3..ea14593 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1044,6 +1044,63 @@ 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) + 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) + toast(`Exported ${rows.length} application${rows.length === 1 ? '' : 's'}`, 'success') + } catch (err) { + toast(friendlyAuthError(err, 'Export failed'), 'error') + } finally { + setExporting(false) + } + } + const [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId()) const syncToastShown = useRef(null) @@ -1135,6 +1192,9 @@ export default function Inbox() { onClick={() => sync.mutate()} /> )} + @@ -1212,11 +1272,31 @@ export default function Inbox() { {activeQuery.isPending && ( )} - {activeQuery.isError && ( + {/* The big error state only when there is truly nothing to + show — cached rows beat a scary banner over a working list. */} + {activeQuery.isError && list.length === 0 && ( {friendlyAuthError(activeQuery.error, 'Request failed')} )} + {/* All channel with one source down: keep the healthy list, + note the gap in one line instead of a full error state. */} + {isAllChannel && applicationsQuery.isError !== formQuery.isError + && (applicationsQuery.isError || formQuery.isError) && ( +
+ {applicationsQuery.isError + ? 'Email applications couldn’t load right now — showing Sheet Forms only.' + : 'Sheet Form applications couldn’t load right now — showing Email only.'} +
+ )} {activeQuery.isSuccess && list.length === 0 ? ( {isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'} From cc9d7c08d16a2015603d679a69f2ef2a49fce812 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 20:40:43 +0500 Subject: [PATCH 10/12] 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 --- frontend/package-lock.json | 901 +++++++++++++++++++++++++++- frontend/package.json | 1 + frontend/src/lib/exportXlsx.js | 100 +++ frontend/src/screens/Candidates.jsx | 35 +- frontend/src/screens/Inbox.jsx | 97 ++- frontend/src/screens/TalentPool.jsx | 49 +- 6 files changed, 1100 insertions(+), 83 deletions(-) create mode 100644 frontend/src/lib/exportXlsx.js diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b4fe592..7c9df49 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10,6 +10,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" @@ -970,6 +971,47 @@ } } }, + "node_modules/@fast-csv/format": { + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/@fast-csv/format/-/format-4.3.5.tgz", + "integrity": "sha512-8iRn6QF3I8Ak78lNAa+Gdl5MJJBM5vRHivFtMRUWINdevNo00K7OXxS2PshawLKTejVwieIlPmK5YlLu6w4u8A==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.isboolean": "^3.0.3", + "lodash.isequal": "^4.5.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0" + } + }, + "node_modules/@fast-csv/format/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, + "node_modules/@fast-csv/parse": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/@fast-csv/parse/-/parse-4.3.6.tgz", + "integrity": "sha512-uRsLYksqpbDmWaSmzvJcuApSEe38+6NQZBUsuAyMZKqHxH0g1wcJgsKUvN3WC8tewaqFjBMMGrkHmC+T7k8LvA==", + "license": "MIT", + "dependencies": { + "@types/node": "^14.0.1", + "lodash.escaperegexp": "^4.1.2", + "lodash.groupby": "^4.6.0", + "lodash.isfunction": "^3.0.9", + "lodash.isnil": "^4.0.0", + "lodash.isundefined": "^3.0.1", + "lodash.uniq": "^4.5.0" + } + }, + "node_modules/@fast-csv/parse/node_modules/@types/node": { + "version": "14.18.63", + "resolved": "https://registry.npmjs.org/@types/node/-/node-14.18.63.tgz", + "integrity": "sha512-fAtCfv4jJg+ExtXhvCkCqUKZ+4ok/JQk01qDKhL5BDDoS3AxKXhV5/MAVUZyQnSEd2GT92fkgZl0pz0Q0AzcIQ==", + "license": "MIT" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -1663,6 +1705,91 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/archiver": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/archiver/-/archiver-5.3.2.tgz", + "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^2.1.0", + "async": "^3.2.4", + "buffer-crc32": "^0.2.1", + "readable-stream": "^3.6.0", + "readdir-glob": "^1.1.2", + "tar-stream": "^2.2.0", + "zip-stream": "^4.1.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/archiver-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-2.1.0.tgz", + "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", + "license": "MIT", + "dependencies": { + "glob": "^7.1.4", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^2.0.0" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/archiver-utils/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/archiver-utils/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/archiver-utils/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/archiver/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/ast-types": { "version": "0.13.4", "resolved": "https://registry.npmjs.org/ast-types/-/ast-types-0.13.4.tgz", @@ -1676,6 +1803,12 @@ "node": ">=4" } }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "license": "MIT" + }, "node_modules/b4a": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/b4a/-/b4a-1.8.1.tgz", @@ -1691,6 +1824,12 @@ } } }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "license": "MIT" + }, "node_modules/bare-events": { "version": "2.9.2", "resolved": "https://registry.npmjs.org/bare-events/-/bare-events-2.9.2.tgz", @@ -1780,7 +1919,6 @@ "version": "1.5.1", "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", - "dev": true, "funding": [ { "type": "github", @@ -1830,6 +1968,55 @@ "require-from-string": "^2.0.2" } }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/binary": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/binary/-/binary-0.3.0.tgz", + "integrity": "sha512-D4H1y5KYwpJgK8wk1Cue5LLPgmwHKYSChkbspQg5JtVuR5ulGckxfR62H3AE9UDkdMC8yyXlqYihuz3Aqg2XZg==", + "license": "MIT", + "dependencies": { + "buffers": "~0.1.1", + "chainsaw": "~0.1.0" + }, + "engines": { + "node": "*" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/bluebird": { + "version": "3.4.7", + "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz", + "integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", + "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, "node_modules/browserslist": { "version": "4.28.7", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.7.tgz", @@ -1868,7 +2055,6 @@ "version": "5.7.1", "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz", "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", - "dev": true, "funding": [ { "type": "github", @@ -1893,12 +2079,28 @@ "version": "0.2.13", "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "dev": true, "license": "MIT", "engines": { "node": "*" } }, + "node_modules/buffer-indexof-polyfill": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.2.tgz", + "integrity": "sha512-I7wzHwA3t1/lwXQh+A5PbNvJxgfo5r3xulgpYDB5zckTu/Z9oUK9biouBKQUjEqzaz3HnAT6TYoovmE+GqSf7A==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/buffers": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/buffers/-/buffers-0.1.1.tgz", + "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==", + "engines": { + "node": ">=0.2.0" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001806", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz", @@ -1920,6 +2122,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/chainsaw": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/chainsaw/-/chainsaw-0.1.0.tgz", + "integrity": "sha512-75kWfWt6MEKNC8xYXIdRpDehRYY/tNSgwKaJq+dbbDcxORuVrrQ+SEHoWsniVn9XPYfP4gmdWIeDk/4YNp1rNQ==", + "license": "MIT/X11", + "dependencies": { + "traverse": ">=0.3.0 <0.4" + }, + "engines": { + "node": "*" + } + }, "node_modules/chromium-bidi": { "version": "0.11.0", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.11.0.tgz", @@ -1969,6 +2183,27 @@ "dev": true, "license": "MIT" }, + "node_modules/compress-commons": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/compress-commons/-/compress-commons-4.1.2.tgz", + "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", + "license": "MIT", + "dependencies": { + "buffer-crc32": "^0.2.13", + "crc32-stream": "^4.0.2", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "license": "MIT" + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1989,6 +2224,37 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, + "node_modules/crc-32": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", + "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", + "license": "Apache-2.0", + "bin": { + "crc32": "bin/crc32.njs" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/crc32-stream": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/crc32-stream/-/crc32-stream-4.0.3.tgz", + "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", + "license": "MIT", + "dependencies": { + "crc-32": "^1.2.0", + "readable-stream": "^3.4.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/css-tree": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", @@ -2042,6 +2308,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2089,6 +2361,45 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/duplexer2": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.1.4.tgz", + "integrity": "sha512-asLFVfWWtJ90ZyOUHMqk7/S2w2guQKxUI2itj3d92ADHhxUSbCMGi1f1cBcJ7xM1To+pE/Khbwo1yuNbMEPKeA==", + "license": "BSD-3-Clause", + "dependencies": { + "readable-stream": "^2.0.2" + } + }, + "node_modules/duplexer2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexer2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexer2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.400", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.400.tgz", @@ -2107,7 +2418,6 @@ "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", - "dev": true, "license": "MIT", "dependencies": { "once": "^1.4.0" @@ -2244,6 +2554,38 @@ "bare-events": "^2.7.0" } }, + "node_modules/exceljs": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/exceljs/-/exceljs-4.4.0.tgz", + "integrity": "sha512-XctvKaEMaj1Ii9oDOqbW/6e1gXknSY4g/aLCDicOXqBE4M0nRWkUu0PTp++UPNzoFY12BNHMfs/VadKIS6llvg==", + "license": "MIT", + "dependencies": { + "archiver": "^5.0.0", + "dayjs": "^1.8.34", + "fast-csv": "^4.3.1", + "jszip": "^3.10.1", + "readable-stream": "^3.6.0", + "saxes": "^5.0.1", + "tmp": "^0.2.0", + "unzipper": "^0.10.11", + "uuid": "^8.3.0" + }, + "engines": { + "node": ">=8.3.0" + } + }, + "node_modules/exceljs/node_modules/saxes": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-5.0.1.tgz", + "integrity": "sha512-5LBh1Tls8c9xgGjw3QrMwETmTMVk0oFgvrFSvWx62llR2hcEInrKNZ2GZCCuuy2lvWrdl5jhbpeqc5hRYKFOcw==", + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/extract-zip": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/extract-zip/-/extract-zip-2.0.1.tgz", @@ -2265,6 +2607,19 @@ "@types/yauzl": "^2.9.1" } }, + "node_modules/fast-csv": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/fast-csv/-/fast-csv-4.3.6.tgz", + "integrity": "sha512-2RNSpuwwsJGP0frGsOmTb9oUF+VkFSM4SyLTDgwf2ciHWTarN0lQTC+F2f/t5J9QjW+c65VFIAAu85GsvMIusw==", + "license": "MIT", + "dependencies": { + "@fast-csv/format": "4.3.5", + "@fast-csv/parse": "4.3.6" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -2300,6 +2655,18 @@ } } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "license": "ISC" + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2315,6 +2682,22 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/fstream": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + }, + "engines": { + "node": ">=0.6" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -2366,6 +2749,33 @@ "node": ">= 14" } }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, "node_modules/html-encoding-sniffer": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", @@ -2411,7 +2821,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -2428,6 +2837,29 @@ ], "license": "BSD-3-Clause" }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.7.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", @@ -2455,6 +2887,12 @@ "dev": true, "license": "MIT" }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -2539,6 +2977,184 @@ "node": ">=6" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/jszip/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/jszip/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lazystream": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz", + "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", + "license": "MIT", + "dependencies": { + "readable-stream": "^2.0.5" + }, + "engines": { + "node": ">= 0.6.3" + } + }, + "node_modules/lazystream/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/lazystream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/lazystream/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/listenercount": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/listenercount/-/listenercount-1.0.1.tgz", + "integrity": "sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ==", + "license": "ISC" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", + "license": "MIT" + }, + "node_modules/lodash.difference": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", + "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", + "license": "MIT" + }, + "node_modules/lodash.escaperegexp": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz", + "integrity": "sha512-TM9YBvyC84ZxE3rgfefxUWiQKLilstD6k7PTGt6wfbtXF8ixIJLOL3VYyV/z+ZiPLsVxAsKAFVwWlWeb2Y8Yyw==", + "license": "MIT" + }, + "node_modules/lodash.flatten": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", + "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", + "license": "MIT" + }, + "node_modules/lodash.groupby": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.groupby/-/lodash.groupby-4.6.0.tgz", + "integrity": "sha512-5dcWxm23+VAoz+awKmBaiBvzox8+RqMgFhi7UvX9DHZr2HdxHXM/Wrf8cfKpsW37RNrvtPn6hSwNqurSILbmJw==", + "license": "MIT" + }, + "node_modules/lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, + "node_modules/lodash.isfunction": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash.isfunction/-/lodash.isfunction-3.0.9.tgz", + "integrity": "sha512-AirXNj15uRIMMPihnkInB4i3NHeb4iBtNg9WRWuK2o31S+ePwwNmDPaTL3o7dTJ+VXNZim7rFs4rxN4YU1oUJw==", + "license": "MIT" + }, + "node_modules/lodash.isnil": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/lodash.isnil/-/lodash.isnil-4.0.0.tgz", + "integrity": "sha512-up2Mzq3545mwVnMhTDMdfoG1OurpA/s5t88JmQX809eH3C8491iu2sfKhTfhQtKY78oPNhiaHJUpT/dUDAAtng==", + "license": "MIT" + }, + "node_modules/lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", + "license": "MIT" + }, + "node_modules/lodash.isundefined": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash.isundefined/-/lodash.isundefined-3.0.1.tgz", + "integrity": "sha512-MXB1is3s899/cD8jheYYE2V9qTHwKvt+npCwpD+1Sxm3Q3cECXCiYHjeHWXNwr6Q0SOBPrYUDxendrO6goVTEA==", + "license": "MIT" + }, + "node_modules/lodash.union": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", + "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", + "license": "MIT" + }, + "node_modules/lodash.uniq": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", + "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", + "license": "MIT" + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -2556,6 +3172,27 @@ "dev": true, "license": "CC0-1.0" }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", @@ -2563,6 +3200,18 @@ "dev": true, "license": "MIT" }, + "node_modules/mkdirp": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", + "integrity": "sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==", + "license": "MIT", + "dependencies": { + "minimist": "^1.2.6" + }, + "bin": { + "mkdirp": "bin/cmd.js" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -2609,11 +3258,19 @@ "node": ">=18" } }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -2653,6 +3310,12 @@ "node": ">= 14" } }, + "node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -2666,6 +3329,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/pend": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", @@ -2722,6 +3394,12 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -2877,6 +3555,50 @@ "react-dom": ">=18" } }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdir-glob": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz", + "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.1.0" + } + }, + "node_modules/readdir-glob/node_modules/brace-expansion": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz", + "integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/readdir-glob/node_modules/minimatch": { + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -2897,6 +3619,19 @@ "node": ">=0.10.0" } }, + "node_modules/rimraf": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.7.1.tgz", + "integrity": "sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + } + }, "node_modules/rollup": { "version": "4.62.4", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.4.tgz", @@ -2943,6 +3678,26 @@ "fsevents": "~2.3.2" } }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/saxes": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", @@ -2978,6 +3733,12 @@ "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -3052,6 +3813,15 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -3179,6 +3949,15 @@ "dev": true, "license": "MIT" }, + "node_modules/tmp": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.7.tgz", + "integrity": "sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==", + "license": "MIT", + "engines": { + "node": ">=14.14" + } + }, "node_modules/tough-cookie": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", @@ -3205,6 +3984,15 @@ "node": ">=20" } }, + "node_modules/traverse": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/traverse/-/traverse-0.3.9.tgz", + "integrity": "sha512-iawgk0hLP3SxGKDfnDJf8wTz4p2qImnyihM5Hh/sGvQ3K37dPi/w8sRhdNIxYA1TwFwc5mDhIJq+O0RsvXBKdQ==", + "license": "MIT/X11", + "engines": { + "node": "*" + } + }, "node_modules/tslib": { "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", @@ -3248,6 +4036,54 @@ "license": "MIT", "optional": true }, + "node_modules/unzipper": { + "version": "0.10.14", + "resolved": "https://registry.npmjs.org/unzipper/-/unzipper-0.10.14.tgz", + "integrity": "sha512-ti4wZj+0bQTiX2KmKWuwj7lhV+2n//uXEotUmGuQqrbVZSEGFMbI68+c6JCQ8aAmUWYvtHEz2A8K6wXvueR/6g==", + "license": "MIT", + "dependencies": { + "big-integer": "^1.6.17", + "binary": "~0.3.0", + "bluebird": "~3.4.1", + "buffer-indexof-polyfill": "~1.0.0", + "duplexer2": "~0.1.4", + "fstream": "^1.0.12", + "graceful-fs": "^4.2.2", + "listenercount": "~1.0.1", + "readable-stream": "~2.3.6", + "setimmediate": "~1.0.4" + } + }, + "node_modules/unzipper/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/unzipper/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/unzipper/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -3279,6 +4115,22 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).", + "license": "MIT", + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -3908,7 +4760,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/ws": { @@ -3947,7 +4798,6 @@ "version": "2.2.0", "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", - "dev": true, "license": "MIT" }, "node_modules/y18n": { @@ -4007,6 +4857,41 @@ "fd-slicer": "~1.1.0" } }, + "node_modules/zip-stream": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-4.1.1.tgz", + "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", + "license": "MIT", + "dependencies": { + "archiver-utils": "^3.0.4", + "compress-commons": "^4.1.2", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, + "node_modules/zip-stream/node_modules/archiver-utils": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/archiver-utils/-/archiver-utils-3.0.4.tgz", + "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", + "license": "MIT", + "dependencies": { + "glob": "^7.2.3", + "graceful-fs": "^4.2.0", + "lazystream": "^1.0.0", + "lodash.defaults": "^4.2.0", + "lodash.difference": "^4.5.0", + "lodash.flatten": "^4.4.0", + "lodash.isplainobject": "^4.0.6", + "lodash.union": "^4.6.0", + "normalize-path": "^3.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">= 10" + } + }, "node_modules/zod": { "version": "3.23.8", "resolved": "https://registry.npmjs.org/zod/-/zod-3.23.8.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2120f50..1ab5aea 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" diff --git a/frontend/src/lib/exportXlsx.js b/frontend/src/lib/exportXlsx.js new file mode 100644 index 0000000..d5fb919 --- /dev/null +++ b/frontend/src/lib/exportXlsx.js @@ -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} 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) +} diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 9e021f6..88ba844 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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' @@ -438,7 +439,39 @@ function RecruiterCandidates() { title="Candidates" sub={<>{total} candidate account{total === 1 ? '' : 's'} · role_id {CANDIDATE_ROLE_ID}} actions={<> -