Inbox: Export CSV button; graceful partial failure on the All channel #60

Merged
talha.ahmed merged 1 commits from Talha into main 2026-09-02 15:25:02 +00:00
1 changed files with 81 additions and 1 deletions

View File

@ -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()}
/>
)}
<button className="btn btn-secondary" onClick={exportCsv} disabled={exporting}>
<Icon name="download" /> {exporting ? 'Exporting…' : 'Export'}
</button>
<button className="btn btn-primary" onClick={() => navigate('/import')}>
<Icon name="upload" /> Upload CVs
</button>
@ -1212,11 +1272,31 @@ export default function Inbox() {
{activeQuery.isPending && (
<SkeletonRows rows={6} />
)}
{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 && (
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
{friendlyAuthError(activeQuery.error, 'Request failed')}
</EmptyState>
)}
{/* 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) && (
<div
className="cell-sub"
style={{
padding: '9px 16px',
borderBottom: '1px solid var(--border)',
background: 'var(--warning-soft)',
color: 'var(--warning)',
}}
>
{applicationsQuery.isError
? 'Email applications couldnt load right now — showing Sheet Forms only.'
: 'Sheet Form applications couldnt load right now — showing Email only.'}
</div>
)}
{activeQuery.isSuccess && list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}