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
Deploy to S3 / deploy (push) Successful in 33s
Details
commit
5f77ba9f8e
|
|
@ -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,7 +1101,9 @@ export default function Inbox() {
|
|||
<PageHeader
|
||||
title="Recruitment Inbox"
|
||||
sub={
|
||||
isForms
|
||||
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'
|
||||
}
|
||||
|
|
@ -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