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 <noreply@anthropic.com>pull/60/head
parent
2abc28fc14
commit
56a05f840f
|
|
@ -1044,6 +1044,63 @@ export default function Inbox() {
|
||||||
markDuplicate.mutate({ id: item.id, isDuplicate: !item.duplicate, kind: item.kind })
|
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 [syncRunId, setSyncRunId] = useState(() => readStoredSyncRunId())
|
||||||
const syncToastShown = useRef(null)
|
const syncToastShown = useRef(null)
|
||||||
|
|
||||||
|
|
@ -1135,6 +1192,9 @@ export default function Inbox() {
|
||||||
onClick={() => sync.mutate()}
|
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')}>
|
<button className="btn btn-primary" onClick={() => navigate('/import')}>
|
||||||
<Icon name="upload" /> Upload CVs
|
<Icon name="upload" /> Upload CVs
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -1212,11 +1272,31 @@ export default function Inbox() {
|
||||||
{activeQuery.isPending && (
|
{activeQuery.isPending && (
|
||||||
<SkeletonRows rows={6} />
|
<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"}>
|
<EmptyState icon="inbox" title={isForms ? "Couldn't load form applicants" : "Couldn't load applications"}>
|
||||||
{friendlyAuthError(activeQuery.error, 'Request failed')}
|
{friendlyAuthError(activeQuery.error, 'Request failed')}
|
||||||
</EmptyState>
|
</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 couldn’t load right now — showing Sheet Forms only.'
|
||||||
|
: 'Sheet Form applications couldn’t load right now — showing Email only.'}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{activeQuery.isSuccess && list.length === 0 ? (
|
{activeQuery.isSuccess && list.length === 0 ? (
|
||||||
<EmptyState icon="inbox" title="Nothing here">
|
<EmptyState icon="inbox" title="Nothing here">
|
||||||
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
|
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue