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
parent
d1fa58ecc3
commit
ec8a4af52d
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue