Inbox: All channel - Email and Sheet Forms merged in one queue

Third channel pill beside Email / Sheet Forms. Both sources are fetched
unpaged (their endpoints read a missing top/limit as no LIMIT), merged
newest-first, and paged client-side - per-source skip/top cannot compose
into a correct global page. Rows carry kind (email/form) so the detail
pane, mark-read (email rows only get checkboxes; sheet rows have no
mailbox read state) and the kind-aware state/duplicate mutations all
pick the right endpoint per row. Tabs collapse to the shared set (no
Unread), counts and totals sum both sources, and the sheet filter spans
every sheet tab on this channel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/53/head
Talha Ahmed 2026-09-02 17:36:52 +05:00
parent d1fa58ecc3
commit ec8a4af52d
1 changed files with 87 additions and 42 deletions

View File

@ -59,10 +59,11 @@ function storeSyncRunId(id) {
}
}
/** Inbox channel: Outlook email queue vs imported Google Form rows. */
/** Inbox channel: Outlook email queue, imported Google Form rows, or both. */
const CHANNELS = [
{ key: 'email', label: 'Email', icon: 'mail' },
{ key: 'forms', label: 'Sheet Forms', icon: 'layers' },
{ key: 'all', label: 'All', icon: 'inbox' },
]
const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026'
@ -303,6 +304,7 @@ async function fetchMessageDetail(recordId) {
if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown'
return {
kind: 'email',
id: String(row.id),
name,
initials: initialsOf(name),
@ -356,6 +358,7 @@ async function fetchApplications(params) {
rows: rows.map((row) => {
const name = row.name || row.email || 'Unknown'
return {
kind: 'email',
id: String(row.id),
name,
initials: initialsOf(name),
@ -737,25 +740,31 @@ export default function Inbox() {
const [noting, setNoting] = useState(null)
const isForms = channel === 'forms'
const channelTabs = isForms ? FORM_TABS : TABS
// Combined channel: both sources fetched UNPAGED (each endpoint reads a
// missing top/limit as no LIMIT), merged by date, and paged client-side
// per-source skip/top cannot compose into a correct global page.
const isAllChannel = channel === 'all'
const channelTabs = isForms || isAllChannel ? FORM_TABS : TABS
const tabFilter = TAB_FILTERS[tab] ?? {}
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({
...tabFilter,
// 'all' drops the param entirely the endpoint reads a missing top as unpaged.
top: pageSize === 'all' ? undefined : pageSize,
skip,
// 'all' page size and the All channel drop the param entirely the
// endpoint reads a missing top as unpaged.
top: pageSize === 'all' || isAllChannel ? undefined : pageSize,
skip: isAllChannel ? 0 : skip,
...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, skip, pageSize, q])
}), [tabFilter, skip, pageSize, q, isAllChannel])
const formParams = useMemo(() => ({
sheet: formSheet || undefined,
offset: skip,
limit: pageSize === 'all' ? undefined : pageSize,
// All channel spans every sheet tab, not just the selected one.
sheet: isAllChannel ? undefined : (formSheet || undefined),
offset: isAllChannel ? 0 : skip,
limit: pageSize === 'all' || isAllChannel ? undefined : pageSize,
...formTabFilter,
...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, skip, pageSize, q, formTabFilter])
}), [formSheet, skip, pageSize, q, formTabFilter, isAllChannel])
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams),
@ -776,7 +785,7 @@ export default function Inbox() {
const formQuery = useQuery({
queryKey: qk.mailbox.formData(formParams),
queryFn: () => fetchFormApplications(formParams),
enabled: isForms,
enabled: isForms || isAllChannel,
})
const countsQuery = useQuery({
@ -785,13 +794,14 @@ export default function Inbox() {
enabled: !isForms,
})
const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined)
const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts({ sheet: formSheet || undefined }),
queryKey: qk.mailbox.formCounts({ sheet: formCountsSheet }),
queryFn: async () => {
const res = await sheetApi.fetchFormCounts({ sheet: formSheet || undefined })
const res = await sheetApi.fetchFormCounts({ sheet: formCountsSheet })
return res?.data ?? {}
},
enabled: isForms,
enabled: isForms || isAllChannel,
})
const emailTotalQuery = useQuery({
@ -805,12 +815,12 @@ export default function Inbox() {
})
const formTotalQuery = useQuery({
queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }),
queryKey: qk.mailbox.formTotal({ sheet: formCountsSheet }),
queryFn: async () => {
const res = await sheetApi.countFormData({ sheet: formSheet || undefined })
const res = await sheetApi.countFormData({ sheet: formCountsSheet })
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
},
enabled: isForms,
enabled: isForms || isAllChannel,
staleTime: Infinity,
})
@ -829,27 +839,55 @@ export default function Inbox() {
}
}, [isForms, formSheetsQuery.data, formSheet])
const activeQuery = isForms ? formQuery : applicationsQuery
// All channel: both sources arrive unpaged; merge newest-first and let the
// pager slice the merged array below.
const mergedRows = useMemo(() => {
if (!isAllChannel) return null
const emails = Array.isArray(applicationsQuery.data?.rows) ? applicationsQuery.data.rows : []
const forms = Array.isArray(formQuery.data?.rows) ? formQuery.data.rows : []
return [...emails, ...forms].sort(
(a, b) => (b.received?.getTime() ?? 0) - (a.received?.getTime() ?? 0),
)
}, [isAllChannel, applicationsQuery.data, formQuery.data])
const activeQuery = isAllChannel
? {
isPending: applicationsQuery.isPending || formQuery.isPending,
// one healthy source still renders; error only when both are down
isError: applicationsQuery.isError && formQuery.isError,
isSuccess: applicationsQuery.isSuccess && formQuery.isSuccess,
error: applicationsQuery.error ?? formQuery.error,
data: { rows: mergedRows ?? [], total: mergedRows?.length ?? 0 },
}
: (isForms ? formQuery : applicationsQuery)
const inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : []
const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
const counts = useMemo(
() => {
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
const e = countsQuery.data ?? {}
const f = formCountsQuery.data ?? {}
const pick = (key) => (isAllChannel
? n(e[key]) + n(f[key])
: n((isForms ? f : e)[key]))
return {
'All Applications': n(serverCounts.all),
Unread: n(serverCounts.unread),
Processed: n(serverCounts.processed),
Rejected: n(serverCounts.rejected),
Duplicates: n(serverCounts.duplicates),
'All Applications': pick('all'),
Unread: pick('unread'),
Processed: pick('processed'),
Rejected: pick('rejected'),
Duplicates: pick('duplicates'),
}
},
[serverCounts],
[countsQuery.data, formCountsQuery.data, isForms, isAllChannel],
)
const poolTotal = isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0)
const poolTotal = isAllChannel
? (emailTotalQuery.data ?? 0) + (formTotalQuery.data ?? 0)
: (isForms ? (formTotalQuery.data ?? 0) : (emailTotalQuery.data ?? 0))
const tabTotal = counts[tab] ?? 0
const countsReady = isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess
const countsReady = isAllChannel
? countsQuery.isSuccess && formCountsQuery.isSuccess
: (isForms ? formCountsQuery.isSuccess : countsQuery.isSuccess)
const total = q.trim()
? (activeQuery.data?.total ?? 0)
: (countsReady ? tabTotal : (poolTotal || (activeQuery.data?.total ?? 0)))
@ -867,11 +905,15 @@ export default function Inbox() {
setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
}, [total, pageSize, skip, showAll])
const list = inbox
// All channel pages the merged array client-side; single channels page on the server.
const list = isAllChannel && !showAll ? inbox.slice(skip, skip + pageSize) : inbox
// Mixed rows: the row's own kind picks the detail endpoint, not the channel.
const selectedKind = inbox.find((i) => i.id === selectedId)?.kind
?? (isForms ? 'form' : 'email')
const detailQuery = useQuery({
queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)),
queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)),
enabled: Boolean(selectedId),
})
@ -902,8 +944,8 @@ export default function Inbox() {
setSkip(0)
setSelectedId(null)
setQ('')
// Unread is email-only; leave it behind when opening Sheet Forms.
if (next === 'forms' && tab === 'Unread') setTab('All Applications')
// Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next !== 'email' && tab === 'Unread') setTab('All Applications')
selection.clear()
}
@ -924,11 +966,12 @@ export default function Inbox() {
setReadAll.mutate({
read,
filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) },
ids: list.map((i) => i.id),
// sheet rows carry no mailbox read state email rows only
ids: list.filter((i) => i.kind !== 'form').map((i) => i.id),
})
return
}
const ids = list.map((i) => i.id)
const ids = list.filter((i) => i.kind !== 'form').map((i) => i.id)
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
@ -975,8 +1018,8 @@ export default function Inbox() {
function select(id) {
setSelectedId(id)
if (isForms) return
const item = inbox.find((i) => i.id === id)
if (item?.kind === 'form') return // sheet rows have no mailbox read state
if (item?.unread) setRead.mutate({ ids: [id], read: true })
}
@ -1058,9 +1101,11 @@ export default function Inbox() {
<PageHeader
title="Recruitment Inbox"
sub={
isForms
? 'Google Form applicants — same queue energy, profile-first cards'
: 'Every candidate, every source — one unified queue'
isAllChannel
? 'Email and Sheet Forms together — one combined stream'
: isForms
? 'Google Form applicants — same queue energy, profile-first cards'
: 'Every candidate, every source — one unified queue'
}
actions={<>
<div className="pill-tabs" role="tablist" aria-label="Inbox channel">
@ -1184,7 +1229,7 @@ export default function Inbox() {
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)}
>
{!isForms && (
{i.kind !== 'form' && (
<RowCheck
checked={selection.selectedIds.has(i.id)}
onToggle={() => selection.toggle(i.id)}
@ -1207,7 +1252,7 @@ export default function Inbox() {
{i.atsScore != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
{isForms && i.noticePeriod && (
{i.kind === 'form' && i.noticePeriod && (
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)}
</div>
@ -1216,7 +1261,7 @@ export default function Inbox() {
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
)}
{isForms && i.residingCity && (
{i.kind === 'form' && i.residingCity && (
<span className="cell-sub">{i.residingCity}</span>
)}
</div>
@ -1262,7 +1307,7 @@ export default function Inbox() {
{friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState>
</div>
) : isForms ? (
) : selected?.kind === 'form' ? (
<FormApplicantDetail
item={selected}
loading={detailQuery.isPending}