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.responses import JSONResponse
from fastapi import HTTPException
from db_setup import get_session
from sqlalchemy.ext.asyncio import AsyncSession
from inbox.views import Email
import uuid
from users.permissions import PermissionTag, require_permission
from dotenv import load_dotenv
load_dotenv()
@ -116,14 +114,22 @@ async def get_inbox_read_status(
@router.get("/inbox/all-applications")
async def get_all_applications(
app_id:uuid.UUID|int=Query(None),
current_user:dict=Depends(require_permission(PermissionTag.INBOX_VIEW)),
session:AsyncSession=Depends(get_session),
record_id: str | None = Query(None),
search: str | None = Query(None),
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:
service=Email(session=session)
data=await service.get_all_applications(app_id)
return JSONResponse(content={"data":data,"total":1,"status_code":200})
if record_id:
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:
raise
except Exception as e:

View File

@ -2,9 +2,19 @@ from pathlib import Path
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
full = message.full_email_response
if isinstance(full, dict):
@ -15,12 +25,21 @@ def serialize_message(message: Inbox_Messages) -> dict:
name = email_address.get("name")
if name:
sender_name = name
return sender_name
attachment_name = None
def _attachment_name(message: Inbox_Messages) -> str | None:
if message.file_name:
attachment_name = message.file_name.split(",")[0].strip() or None
elif message.file_path:
attachment_name = Path(message.file_path.split(",")[0].strip()).name or None
return message.file_name.split(",")[0].strip() or None
if message.file_path:
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 {
"id": str(message.id),
@ -47,3 +66,36 @@ def serialize_message(message: Inbox_Messages) -> dict:
"match_error": message.match_error,
"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 inbox.models import Inbox_Messages
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 (
EMAIL_API_TOKEN,
fetch_message_read_status,
@ -91,6 +91,16 @@ class Email:
item["files"]=files
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):
message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id)
if not message:

View File

@ -11,6 +11,18 @@ export function listMessages() {
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. */
export function syncMailbox({ token, top, skip } = {}) {
return request('/email/fetch', { params: { token, top, skip } })

View File

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

View File

@ -23,7 +23,8 @@ import { friendlyAuthError } from '../lib/errors'
import * as inboxApi from '../api/inbox'
import {
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'
const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Email']
@ -51,22 +52,20 @@ function parseDate(value) {
return Number.isNaN(d.getTime()) ? null : d
}
function resumeText(i) {
return `${i.name.toUpperCase()}
${i.email} · ${i.phone}
${'—'.repeat(30)}
PROFESSIONAL SUMMARY
${i.experience} years of experience. Applied for ${i.position} via ${i.source}.
EXPERIENCE
${pick(companies)} Senior role (2021Present)
${pick(companies)} Associate (20182021)
EDUCATION
Bachelor's Degree, Computer Science
SKILLS
${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}, ${pick(skillsPool)}`
/**
* `source` arrives as the raw To address, because that is where the board tag
* lands careers-rozee@, employee-referral@, mustakbil@ and so on. Strip
* everything but letters from both sides so "Employee Referral" still matches
* "employee-referral@", and keep the brand colour SourceChip paints from.
* Nothing matches -> show the first recipient verbatim rather than guess.
*/
function sourceFrom(messageTo) {
const raw = (messageTo || '').trim()
if (!raw) return { source: 'Unknown', sourceMeta: null }
const flat = raw.toLowerCase().replace(/[^a-z]/g, '')
const hit = inboxSources.find((s) => flat.includes(s.toLowerCase().replace(/[^a-z]/g, '')))
if (hit) return { source: hit, sourceMeta: sourceMeta[hit] }
return { source: raw.split(',')[0].trim(), sourceMeta: null }
}
function SourceChip({ item }) {
@ -83,7 +82,7 @@ function SourceChip({ item }) {
export default function Inbox() {
const { toast } = useToast()
const navigate = useNavigate()
const { data: inbox = [] } = useQuery(seedQuery('inbox'))
const qc = useQueryClient()
const { data: jobs = [] } = useQuery(seedQuery('jobs'))
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
@ -96,6 +95,48 @@ export default function Inbox() {
const [assigning, setAssigning] = 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({
queryKey: qk.mailbox.messages(),
queryFn: async () => {
@ -151,9 +192,20 @@ export default function Inbox() {
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) {
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) {
@ -250,7 +302,15 @@ export default function Inbox() {
</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>
) : (
list.map((i) => (
@ -271,8 +331,15 @@ export default function Inbox() {
<div className="ii-meta"><SourceChip item={i} /> <Badge>{i.processing}</Badge></div>
</div>
<div style={{ textAlign: 'right', flexShrink: 0 }}>
<div className="ii-time">{relTime(Math.round((NOW - i.received) / 60000))}</div>
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
<div className="ii-time">
{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>
))
@ -316,13 +383,17 @@ export default function Inbox() {
<button
className="btn btn-primary"
onClick={() => { const it = previewing; setPreviewing(null); importItem(it) }}
disabled
title="Needs a backend endpoint — not implemented yet"
>
<Icon name="user-plus" /> Import Candidate
</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>
)}
@ -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 }) {
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)'
// 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 (
<div style={{ padding: 24 }}>
@ -381,43 +460,72 @@ function ApplicationDetail({ item: i, onPreview, onImport, onParse, onAssign, on
</Badge>
</div>
</div>
<div style={{ textAlign: 'center' }}>
<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>
{i.atsScore != null && (
<div style={{ textAlign: 'center' }}>
<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 className="cell-sub" style={{ marginTop: 4 }}>ATS Score</div>
</div>
)}
</div>
<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">Phone</div><div className="iv">{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">Assigned Recruiter</div><div className="iv">{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="il">Email</div><div className="iv">{orDash(i.email)}</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">{orDash(i.experience, ' years')}</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">Match</div>
<div className="iv"><Badge className={atsRecommendationClass(recLabel)}>{recLabel}</Badge></div>
<div className="il">Received</div>
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</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 className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div className="fw-600"><Icon name="paperclip" /> {i.attachment}</div>
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
{i.hasAttachment && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<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>
<pre className="resume-thumb">{resumeText(i)}</pre>
</div>
</div>
)}
<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-secondary" onClick={onParse}><Icon name="sparkles" /> Parse Resume</button>
<button className="btn btn-secondary" onClick={onAssign}><Icon name="users" /> Assign Recruiter</button>
<button className="btn btn-secondary" onClick={onMove}><Icon name="layers" /> Move to Pipeline</button>
<button className="btn btn-secondary" onClick={onNote}><Icon name="edit" /> Add Note</button>
<button className="btn btn-ghost" style={{ color: 'var(--danger)' }} onClick={onReject}>
<button className="btn btn-primary" onClick={onImport} disabled title={noBackend}>
<Icon name="user-plus" /> Import Candidate
</button>
<button className="btn btn-secondary" onClick={onParse} disabled title={noBackend}>
<Icon name="sparkles" /> Parse Resume
</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
</button>
</div>