frontend all aplications

pull/5/head
ahmed.mujtaba 2026-08-07 19:17:16 +05:00
parent 52b76bb1cf
commit 3ae716a8c4
6 changed files with 251 additions and 62 deletions

View File

@ -1,11 +1,9 @@
from typing import Any
from fastapi import APIRouter,Depends, Query from fastapi import APIRouter,Depends, Query
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from fastapi import HTTPException from fastapi import HTTPException
from db_setup import get_session from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from inbox.views import Email from inbox.views import Email
import uuid
from users.permissions import PermissionTag, require_permission from users.permissions import PermissionTag, require_permission
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
@ -116,14 +114,22 @@ async def get_inbox_read_status(
@router.get("/inbox/all-applications") @router.get("/inbox/all-applications")
async def get_all_applications( async def get_all_applications(
app_id:uuid.UUID|int=Query(None), record_id: str | None = Query(None),
current_user:dict=Depends(require_permission(PermissionTag.INBOX_VIEW)), search: str | None = Query(None),
session:AsyncSession=Depends(get_session), top: int | None = Query(None),
skip: int = Query(0, ge=0),
current_user: dict = Depends(require_permission(PermissionTag.INBOX_VIEW)),
session: AsyncSession = Depends(get_session),
): ):
try: try:
service=Email(session=session) service=Email(session=session)
data=await service.get_all_applications(app_id) if record_id:
return JSONResponse(content={"data":data,"total":1,"status_code":200}) item=await service.get_application_by_id(record_id)
return JSONResponse(content={"data":item,"total":1,"status_code":200})
items=await service.get_all_applications(top,skip,search)
total=await service.count_inbox_messages(search)
return JSONResponse(content={"data":items,"total":total,"status_code":200})
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -2,9 +2,19 @@ from pathlib import Path
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
# match_status (inbox/tasks.py) -> the resume badge the inbox tabs render.
_RESUME_STATUS = {
"processing": "Parsing",
"matched": "Parsed",
"no_text": "Failed",
"failed": "Failed",
"dlq": "Failed",
"skipped": "Pending",
}
def serialize_message(message: Inbox_Messages) -> dict:
"""inbox_messages row -> the shape the #inbox Email tab renders.""" def _sender_name(message: Inbox_Messages) -> str:
"""Graph's display name when the payload carries one, else the raw address."""
sender_name = message.message_from sender_name = message.message_from
full = message.full_email_response full = message.full_email_response
if isinstance(full, dict): if isinstance(full, dict):
@ -15,12 +25,21 @@ def serialize_message(message: Inbox_Messages) -> dict:
name = email_address.get("name") name = email_address.get("name")
if name: if name:
sender_name = name sender_name = name
return sender_name
attachment_name = None
def _attachment_name(message: Inbox_Messages) -> str | None:
if message.file_name: if message.file_name:
attachment_name = message.file_name.split(",")[0].strip() or None return message.file_name.split(",")[0].strip() or None
elif message.file_path: if message.file_path:
attachment_name = Path(message.file_path.split(",")[0].strip()).name or None return Path(message.file_path.split(",")[0].strip()).name or None
return None
def serialize_message(message: Inbox_Messages) -> dict:
"""inbox_messages row -> the shape the #inbox Email tab renders."""
sender_name = _sender_name(message)
attachment_name = _attachment_name(message)
return { return {
"id": str(message.id), "id": str(message.id),
@ -47,3 +66,36 @@ def serialize_message(message: Inbox_Messages) -> dict:
"match_error": message.match_error, "match_error": message.match_error,
"matched_at": message.matched_at.isoformat() if message.matched_at else None, "matched_at": message.matched_at.isoformat() if message.matched_at else None,
} }
def serialize_application(message: Inbox_Messages) -> dict:
"""inbox_messages row -> the shape the #inbox All Applications tab renders.
`position` is the mail subject and `source` is the To address, which is where
the board tag (Rozee, Mustakbil, Employee Referral, ...) lands.
The tab also wants ats_score, phone, experience, recruiter, duplicate and a
processing state beyond read/unread. inbox_messages has no columns for any of
those, so they come back null instead of invented see the note in
inbox/file_decoder.py. `processing` is derived from message_read alone, so it
is only ever "Unread" or "Read"; Imported/Processed/Rejected need a column.
"""
return {
"id": str(message.id),
"name": _sender_name(message),
"email": message.message_from,
"position": message.message_subject,
"source": message.message_to,
"received": message.message_received_time,
"unread": not message.message_read,
"processing": "Read" if message.message_read else "Unread",
"resume_status": _RESUME_STATUS.get(message.match_status, "Pending"),
"attachment": _attachment_name(message),
"has_attachment": message.attachment,
"resume_text": message.resume_text,
"ats_score": None,
"phone": None,
"experience": None,
"recruiter": None,
"duplicate": None,
}

View File

@ -3,7 +3,7 @@ import httpx,os
from fastapi import HTTPException from fastapi import HTTPException
from inbox.models import Inbox_Messages from inbox.models import Inbox_Messages
from inbox.file_decoder import decode_attachment from inbox.file_decoder import decode_attachment
from inbox.serializers import serialize_message from inbox.serializers import serialize_application, serialize_message
from inbox.plugins import ( from inbox.plugins import (
EMAIL_API_TOKEN, EMAIL_API_TOKEN,
fetch_message_read_status, fetch_message_read_status,
@ -91,6 +91,16 @@ class Email:
item["files"]=files item["files"]=files
return item return item
async def get_all_applications(self,top,skip,search=None):
messages=await Inbox_Messages.get_inbox_messages(self.session,top,skip,search)
return [serialize_application(m) for m in messages]
async def get_application_by_id(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:
raise HTTPException(status_code=404,detail="Application not found")
return serialize_application(message)
async def queue_rematch(self,record_id): async def queue_rematch(self,record_id):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message: if not message:

View File

@ -11,6 +11,18 @@ export function listMessages() {
return request('/inbox/fetch') return request('/inbox/fetch')
} }
/**
* Persisted applications the shape the All Applications tab renders.
*
* 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 } = {}) {
return request('/inbox/all-applications', {
params: { search, top, skip, record_id: recordId },
})
}
/** Triggers the Graph proxy to pull new mail and persist it. */ /** Triggers the Graph proxy to pull new mail and persist it. */
export function syncMailbox({ token, top, skip } = {}) { export function syncMailbox({ token, top, skip } = {}) {
return request('/email/fetch', { params: { token, top, skip } }) return request('/email/fetch', { params: { token, top, skip } })

View File

@ -19,6 +19,7 @@ export const qk = {
mailbox: { mailbox: {
all: () => ['mailbox'], all: () => ['mailbox'],
messages: () => ['mailbox', 'messages'], messages: () => ['mailbox', 'messages'],
applications: (p = {}) => ['mailbox', 'applications', p],
}, },
// --- seed-backed buckets --- // --- seed-backed buckets ---

View File

@ -23,7 +23,8 @@ import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox' import * as inboxApi from '../api/inbox'
import { import {
atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob, atsRecommendationClass, avatarColor, companies, fmtDate, fmtShort, getJob,
initials as initialsOf, int, locations, pick, relTime, skillsPool, TODAY, initials as initialsOf, inboxSources, int, locations, pick, relTime, sourceMeta,
TODAY,
} from '../data/seed' } from '../data/seed'
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email'] const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
@ -51,22 +52,20 @@ function parseDate(value) {
return Number.isNaN(d.getTime()) ? null : d return Number.isNaN(d.getTime()) ? null : d
} }
function resumeText(i) { /**
return `${i.name.toUpperCase()} * `source` arrives as the raw To address, because that is where the board tag
${i.email} · ${i.phone} * lands careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
${'—'.repeat(30)} * everything but letters from both sides so "Employee Referral" still matches
PROFESSIONAL SUMMARY * "employee-referral@", and keep the brand colour SourceChip paints from.
${i.experience} years of experience. Applied for ${i.position} via ${i.source}. * Nothing matches -> show the first recipient verbatim rather than guess.
*/
EXPERIENCE function sourceFrom(messageTo) {
${pick(companies)} Senior role (2021Present) const raw = (messageTo || '').trim()
${pick(companies)} Associate (20182021) if (!raw) return { source: 'Unknown', sourceMeta: null }
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
EDUCATION const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
Bachelor's Degree, Computer Science if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
return { source: raw.split(',')[0].trim(), sourceMeta: null }
SKILLS
${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
} }
function SourceChip({ item }) { function SourceChip({ item }) {
@ -83,7 +82,7 @@ function SourceChip({ item }) {
export default function Inbox() { export default function Inbox() {
const { toast } = useToast() const { toast } = useToast()
const navigate = useNavigate() const navigate = useNavigate()
const { data: inbox = [] } = useQuery(seedQuery('inbox')) const qc = useQueryClient()
const { data: jobs = [] } = useQuery(seedQuery('jobs')) const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox') const updateInbox = useSeedMutation('inbox')
@ -96,6 +95,48 @@ export default function Inbox() {
const [assigning, setAssigning] = useState(null) const [assigning, setAssigning] = useState(null)
const [noting, setNoting] = useState(null) const [noting, setNoting] = useState(null)
/**
* GET /inbox/all-applications. 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 this tab is disabled until
* the endpoints exist. `processing` is derived from message_read alone, which
* is why the Imported / Processed / Rejected / Duplicates tabs read empty.
*/
const applicationsQuery = useQuery({
queryKey: qk.mailbox.applications(),
queryFn: async () => {
const res = await inboxApi.listApplications()
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => {
const name = row.name || row.email || 'Unknown'
return {
id: String(row.id),
name,
initials: initialsOf(name),
color: avatarColor(name),
email: row.email || '',
position: row.position || '(no subject)',
...sourceFrom(row.source),
received: parseDate(row.received),
unread: Boolean(row.unread),
processing: row.processing || 'Unread',
resumeStatus: row.resume_status || 'Pending',
attachment: row.attachment,
hasAttachment: Boolean(row.has_attachment),
resumeText: row.resume_text || '',
atsScore: row.ats_score,
phone: row.phone,
experience: row.experience,
recruiter: row.recruiter,
duplicate: Boolean(row.duplicate),
}
})
},
enabled: tab !== 'Email',
})
const inbox = applicationsQuery.data ?? []
const emailsQuery = useQuery({ const emailsQuery = useQuery({
queryKey: qk.mailbox.messages(), queryKey: qk.mailbox.messages(),
queryFn: async () => { queryFn: async () => {
@ -151,9 +192,20 @@ export default function Inbox() {
const selected = inbox.find((i) => i.id === selectedId) const selected = inbox.find((i) => i.id === selectedId)
// The one mutation this tab CAN persist. Note it needs INBOX_EDIT while the
// list only needs INBOX_VIEW, so a view-only user gets a 403 here.
const markApplicationRead = useMutation({
mutationFn: (recordId) => inboxApi.markRead(recordId),
onSuccess: () => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
},
onError: (err) => toast(friendlyAuthError(err, 'Could not mark application read.'), 'error'),
})
function select(id) { function select(id) {
setSelectedId(id) setSelectedId(id)
updateInbox((items) => items.map((i) => (i.id === id ? { ...i, unread: false } : i))) const item = inbox.find((i) => i.id === id)
if (item?.unread) markApplicationRead.mutate(id)
} }
function makeCandidate(item, job, cs) { function makeCandidate(item, job, cs) {
@ -250,7 +302,15 @@ export default function Inbox() {
</div> </div>
</div> </div>
<div> <div>
{list.length === 0 ? ( {applicationsQuery.isPending && (
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
)}
{applicationsQuery.isError && (
<EmptyState icon="inbox" title="Couldnt load applications">
{friendlyAuthError(applicationsQuery.error, 'Request failed')}
</EmptyState>
)}
{applicationsQuery.isSuccess && list.length === 0 ? (
<EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState> <EmptyState icon="inbox" title="Nothing here">No applications in this view.</EmptyState>
) : ( ) : (
list.map((i) => ( list.map((i) => (
@ -271,8 +331,15 @@ export default function Inbox() {
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div> <div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
</div> </div>
<div style={{ textAlign: 'right', flexShrink: 0 }}> <div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div> <div className="ii-time">
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div> {i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'}
</div>
{/* No ATS score exists server-side the agent returns a
verdict, not a number. The chip stays off rather than
rendering a placeholder that reads as a real score. */}
{i.atsScore != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
</div> </div>
</div> </div>
)) ))
@ -316,13 +383,17 @@ export default function Inbox() {
<button <button
className="btn btn-primary" className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }} onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
disabled
title="Needs a backend endpoint — not implemented yet"
> >
<Icon name="user-plus" /> Import Candidate <Icon name="user-plus" /> Import Candidate
</button> </button>
</> </>
} }
> >
<pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>{resumeText(previewing)}</pre> <pre className="resume-thumb" style={{ maxHeight: 'none', fontSize: 12 }}>
{previewing.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</Modal> </Modal>
)} )}
@ -363,9 +434,17 @@ export default function Inbox() {
) )
} }
/** Fields inbox_messages has no column for come back null; show a dash, not "null". */
function orDash(value, suffix = '') {
return value == null || value === '' ? '—' : `${value}${suffix}`
}
function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) { function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, onMove, onNote, onReject }) {
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match' const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)' const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
// Every action below writes to a table column or an endpoint that does not
// exist yet, so they are disabled rather than silently dropping the click.
const noBackend = 'Needs a backend endpoint — not implemented yet'
return ( return (
<div style={{ padding: 24 }}> <div style={{ padding: 24 }}>
@ -381,43 +460,72 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
</Badge> </Badge>
</div> </div>
</div> </div>
<div style={{ textAlign: 'center' }}> {i.atsScore != null && (
<div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}> <div style={{ textAlign: 'center' }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div> <div className="ats-ring" style={{ width: 84, height: 84, '--pct': i.atsScore, '--c': ringColor }}>
<div className="ats-val"><div className="ats-num" style={{ fontSize: 22 }}>{i.atsScore}</div></div>
</div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div> </div>
<div className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div> )}
</div>
</div> </div>
<div className="info-grid" style={{ marginBottom: 20 }}> <div className="info-grid" style={{ marginBottom: 20 }}>
<div className="info-item"><div className="il">Email</div><div className="iv">{i.email}</div></div> <div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{i.phone}</div></div> <div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Experience</div><div className="iv">{i.experience} years</div></div> <div className="info-item"><div className="il">Experience</div><div className="iv">{orDash(i.experience, ' years')}</div></div>
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{i.recruiter}</div></div> <div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
<div className="info-item"><div className="il">Received</div><div className="iv">{fmtDate(i.received)}</div></div>
<div className="info-item"> <div className="info-item">
<div className="il">Match</div> <div className="il">Received</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div> <div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
</div> </div>
{i.atsScore != null && (
<div className="info-item">
<div className="il">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
</div>
)}
</div> </div>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}> {i.hasAttachment && (
<div className="card-body"> <div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}> <div className="card-body">
<div className="fw-600"><Icon name="paperclip" /> {i.attachment}</div> <div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button> <div className="fw-600"><Icon name="paperclip" /> {orDash(i.attachment)}</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
{/* The real extracted PDF text (inbox_messages.resume_text), written
by the matching task. Empty until that task has run. */}
<pre className="resume-thumb">
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</div> </div>
<pre className="resume-thumb">{resumeText(i)}</pre>
</div> </div>
</div> )}
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}> <div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport}><Icon name="user-plus" /> Import Candidate</button> <button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
<button className="btn btn-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button> <Icon name="user-plus" /> Import Candidate
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button> </button>
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button> <button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button> <Icon name="sparkles" /> Parse Resume
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}> </button>
<button className="btn btn-secondary" onClick={onAssign} disabled title={noBackend}>
<Icon name="users" /> Assign Recruiter
</button>
<button className="btn btn-secondary" onClick={onMove} disabled title={noBackend}>
<Icon name="layers" /> Move to Pipeline
</button>
<button className="btn btn-secondary" onClick={onNote} disabled title={noBackend}>
<Icon name="edit" /> Add Note
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={onReject}
disabled
title={noBackend}
>
<Icon name="x" /> Reject <Icon name="x" /> Reject
</button> </button>
</div> </div>