From 56a05f840f1e4b65832426031555b6d36b790e44 Mon Sep 17 00:00:00 2001 From: Talha Ahmed Date: Wed, 2 Sep 2026 20:20:36 +0500 Subject: [PATCH] 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.'} -- 2.40.1