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.'}