Merge pull request 'Inbox: All channel � Email and Sheet Forms merged in one queue' (#53) from Talha into main
Deploy to S3 / deploy (push) Successful in 33s Details

pull/64/head^2
talha.ahmed 2026-09-02 12:36:29 +00:00
commit 5f77ba9f8e
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 = [ const CHANNELS = [
{ key: 'email', label: 'Email', icon: 'mail' }, { key: 'email', label: 'Email', icon: 'mail' },
{ key: 'forms', label: 'Sheet Forms', icon: 'layers' }, { key: 'forms', label: 'Sheet Forms', icon: 'layers' },
{ key: 'all', label: 'All', icon: 'inbox' },
] ]
const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026' const DEFAULT_FORM_SHEET = 'Form Responses - Candidate Database Sheet 2026'
@ -303,6 +304,7 @@ async function fetchMessageDetail(recordId) {
if (!row) return null if (!row) return null
const name = row.sender_name || row.fromEmail || 'Unknown' const name = row.sender_name || row.fromEmail || 'Unknown'
return { return {
kind: 'email',
id: String(row.id), id: String(row.id),
name, name,
initials: initialsOf(name), initials: initialsOf(name),
@ -356,6 +358,7 @@ async function fetchApplications(params) {
rows: rows.map((row) => { rows: rows.map((row) => {
const name = row.name || row.email || 'Unknown' const name = row.name || row.email || 'Unknown'
return { return {
kind: 'email',
id: String(row.id), id: String(row.id),
name, name,
initials: initialsOf(name), initials: initialsOf(name),
@ -737,25 +740,31 @@ export default function Inbox() {
const [noting, setNoting] = useState(null) const [noting, setNoting] = useState(null)
const isForms = channel === 'forms' 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 tabFilter = TAB_FILTERS[tab] ?? {}
const formTabFilter = FORM_TAB_FILTERS[tab] ?? {} const formTabFilter = FORM_TAB_FILTERS[tab] ?? {}
const listParams = useMemo(() => ({ const listParams = useMemo(() => ({
...tabFilter, ...tabFilter,
// 'all' drops the param entirely the endpoint reads a missing top as unpaged. // 'all' page size and the All channel drop the param entirely the
top: pageSize === 'all' ? undefined : pageSize, // endpoint reads a missing top as unpaged.
skip, top: pageSize === 'all' || isAllChannel ? undefined : pageSize,
skip: isAllChannel ? 0 : skip,
...(q.trim() ? { search: q.trim() } : {}), ...(q.trim() ? { search: q.trim() } : {}),
}), [tabFilter, skip, pageSize, q]) }), [tabFilter, skip, pageSize, q, isAllChannel])
const formParams = useMemo(() => ({ const formParams = useMemo(() => ({
sheet: formSheet || undefined, // All channel spans every sheet tab, not just the selected one.
offset: skip, sheet: isAllChannel ? undefined : (formSheet || undefined),
limit: pageSize === 'all' ? undefined : pageSize, offset: isAllChannel ? 0 : skip,
limit: pageSize === 'all' || isAllChannel ? undefined : pageSize,
...formTabFilter, ...formTabFilter,
...(q.trim() ? { search: q.trim() } : {}), ...(q.trim() ? { search: q.trim() } : {}),
}), [formSheet, skip, pageSize, q, formTabFilter]) }), [formSheet, skip, pageSize, q, formTabFilter, isAllChannel])
const applicationsQuery = useQuery({ const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(listParams), queryKey: qk.mailbox.applications(listParams),
@ -776,7 +785,7 @@ export default function Inbox() {
const formQuery = useQuery({ const formQuery = useQuery({
queryKey: qk.mailbox.formData(formParams), queryKey: qk.mailbox.formData(formParams),
queryFn: () => fetchFormApplications(formParams), queryFn: () => fetchFormApplications(formParams),
enabled: isForms, enabled: isForms || isAllChannel,
}) })
const countsQuery = useQuery({ const countsQuery = useQuery({
@ -785,13 +794,14 @@ export default function Inbox() {
enabled: !isForms, enabled: !isForms,
}) })
const formCountsSheet = isAllChannel ? undefined : (formSheet || undefined)
const formCountsQuery = useQuery({ const formCountsQuery = useQuery({
queryKey: qk.mailbox.formCounts({ sheet: formSheet || undefined }), queryKey: qk.mailbox.formCounts({ sheet: formCountsSheet }),
queryFn: async () => { queryFn: async () => {
const res = await sheetApi.fetchFormCounts({ sheet: formSheet || undefined }) const res = await sheetApi.fetchFormCounts({ sheet: formCountsSheet })
return res?.data ?? {} return res?.data ?? {}
}, },
enabled: isForms, enabled: isForms || isAllChannel,
}) })
const emailTotalQuery = useQuery({ const emailTotalQuery = useQuery({
@ -805,12 +815,12 @@ export default function Inbox() {
}) })
const formTotalQuery = useQuery({ const formTotalQuery = useQuery({
queryKey: qk.mailbox.formTotal({ sheet: formSheet || undefined }), queryKey: qk.mailbox.formTotal({ sheet: formCountsSheet }),
queryFn: async () => { 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) return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
}, },
enabled: isForms, enabled: isForms || isAllChannel,
staleTime: Infinity, staleTime: Infinity,
}) })
@ -829,27 +839,55 @@ export default function Inbox() {
} }
}, [isForms, formSheetsQuery.data, formSheet]) }, [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 inbox = Array.isArray(activeQuery.data?.rows) ? activeQuery.data.rows : []
const serverCounts = isForms ? (formCountsQuery.data ?? {}) : (countsQuery.data ?? {})
const counts = useMemo( const counts = useMemo(
() => { () => {
const n = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0) 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 { return {
'All Applications': n(serverCounts.all), 'All Applications': pick('all'),
Unread: n(serverCounts.unread), Unread: pick('unread'),
Processed: n(serverCounts.processed), Processed: pick('processed'),
Rejected: n(serverCounts.rejected), Rejected: pick('rejected'),
Duplicates: n(serverCounts.duplicates), 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 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() const total = q.trim()
? (activeQuery.data?.total ?? 0) ? (activeQuery.data?.total ?? 0)
: (countsReady ? tabTotal : (poolTotal || (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)) setSkip(Math.max(0, Math.floor((total - 1) / pageSize) * pageSize))
}, [total, pageSize, skip, showAll]) }, [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({ const detailQuery = useQuery({
queryKey: isForms ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId), queryKey: selectedKind === 'form' ? qk.mailbox.formRow(selectedId) : qk.mailbox.message(selectedId),
queryFn: () => (isForms ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)), queryFn: () => (selectedKind === 'form' ? fetchFormDetail(selectedId) : fetchMessageDetail(selectedId)),
enabled: Boolean(selectedId), enabled: Boolean(selectedId),
}) })
@ -902,8 +944,8 @@ export default function Inbox() {
setSkip(0) setSkip(0)
setSelectedId(null) setSelectedId(null)
setQ('') setQ('')
// Unread is email-only; leave it behind when opening Sheet Forms. // Unread is email-only; leave it behind when opening Sheet Forms or All.
if (next === 'forms' && tab === 'Unread') setTab('All Applications') if (next !== 'email' && tab === 'Unread') setTab('All Applications')
selection.clear() selection.clear()
} }
@ -924,11 +966,12 @@ export default function Inbox() {
setReadAll.mutate({ setReadAll.mutate({
read, read,
filter: { ...tabFilter, ...(q.trim() ? { search: q.trim() } : {}) }, 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 return
} }
const ids = list.map((i) => i.id) const ids = list.filter((i) => i.kind !== 'form').map((i) => i.id)
if (!ids.length) return if (!ids.length) return
setRead.mutate({ ids, read }, { setRead.mutate({ ids, read }, {
onSuccess: () => { onSuccess: () => {
@ -975,8 +1018,8 @@ export default function Inbox() {
function select(id) { function select(id) {
setSelectedId(id) setSelectedId(id)
if (isForms) return
const item = inbox.find((i) => i.id === id) 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 }) if (item?.unread) setRead.mutate({ ids: [id], read: true })
} }
@ -1058,9 +1101,11 @@ export default function Inbox() {
<PageHeader <PageHeader
title="Recruitment Inbox" title="Recruitment Inbox"
sub={ sub={
isForms isAllChannel
? 'Google Form applicants — same queue energy, profile-first cards' ? 'Email and Sheet Forms together — one combined stream'
: 'Every candidate, every source — one unified queue' : isForms
? 'Google Form applicants — same queue energy, profile-first cards'
: 'Every candidate, every source — one unified queue'
} }
actions={<> actions={<>
<div className="pill-tabs" role="tablist" aria-label="Inbox channel"> <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' : ''}`} className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)} onClick={() => select(i.id)}
> >
{!isForms && ( {i.kind !== 'form' && (
<RowCheck <RowCheck
checked={selection.selectedIds.has(i.id)} checked={selection.selectedIds.has(i.id)}
onToggle={() => selection.toggle(i.id)} onToggle={() => selection.toggle(i.id)}
@ -1207,7 +1252,7 @@ export default function Inbox() {
{i.atsScore != null && ( {i.atsScore != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div> <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 className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)} )}
</div> </div>
@ -1216,7 +1261,7 @@ export default function Inbox() {
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge> <Badge>{i.applicationStatus}</Badge>
)} )}
{isForms && i.residingCity && ( {i.kind === 'form' && i.residingCity && (
<span className="cell-sub">{i.residingCity}</span> <span className="cell-sub">{i.residingCity}</span>
)} )}
</div> </div>
@ -1262,7 +1307,7 @@ export default function Inbox() {
{friendlyAuthError(detailQuery.error, 'Request failed')} {friendlyAuthError(detailQuery.error, 'Request failed')}
</EmptyState> </EmptyState>
</div> </div>
) : isForms ? ( ) : selected?.kind === 'form' ? (
<FormApplicantDetail <FormApplicantDetail
item={selected} item={selected}
loading={detailQuery.isPending} loading={detailQuery.isPending}