diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 1bf403e..42207ce 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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() {
@@ -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' && ( selection.toggle(i.id)} @@ -1207,7 +1252,7 @@ export default function Inbox() { {i.atsScore != null && (
)} - {isForms && i.noticePeriod && ( + {i.kind === 'form' && i.noticePeriod && (
{i.noticePeriod}
)}
@@ -1216,7 +1261,7 @@ export default function Inbox() { {i.applicationStatus && i.applicationStatus !== 'CLOSED' && ( {i.applicationStatus} )} - {isForms && i.residingCity && ( + {i.kind === 'form' && i.residingCity && ( {i.residingCity} )} @@ -1262,7 +1307,7 @@ export default function Inbox() { {friendlyAuthError(detailQuery.error, 'Request failed')} - ) : isForms ? ( + ) : selected?.kind === 'form' ? (