frontend all aplications
parent
52b76bb1cf
commit
3ae716a8c4
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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 } })
|
||||||
|
|
|
||||||
|
|
@ -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 ---
|
||||||
|
|
|
||||||
|
|
@ -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 (2021–Present)
|
const raw = (messageTo || '').trim()
|
||||||
• ${pick(companies)} — Associate (2018–2021)
|
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="Couldn’t 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>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue