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 = [
|
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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue