Inbox: Export CSV button; graceful partial failure on the All channel #60
|
|
@ -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 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 ? (
|
||||
<EmptyState icon="inbox" title="Nothing here">
|
||||
{isForms ? 'No form responses in this sheet tab.' : 'No applications in this view.'}
|
||||
|
|
|
|||
Loading…
Reference in New Issue