WIRED WITH FRONTEND

pull/5/head
ahmed.mujtaba 2026-08-07 20:49:00 +05:00
parent 9f6926b1e1
commit 36be406f92
6 changed files with 62 additions and 31 deletions

View File

@ -126,9 +126,11 @@ async def get_all_applications(
):
try:
service=Email(session=session)
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
items=await service.get_all_applications(top, skip, search, application_status=application_status)
total=await service.count_inbox_messages(search, application_status=application_status)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
if isread==False:
items=await service.get_all_applications(top, skip, search, isread=False)
total=await service.count_inbox_messages(search, isread=False)

View File

@ -226,10 +226,12 @@ class Inbox_Messages(SQLModel, table=True):
return result.scalars().first()
@classmethod
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True):
async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED):
statement = select(func.count()).select_from(cls)
if search:
statement = statement.where(cls._search_filter(search))
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
statement = statement.where(cls.application_status==application_status)
if isread==False:
statement = statement.where(cls.message_read==False)
result = await session.execute(statement)

View File

@ -90,6 +90,7 @@ def serialize_application(message: Inbox_Messages) -> dict:
"received": message.message_received_time,
"unread": not message.message_read,
"processing": "Read" if message.message_read else "Unread",
"application_status": message.application_status,
"resume_status": _RESUME_STATUS.get(message.match_status, "Pending"),
"attachment": _attachment_name(message),
"has_attachment": message.attachment,

View File

@ -1,7 +1,7 @@
import logging
import httpx,os
from fastapi import HTTPException
from backend.inbox.enums import Candidate_application_Status
from inbox.enums import Candidate_application_Status
from inbox.models import Inbox_Messages
from inbox.file_decoder import decode_attachment
from inbox.serializers import serialize_application, serialize_message
@ -93,10 +93,9 @@ class Email:
return item
async def get_all_applications(self,top,skip,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,application_status=application_status)
if isread==False:
elif isread==False:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search,isread)
else:
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search)
@ -129,8 +128,10 @@ class Email:
task_ids.append(task.task_id)
return task_ids
async def count_inbox_messages(self,search=None,isread:bool=True):
if isread==False:
async def count_inbox_messages(self,search=None,isread:bool=True,application_status:Candidate_application_Status=Candidate_application_Status.CLOSED):
if application_status == Candidate_application_Status.PROCESS or application_status==Candidate_application_Status.REJECTED:
return await Inbox_Messages.count_inbox_messages(self.session,search,application_status=application_status)
elif isread==False:
return await Inbox_Messages.count_inbox_messages(self.session,search,isread=False)
else:
return await Inbox_Messages.count_inbox_messages(self.session,search)

View File

@ -17,12 +17,14 @@ export function listMessages() {
* Unlike /inbox/fetch this one IS permissioned server-side
* (require_permission(INBOX_VIEW)), so a caller without the tag gets a 403.
*/
export function listApplications({ search, top, skip, recordId, isread } = {}) {
export function listApplications({ search, top, skip, recordId, isread, applicationStatus } = {}) {
return request('/inbox/all-applications', {
// `isread` is tri-valued on the wire: omit it for every tab (server defaults
// to true = no filter), send false for the Unread tab only. buildUrl drops
// undefined but keeps false, so `isread: undefined` sends no param at all.
params: { search, top, skip, record_id: recordId, isread },
// Same for `application_status`: omit for every tab (server defaults to
// CLOSED = no filter), send PROCESS / REJECTED for those tabs only.
params: { search, top, skip, record_id: recordId, isread, application_status: applicationStatus },
})
}

View File

@ -29,6 +29,17 @@ import {
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
/**
* Server-side filters for the tabs that /inbox/all-applications can narrow.
* Unfiltered tabs (and countsQuery) pass `{}` so the backend defaults apply
* isread=true and application_status=CLOSED both mean "no filter".
*/
const TAB_FILTERS = {
Unread: { isread: false },
Processed: { applicationStatus: 'PROCESS' },
Rejected: { applicationStatus: 'REJECTED' },
}
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
const NOW = new Date('2026-07-09T20:00')
@ -151,11 +162,12 @@ async function fetchMessageDetail(recordId) {
/**
* GET /inbox/all-applications -> the shape the application tabs render.
*
* READ-ONLY: inbox_messages has no columns for processing state, duplicates,
* recruiter, phone, experience or an ATS score, so those arrive null and every
* mutating action on these tabs is disabled until the endpoints exist.
* `processing` is derived from message_read alone, which is why the Imported /
* Processed / Rejected / Duplicates tabs read empty.
* READ-ONLY: inbox_messages has no columns for duplicates, recruiter, phone,
* experience or an ATS score, so those arrive null and every mutating action
* on these tabs is disabled until the endpoints exist. `processing` is derived
* from message_read alone (Read/Unread). Processed / Rejected tabs filter on
* `application_status` (PROCESS / REJECTED); Imported / Duplicates stay empty
* with no backing columns.
*/
async function fetchApplications(params) {
const res = await inboxApi.listApplications(params)
@ -173,6 +185,7 @@ async function fetchApplications(params) {
received: parseDate(row.received),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
applicationStatus: row.application_status || null,
resumeStatus: row.resume_status || 'Pending',
attachment: row.attachment,
hasAttachment: Boolean(row.has_attachment),
@ -238,24 +251,25 @@ export default function Inbox() {
const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null)
// Only the Unread tab filters server-side; every other tab omits the param and
// the backend's default (true) means "no filter".
const isread = tab === 'Unread' ? false : undefined
// Tabs with a server-side filter pass their params; everything else (and
// countsQuery) passes `{}` so the backend defaults mean "no filter".
const tabFilter = TAB_FILTERS[tab] ?? {}
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications({ isread }),
queryFn: () => fetchApplications({ isread }),
queryKey: qk.mailbox.applications(tabFilter),
queryFn: () => fetchApplications(tabFilter),
enabled: tab !== 'Email',
})
/**
* The tab badges need whole-table counts, which a server-filtered response
* cannot give and there is no counts endpoint. So the unfiltered set stays
* loaded for them. On every tab except Unread this resolves to the SAME query
* key as the list above, so React Query serves both from one request.
* loaded for them. On every tab without a TAB_FILTERS entry this resolves to
* the SAME query key as the list above, so React Query serves both from one
* request.
*/
const countsQuery = useQuery({
queryKey: qk.mailbox.applications({ isread: undefined }),
queryKey: qk.mailbox.applications({}),
queryFn: () => fetchApplications({}),
enabled: tab !== 'Email',
})
@ -293,14 +307,15 @@ export default function Inbox() {
})
const counts = useMemo(
// Counted off the UNFILTERED set `inbox` is server-filtered on the Unread
// tab, so counting it there would report the unread total for every badge.
// Counted off the UNFILTERED set `inbox` is server-filtered on Unread /
// Processed / Rejected, so counting it there would report that tab's total
// for every badge.
() => ({
'All Applications': allApplications.length,
Unread: allApplications.filter((i) => i.processing === 'Unread').length,
Imported: allApplications.filter((i) => i.processing === 'Imported').length,
Processed: allApplications.filter((i) => i.processing === 'Processed').length,
Rejected: allApplications.filter((i) => i.processing === 'Rejected').length,
Processed: allApplications.filter((i) => i.applicationStatus === 'PROCESS').length,
Rejected: allApplications.filter((i) => i.applicationStatus === 'REJECTED').length,
Duplicates: allApplications.filter((i) => i.duplicate).length,
Email: (emailsQuery.data ?? []).filter((e) => e.unread).length,
}),
@ -309,13 +324,13 @@ export default function Inbox() {
const list = useMemo(() => {
let l = inbox
// Unread is already filtered server-side; re-applying it client-side is what
// makes the optimistic mark-read drop the row from the list immediately
// instead of leaving it until the refetch lands.
// Unread / Processed / Rejected are already filtered server-side; re-applying
// client-side keeps the optimistic mark-read drop-off for Unread, and keeps
// Processed/Rejected coherent if a stale cache briefly holds mixed rows.
if (tab === 'Unread') l = l.filter((i) => i.processing === 'Unread')
else if (tab === 'Imported') l = l.filter((i) => i.processing === 'Imported')
else if (tab === 'Processed') l = l.filter((i) => i.processing === 'Processed')
else if (tab === 'Rejected') l = l.filter((i) => i.processing === 'Rejected')
else if (tab === 'Processed') l = l.filter((i) => i.applicationStatus === 'PROCESS')
else if (tab === 'Rejected') l = l.filter((i) => i.applicationStatus === 'REJECTED')
else if (tab === 'Duplicates') l = l.filter((i) => i.duplicate)
if (q) l = l.filter((i) => (i.name + i.position + i.source).toLowerCase().includes(q.toLowerCase()))
return l
@ -465,7 +480,12 @@ export default function Inbox() {
)}
</div>
<div className="ii-pos">{i.position}</div>
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<Badge>{i.applicationStatus}</Badge>
)}
</div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">
@ -599,6 +619,9 @@ function ApplicationDetail({ item: i, loading, onPreview, onImport, onParse, onA
<div className="ph-role">{i.position}</div>
<div className="ph-tags" style={{ marginTop: 8 }}>
<SourceChip item={i} /> <Badge>{i.processing}</Badge>{' '}
{i.applicationStatus && i.applicationStatus !== 'CLOSED' && (
<><Badge>{i.applicationStatus}</Badge>{' '}</>
)}
<Badge className={i.resumeStatus === 'Parsed' ? 'b-green' : i.resumeStatus === 'Failed' ? 'b-red' : 'b-amber'}>
{i.resumeStatus}
</Badge>{' '}