From c106aad56742d454490bacd4c4feebfcd34f8d50 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 19 Aug 2026 18:14:26 +0500 Subject: [PATCH 1/7] ui cahnges --- backend/job/candidate/models.py | 2 +- frontend/src/api/pipeline.js | 18 +-- frontend/src/app/ai/replies.jsx | 2 +- frontend/src/data/seed.js | 4 +- frontend/src/lib/useApplications.js | 2 +- frontend/src/screens/Inbox.jsx | 224 +--------------------------- frontend/src/screens/Pipeline.jsx | 2 +- frontend/src/screens/Reports.jsx | 4 +- frontend/src/screens/TalentPool.jsx | 6 +- frontend/src/styles/styles.css | 2 +- frontend/src/ui/Tabs.jsx | 3 +- frontend/src/ui/primitives.jsx | 2 +- 12 files changed, 30 insertions(+), 241 deletions(-) diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index e23acaa..b3e4e89 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -42,7 +42,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True): platform: str = Field(default="") created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id") experience: str = Field(default="") - # Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Applied. + # Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Shortlist. status: str = Field(default="") # Free text, not a users FK: a referrer is often someone outside the system referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""}) diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index 9cd7775..4d30c80 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -17,15 +17,15 @@ import { request } from '../lib/apiClient' * * The enum has 11 values and the board 7 columns, so this is deliberately * many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere) - * and reads as Applied rather than as an outcome, ONHOLD parks in Screening, and + * and reads as Shortlist rather than as an outcome, ONHOLD parks in Screening, and * APPROVED is the pre-HIRED spelling of a hire. * - * Anything unmapped falls through to Applied rather than vanishing from the + * Anything unmapped falls through to Shortlist rather than vanishing from the * board — a card with no column is a candidate nobody sees. */ export const STAGE_FROM_STATUS = { - PENDING: 'Applied', - CLOSED: 'Applied', + PENDING: 'Shortlist', + CLOSED: 'Shortlist', PROCESS: 'Screening', ONHOLD: 'Screening', SCREENING: 'Screening', @@ -41,10 +41,10 @@ export const STAGE_FROM_STATUS = { * Column -> the status WRITTEN on a drop. Not the inverse of the map above: the * legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are * never written, so the vocabulary converges on the canonical value as cards get - * moved. Applied writes PENDING because the enum has no APPLIED member. + * moved. Shortlist writes PENDING because the enum has no SHORTLIST member. */ export const STATUS_FROM_STAGE = { - Applied: 'PENDING', + Shortlist: 'PENDING', Screening: 'SCREENING', Assessment: 'ASSESSMENT', Interview: 'INTERVIEW', @@ -85,12 +85,12 @@ export function listApplications({ jobId, limit, offset } = {}) { /** * Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN) - * land in Applied, same as STAGE_FROM_STATUS's card fallback. + * land in Shortlist, same as STAGE_FROM_STATUS's card fallback. */ export function toStageCounts(byStatus) { const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0])) for (const [status, n] of Object.entries(byStatus || {})) { - const stage = STAGE_FROM_STATUS[status] ?? 'Applied' + const stage = STAGE_FROM_STATUS[status] ?? 'Shortlist' counts[stage] = (counts[stage] ?? 0) + (n || 0) } return counts @@ -179,7 +179,7 @@ export function toBoardCard(row, kind = 'inbox') { userId: row.user_id ?? null, name: row.name || row.email || 'Unknown', email: row.email ?? null, - stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied', + stage: STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist', status: row.application_status ?? null, experience: row.experience || null, aiScore: row.ats_result?.overall_score ?? null, diff --git a/frontend/src/app/ai/replies.jsx b/frontend/src/app/ai/replies.jsx index b2c6923..d3534ba 100644 --- a/frontend/src/app/ai/replies.jsx +++ b/frontend/src/app/ai/replies.jsx @@ -137,7 +137,7 @@ export function reply(prompt, { candidates, recruiters }) {

Pipeline health analysis

diff --git a/frontend/src/data/seed.js b/frontend/src/data/seed.js index 4619c24..1a976ae 100644 --- a/frontend/src/data/seed.js +++ b/frontend/src/data/seed.js @@ -33,7 +33,7 @@ export const TODAY = new Date('2026-07-09T09:00:00'); const grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7']; const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft']; const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"]; - const stages = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected']; + const stages = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected']; const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList']; const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare']; @@ -117,7 +117,7 @@ export const TODAY = new Date('2026-07-09T09:00:00'); // ---------- Candidates ---------- const openJobs = jobs.filter(j => j.status === 'Open'); const candidates = []; - const stageWeights = ['Applied', 'Applied', 'Applied', 'Screening', 'Screening', 'Assessment', 'Interview', 'Interview', 'Offer', 'Hired', 'Rejected', 'Rejected']; + const stageWeights = ['Shortlist', 'Shortlist', 'Shortlist', 'Screening', 'Screening', 'Assessment', 'Interview', 'Interview', 'Offer', 'Hired', 'Rejected', 'Rejected']; for (let i = 0; i < 100; i++) { const name = fullName(); const job = pick(openJobs.length ? openJobs : jobs); diff --git a/frontend/src/lib/useApplications.js b/frontend/src/lib/useApplications.js index 2e084f3..57fd882 100644 --- a/frontend/src/lib/useApplications.js +++ b/frontend/src/lib/useApplications.js @@ -35,7 +35,7 @@ export function useApplications() { email: row.email ?? null, jobTitle: row.title ?? null, jobPostId: row.assigned_job_post_id ?? null, - stage: pipelineApi.STAGE_FROM_STATUS[row.application_status] ?? 'Applied', + stage: pipelineApi.STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist', })) }, }) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 573ee47..f8368ac 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1,5 +1,5 @@ /* ============================================================ - Recruitment Inbox — six seed-backed tabs plus the Email tab, which is the + Recruitment Inbox — application tabs plus the Email tab, which is the app's oldest real network call (GET /inbox/fetch, previously the only fetch in the entire prototype). @@ -27,60 +27,23 @@ import { inboxSources, relTime, sourceMeta, } from '../data/seed' -const TABS = ['All Applications', 'Unread', 'Imported', 'Processed', 'Rejected', 'Duplicates', 'Filtered Out', 'Email'] +const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates', 'Email'] /** * Tabs that do not read /inbox/all-applications at all. Email reads - * /inbox/fetch; Filtered Out reads /inbox/triage, whose rows are the mail that - * never became an inbox row and so cannot appear in the applications list. + * /inbox/fetch; every other tab is a view over the applications list. */ -const SPECIAL_TABS = new Set(['Email', 'Filtered Out']) +const SPECIAL_TABS = new Set(['Email']) /** * Server-side filters for tabs /inbox/all-applications can narrow. - * Imported / Processed / Rejected / Duplicates filter client-side on + * Processed / Rejected / Duplicates filter client-side on * processing_state + is_duplicate (see GET /inbox/counts). */ const TAB_FILTERS = { Unread: { isread: false }, } -/** - * reason_code -> the chip the Filtered Out tab paints. Mirrors - * Triage_Reason_Code in backend/inbox_classifier/enums.py; an unknown code - * falls through to the raw string rather than rendering blank, so adding a - * label server-side degrades visibly instead of silently. - */ -const TRIAGE_REASONS = { - job_application: { label: 'Job application', cls: 'b-green' }, - recruiter_or_vendor: { label: 'Recruiter / vendor', cls: 'b-amber' }, - newsletter_or_marketing: { label: 'Newsletter', cls: 'b-gray' }, - internal_or_scheduling: { label: 'Internal', cls: 'b-blue' }, - automated_notification: { label: 'Automated', cls: 'b-gray' }, - other: { label: 'Other', cls: 'b-gray' }, -} - -/** - * `unclassified:` is what should_ingest stamps when the model could not be - * consulted at all — no API key, a timeout, a refusal. Those rows were ingested - * anyway (INBOX_TRIAGE_FAIL_OPEN defaults true), so they are a provider-health - * signal, not a filtering decision, and get their own chip. - */ -function triageReason(code) { - const raw = code || '' - if (raw.startsWith('unclassified:')) { - return { label: `Unclassified · ${raw.slice('unclassified:'.length)}`, cls: 'b-red' } - } - return TRIAGE_REASONS[raw] || { label: raw || 'Unknown', cls: 'b-gray' } -} - -/** The three views over the ledger. `undefined` sends no is_application param. */ -const TRIAGE_VIEWS = { - 'Filtered out': false, - Kept: true, - All: undefined, -} - /** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */ const NOW = new Date('2026-07-09T20:00') @@ -332,57 +295,21 @@ export default function Inbox() { enabled: tab === 'Email', }) - // Hoisted like emailsQuery so `total` can badge the tab. Default view is the - // rejections: the mail the gate kept is already visible in every other tab. - const [triageView, setTriageView] = useState('Filtered out') - const triageFilter = { isApplication: TRIAGE_VIEWS[triageView] } - - const triageQuery = useQuery({ - queryKey: qk.mailbox.triage(triageFilter), - queryFn: async () => { - const res = await inboxApi.listTriage(triageFilter) - const rows = Array.isArray(res?.data) ? res.data : [] - return { - total: res?.total ?? rows.length, - rows: rows.map((row) => ({ - id: String(row.id), - messageId: row.message_id || '', - isApplication: Boolean(row.is_application), - reason: triageReason(row.reason_code), - confidence: typeof row.confidence === 'number' ? row.confidence : null, - evidence: row.evidence || '', - status: row.status || '', - from: row.fromEmail || 'Unknown', - subject: row.subject || '(no subject)', - when: parseDate(row.when), - attachment: row.attachment || '', - hasAttachment: Boolean(row.has_attachment), - ingested: Boolean(row.ingested), - overriddenAt: parseDate(row.overridden_at), - })), - } - }, - enabled: tab === 'Filtered Out', - }) - const counts = useMemo( () => ({ 'All Applications': serverCounts.all ?? 0, Unread: serverCounts.unread ?? 0, - Imported: serverCounts.imported ?? 0, Processed: serverCounts.processed ?? 0, Rejected: serverCounts.rejected ?? 0, Duplicates: serverCounts.duplicates ?? 0, - 'Filtered Out': triageQuery.data?.total ?? 0, Email: (emailsQuery.data ?? []).filter((e) => e.unread).length, }), - [serverCounts, emailsQuery.data, triageQuery.data], + [serverCounts, emailsQuery.data], ) const list = useMemo(() => { let l = inbox 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 === 'Duplicates') l = l.filter((i) => i.duplicate) @@ -440,10 +367,6 @@ export default function Inbox() { setState.mutate({ id: item.id, state: 'imported', name: item.name }) } - function parseResume() { - toast('Resume parsing runs via the matching agent — use Assign Job / Rematch.', 'info') - } - function moveToPipeline(item) { setState.mutate({ id: item.id, state: 'processed', name: item.name }) } @@ -491,13 +414,6 @@ export default function Inbox() { {tab === 'Email' ? ( - ) : tab === 'Filtered Out' ? ( - ) : (
@@ -578,7 +494,6 @@ export default function Inbox() { busy={setState.isPending || markDuplicate.isPending} onPreview={() => setPreviewing(selected)} onImport={() => importItem(selected)} - onParse={() => parseResume()} onMove={() => moveToPipeline(selected)} onNote={() => setNoting(selected)} onReject={() => reject(selected)} @@ -657,7 +572,7 @@ function orDash(value, suffix = '') { } function ApplicationDetail({ - item: i, loading, busy, onPreview, onImport, onParse, onMove, onNote, onReject, onToggleDuplicate, + item: i, loading, busy, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate, }) { const navigate = useNavigate() const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match' @@ -751,9 +666,6 @@ function ApplicationDetail({ - - ))} -
-
- -
- {query.isPending && ( - Fetching the intake ledger. - )} - {query.isError && ( - - {friendlyAuthError(query.error, 'Request failed')} - - )} - {query.isSuccess && rows.length === 0 && ( - - Every message the gate has seen was judged a job application. - - )} - {query.isSuccess && rows.map((r) => ( -
- -
-
{r.subject}
-
{r.from}
-
- {r.reason.label} - {r.confidence != null && ( - {Math.round(r.confidence * 100)}% confident - )} - {r.hasAttachment && ( - - {r.attachment || 'attachment'} - - )} - {r.ingested && Kept} - {r.overriddenAt && Overridden} -
- {r.evidence && ( -
{r.evidence}
- )} -
-
- {r.when ? fmtShort(r.when) : '—'} - {r.isApplication ? ( - - ) : ( - - )} -
-
- ))} -
- - ) -} - /** The live tab: real fetch, real loading state, real error state. */ function EmailTab({ query, toast }) { const qc = useQueryClient() diff --git a/frontend/src/screens/Pipeline.jsx b/frontend/src/screens/Pipeline.jsx index 87954ad..0e0cff9 100644 --- a/frontend/src/screens/Pipeline.jsx +++ b/frontend/src/screens/Pipeline.jsx @@ -28,7 +28,7 @@ import * as pipelineApi from '../api/pipeline' /* Stage colours reference CSS tokens so the board re-tints with the theme. */ export const KANBAN_STAGES = [ - { name: 'Applied', color: 'var(--stage-1)' }, + { name: 'Shortlist', color: 'var(--stage-1)' }, { name: 'Screening', color: 'var(--stage-2)' }, { name: 'Assessment', color: 'var(--stage-3)' }, { name: 'Interview', color: 'var(--stage-4)' }, diff --git a/frontend/src/screens/Reports.jsx b/frontend/src/screens/Reports.jsx index 333dbf2..82d25d5 100644 --- a/frontend/src/screens/Reports.jsx +++ b/frontend/src/screens/Reports.jsx @@ -45,8 +45,8 @@ const RANGES = [ /* Order matters: "reached" is a running sum from the end of this list back to the start. REJECTED is deliberately absent — see the header note. */ const FUNNEL_ORDER = [ - { key: 'PENDING', label: 'Applied' }, - { key: 'CLOSED', label: 'Applied' }, + { key: 'PENDING', label: 'Shortlist' }, + { key: 'CLOSED', label: 'Shortlist' }, { key: 'SCREENING', label: 'Screened' }, { key: 'PROCESS', label: 'Screened' }, { key: 'ONHOLD', label: 'Screened' }, diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index dee2bcb..18f8a03 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -47,15 +47,15 @@ import { avatarColor, departments, initials as initialsOf } from '../data/seed' /** The seed bucket holds 100 candidates; one template per person, no reuse. */ const FETCH_LIMIT = 100 -const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] +const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] /** * Candidate_application_Status (backend/inbox/enums.py) -> the seed stage * vocabulary every screen renders. CLOSED is the column default, i.e. untriaged, - * so it reads as Applied rather than as an outcome. + * so it reads as Shortlist rather than as an outcome. */ const STAGE_FROM_STATUS = { - PENDING: 'Applied', CLOSED: 'Applied', PROCESS: 'Screening', + PENDING: 'Shortlist', CLOSED: 'Shortlist', PROCESS: 'Screening', ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected', } diff --git a/frontend/src/styles/styles.css b/frontend/src/styles/styles.css index 505fe1b..63c04c9 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -697,7 +697,7 @@ canvas { width: 100%; max-width: 100%; display: block; } /* ================= TABS ================= */ .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 22px; overflow-x: auto; } -.tab { padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; } +.tab { display: inline-flex; align-items: center; gap: 12px; padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; } .tab:hover { color: var(--text); } .tab.active { color: var(--primary); border-bottom-color: var(--primary); } .tab-pane { display: none; animation: fadeUp .25s; } diff --git a/frontend/src/ui/Tabs.jsx b/frontend/src/ui/Tabs.jsx index 8f507be..e4a2283 100644 --- a/frontend/src/ui/Tabs.jsx +++ b/frontend/src/ui/Tabs.jsx @@ -27,8 +27,7 @@ export function Tabs({ tabs, value, onChange, className = 'tabs' }) { className={`tab${active ? ' active' : ''}`} onClick={() => onChange(key)} > - {label} - {t.count != null && {t.count}} + {label}{t.count != null && {t.count}} ) })} diff --git a/frontend/src/ui/primitives.jsx b/frontend/src/ui/primitives.jsx index 51b0d9c..d29d629 100644 --- a/frontend/src/ui/primitives.jsx +++ b/frontend/src/ui/primitives.jsx @@ -34,7 +34,7 @@ export function AvatarStack({ names = [], max = 3 }) { // The 30-entry status -> class map from js/ui.js:73-81, verbatim. export const STATUS_CLASS = { Open: 'b-green', Closed: 'b-gray', 'On Hold': 'b-amber', Draft: 'b-blue', - Applied: 'b-blue', Screening: 'b-purple', Assessment: 'b-amber', Interview: 'b-indigo', + Shortlist: 'b-blue', Screening: 'b-purple', Assessment: 'b-amber', Interview: 'b-indigo', Offer: 'b-teal', Hired: 'b-green', Rejected: 'b-red', Scheduled: 'b-blue', Completed: 'b-green', Cancelled: 'b-red', 'No Show': 'b-amber', Sent: 'b-blue', Accepted: 'b-green', Negotiating: 'b-amber', Declined: 'b-red', Expired: 'b-gray', -- 2.40.1 From ee2f0c8ef8ec36953b1771e1f3cc789fe3b49a48 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 19 Aug 2026 18:46:24 +0500 Subject: [PATCH 2/7] . --- backend/inbox/app.py | 70 ++- backend/inbox/models.py | 110 ++++- backend/inbox/views.py | 45 +- frontend/src/api/inbox.js | 54 ++- frontend/src/screens/Inbox.jsx | 733 ++++++++++++++++++++++++++--- frontend/src/screens/Matching.jsx | 133 +----- frontend/src/ui/SuggestedRoles.jsx | 147 ++++++ 7 files changed, 1065 insertions(+), 227 deletions(-) create mode 100644 frontend/src/ui/SuggestedRoles.jsx diff --git a/backend/inbox/app.py b/backend/inbox/app.py index e4953a0..df08030 100644 --- a/backend/inbox/app.py +++ b/backend/inbox/app.py @@ -25,6 +25,30 @@ class DuplicateBody(BaseModel): is_duplicate: bool +class ReadBody(BaseModel): + read: bool = True + + +class BulkReadBody(BaseModel): + record_ids: list[str] + read: bool = True + + +class ReadAllBody(BaseModel): + """The caller's CURRENT list filter, echoed back so the update narrows the same way. + + Every field defaults to the same "no filter" value the list endpoint uses, so an + empty body means "the All Applications tab" — exactly what GET + /inbox/all-applications returns with no query params. + """ + + read: bool = True + search: str | None = None + isread: bool = True + application_status: Candidate_application_Status = Candidate_application_Status.CLOSED + assigned: bool | None = None + + class TriageOverrideBody(BaseModel): is_application: bool @@ -145,12 +169,15 @@ async def assign_job_post( @router.post("/inbox/{record_id}/read") async def mark_inbox_read( record_id: str, + payload: ReadBody | None = None, current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), session: AsyncSession = Depends(get_session), ): + """Flip one row. The body is OPTIONAL and defaults to read=true, so the original + bodyless POST this route shipped with keeps working unchanged.""" try: service=Email(session=session) - data=await service.mark_read(record_id) + data=await service.mark_read(record_id,payload.read if payload else True) return JSONResponse(content={"data":data,"total":1,"status_code":200}) except HTTPException: raise @@ -158,6 +185,47 @@ async def mark_inbox_read( raise HTTPException(status_code=500,detail=str(e)) +@router.patch("/inbox/read") +async def bulk_mark_inbox_read( + payload: BulkReadBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + """Selected rows -> read/unread. Single segment after /inbox, so it never collides + with the two-segment /inbox/{record_id}/read above.""" + try: + service=Email(session=session) + data=await service.set_read_bulk(payload.record_ids,payload.read) + return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + +@router.patch("/inbox/read-all") +async def mark_all_inbox_read( + payload: ReadAllBody, + current_user: dict = Depends(require_permission(PermissionTag.INBOX_EDIT)), + session: AsyncSession = Depends(get_session), +): + """Every row matching the caller's current list filter -> read/unread.""" + try: + service=Email(session=session) + data=await service.set_read_all( + payload.read, + search=payload.search, + isread=payload.isread, + application_status=payload.application_status, + assigned=payload.assigned, + ) + return JSONResponse(content={"data":data,"total":data["updated"],"status_code":200}) + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500,detail=str(e)) + + @router.get("/inbox/{record_id}/read-status") async def get_inbox_read_status( record_id: str, diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 44d2832..ea1d579 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -322,6 +322,11 @@ class Inbox_Messages(SQLModel, table=True): message_cc: str | None = Field(default=None) message_bcc: str | None = Field(default=None) message_read: bool = Field(default=False) + # Stamped whenever a human flips read state from the app (single row, bulk, or + # whole view). apply_read_status skips these rows: nothing pushes local state + # back to Outlook, so without the stamp the every-minute sync_read_status sweep + # would silently re-read a mail the recruiter deliberately marked unread. + read_overridden_at: datetime | None = Field(default=None, sa_type=DateTime(timezone=True)) attachment: bool = Field(default=False) message_reply: str | None = Field(default=None) file_name: str | None = Field(default=None) @@ -568,29 +573,44 @@ class Inbox_Messages(SQLModel, table=True): ) @classmethod - async def get_inbox_messages( - cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None + def _apply_filters( + cls, statement, search: str | None=None, isread: bool=True, + application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, + assigned: bool | None=None, ): - statement = select(cls).order_by(cls.message_received_time.desc()) + """The one WHERE chain shared by the list, the count and the bulk read UPDATE. + + Works on a Select or an Update — both expose .where() — which is the whole + point: "mark all read in this view" must narrow on exactly the predicates the + list narrowed on. A scope filter that drifts from the list filter silently + touches rows the user never saw, and there is no undo for that. + """ 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 assigned is True: statement = statement.where(cls.assigned_job_post_id.is_not(None)) elif assigned is False: statement = statement.where(cls.assigned_job_post_id.is_(None)) + if isread==False: + statement = statement.where(cls.message_read==False) + return statement + @classmethod + async def get_inbox_messages( + cls, session: AsyncSession, top: int | None, skip: int, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None + ): + statement = cls._apply_filters( + select(cls).order_by(cls.message_received_time.desc()), + search, isread, application_status, assigned, + ) if skip: statement = statement.offset(skip) if top is not None: statement = statement.limit(top) - if isread==False: - statement = statement.where(cls.message_read==False) result = await session.execute(statement) return result.scalars().all() @@ -635,17 +655,10 @@ class Inbox_Messages(SQLModel, table=True): @classmethod async def count_inbox_messages(cls, session: AsyncSession, search: str | None, isread: bool=True, application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, assigned: bool | None=None): - 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 assigned is True: - statement = statement.where(cls.assigned_job_post_id.is_not(None)) - elif assigned is False: - statement = statement.where(cls.assigned_job_post_id.is_(None)) - if isread==False: - statement = statement.where(cls.message_read==False) + statement = cls._apply_filters( + select(func.count()).select_from(cls), + search, isread, application_status, assigned, + ) result = await session.execute(statement) return result.scalar_one() @@ -659,6 +672,12 @@ class Inbox_Messages(SQLModel, table=True): every-minute sync_read_status sweep would otherwise revert a mail the user just opened. Cost of the latch: un-reading a mail in Outlook no longer propagates here. + + Rows with read_overridden_at set are excluded outright. The latch alone is not + enough once the UI can mark UNREAD: a mail that is read in Outlook keeps being + reported isRead=true, so the next sweep would undo the recruiter's click within + the minute. A human decision on this row wins permanently; the only rows + excluded are ones somebody already decided about. """ if not changes: return 0 @@ -666,7 +685,9 @@ class Inbox_Messages(SQLModel, table=True): if not read_ids: return 0 result=await session.execute( - update(cls).where(cls.message_id.in_(read_ids)).values(message_read=True) + update(cls) + .where(cls.message_id.in_(read_ids),cls.read_overridden_at.is_(None)) + .values(message_read=True) ) await session.commit() return result.rowcount or 0 @@ -698,16 +719,63 @@ class Inbox_Messages(SQLModel, table=True): return {row for (row,) in result.all() if row} @classmethod - async def mark_message_read(cls, session: AsyncSession, record_id): + async def mark_message_read(cls, session: AsyncSession, record_id, read: bool=True): row=await cls.get_inbox_message_by_id(session,record_id) if not row: return None - row.message_read=True + row.message_read=bool(read) + row.read_overridden_at=_now() session.add(row) await session.commit() await session.refresh(row) return row + @classmethod + async def set_read_bulk(cls, session: AsyncSession, record_ids, read: bool) -> int: + """Flip read state for an explicit id list in ONE statement. Returns rows matched. + + Unparseable ids are dropped rather than raising: a stale row id in a selection + must not sink the other 49 the recruiter ticked. The caller compares `updated` + against `requested` to notice. + + No `message_read != read` predicate here — the caller wants to know how many of + its ids actually EXIST, which is what rowcount reports without it. + """ + uids=[] + for raw in record_ids or []: + try: + uids.append(uuid.UUID(str(raw))) + except (AttributeError, TypeError, ValueError): + continue + if not uids: + return 0 + result=await session.execute( + update(cls).where(cls.id.in_(uids)).values(message_read=bool(read),read_overridden_at=_now()) + ) + await session.commit() + return result.rowcount or 0 + + @classmethod + async def set_read_scope( + cls, session: AsyncSession, read: bool, search: str | None=None, isread: bool=True, + application_status: Candidate_application_Status=Candidate_application_Status.CLOSED, + assigned: bool | None=None, + ) -> int: + """Mark every row matching a list filter. Returns rows actually CHANGED. + + The extra `message_read != read` predicate is what makes the count honest: the + recruiter is told "12 marked read", not "1,240 rows touched" on a mailbox that + was already read. It also keeps read_overridden_at off rows nobody decided + anything about, so the Outlook sweep keeps its reach over untouched mail. + """ + statement=cls._apply_filters(update(cls),search,isread,application_status,assigned) + statement=statement.where(cls.message_read!=bool(read)) + result=await session.execute( + statement.values(message_read=bool(read),read_overridden_at=_now()) + ) + await session.commit() + return result.rowcount or 0 + @classmethod async def count_processing(cls, session: AsyncSession): statement = select( diff --git a/backend/inbox/views.py b/backend/inbox/views.py index 822ba6d..3a5bce8 100644 --- a/backend/inbox/views.py +++ b/backend/inbox/views.py @@ -32,6 +32,10 @@ from datetime import datetime,timezone logger=logging.getLogger("inbox.match") triage_logger=logging.getLogger("inbox.triage") +# One statement, one round trip — but an unbounded id list is still a client-supplied +# IN () of arbitrary size, so the batch is capped and the route answers 413. +MAX_BULK_READ_IDS=500 + class Email: def __init__(self,session:AsyncSession,token=None): @@ -349,12 +353,49 @@ class Email: logger.warning("could not queue ats score for %s: %s",record_id,exc) return await self.get_inbox_message_by_id(record_id) - async def mark_read(self,record_id): - message=await Inbox_Messages.mark_message_read(self.session,record_id) + async def mark_read(self,record_id,read=True): + message=await Inbox_Messages.mark_message_read(self.session,record_id,read) if not message: raise HTTPException(status_code=404,detail="Message not found") return serialize_message(message) + async def set_read_bulk(self,record_ids,read): + """Flip read state for a hand-picked selection. + + Returns counts, never rows: a 500-id selection would otherwise serialize 500 + full messages back at a client that only needs to know it worked. + + `updated` < `requested` means some ids no longer exist — a stale selection + against a list that moved. That is reported, not raised, because the rows that + DID exist were already committed. + """ + ids=[str(r).strip() for r in (record_ids or []) if str(r or "").strip()] + if not ids: + raise HTTPException(status_code=422,detail="record_ids must contain at least one id") + if len(ids)>MAX_BULK_READ_IDS: + raise HTTPException(status_code=413, + detail=f"At most {MAX_BULK_READ_IDS} ids per request") + updated=await Inbox_Messages.set_read_bulk(self.session,ids,read) + logger.info("bulk read: requested=%s updated=%s read=%s",len(ids),updated,bool(read)) + return {"requested":len(ids),"updated":updated,"read":bool(read)} + + async def set_read_all(self,read,search=None,isread:bool=True, + application_status:Candidate_application_Status=Candidate_application_Status.CLOSED, + assigned=None): + """Mark every row the SAME filter set would have listed. + + The filter arguments are the caller's current view, not a free-form query: the + button says "mark all read in this view" and the WHERE chain is literally the + list's own (Inbox_Messages._apply_filters), so the two cannot drift. + """ + updated=await Inbox_Messages.set_read_scope( + self.session,read,search=search,isread=isread, + application_status=application_status,assigned=assigned, + ) + logger.info("scope read: updated=%s read=%s isread=%s assigned=%s status=%s", + updated,bool(read),isread,assigned,getattr(application_status,"value",application_status)) + return {"updated":updated,"read":bool(read)} + async def refresh_read_status(self,record_id): message=await Inbox_Messages.get_inbox_message_by_id(self.session,record_id) if not message: diff --git a/frontend/src/api/inbox.js b/frontend/src/api/inbox.js index 1e0e414..7366f23 100644 --- a/frontend/src/api/inbox.js +++ b/frontend/src/api/inbox.js @@ -56,9 +56,57 @@ export function syncMailbox({ token, top, skip } = {}) { return request('/email/fetch', { params: { token, top, skip } }) } -/** Marks one persisted inbox row read (local DB only). */ -export function markRead(recordId) { - return request(`/inbox/${recordId}/read`, { method: 'POST' }) +/** + * Flips one persisted inbox row read/unread (local DB only — nothing is pushed + * back to Outlook). The body is optional server-side and defaults to read=true. + */ +export function markRead(recordId, read = true) { + return request(`/inbox/${recordId}/read`, { method: 'POST', body: { read } }) +} + +/** + * Flips a hand-picked selection in one statement. Requires inbox.edit. + * + * Capped at 500 ids server-side (MAX_BULK_READ_IDS in backend/inbox/views.py), + * which answers 413 — callers with a longer list chunk it. + * + * Resolves to `{requested, updated, read}`. `updated < requested` means some ids + * no longer exist, not that the call failed: the rows that did exist committed. + */ +export function bulkSetRead(recordIds, read) { + return request('/inbox/read', { + method: 'PATCH', + body: { record_ids: recordIds, read }, + }) +} + +/** + * Flips EVERY row matching a list filter — the "mark all in this view" button. + * + * The filter params are deliberately the same ones listApplications takes, and + * the server runs them through the same WHERE builder the list uses + * (Inbox_Messages._apply_filters). Omit them all and the scope is the whole + * mailbox, which is exactly what the All Applications tab shows. + * + * `search` is NOT the Inbox screen's search box: that filters client-side on + * name/position/source, while the server matches subject/from/body. Passing one + * for the other would mark rows the user never saw — the screen sends the + * visible ids to bulkSetRead instead whenever its search box is non-empty. + * + * Resolves to `{updated, read}`, where `updated` counts rows that actually + * CHANGED state, so it is safe to show in a toast. + */ +export function setReadAll({ read, search, isread, applicationStatus, assigned } = {}) { + return request('/inbox/read-all', { + method: 'PATCH', + body: { + read, + search, + isread, + application_status: applicationStatus, + assigned, + }, + }) } /** Assign (or clear with null) the job post for one application. Requires inbox.edit. */ diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index f8368ac..22d626b 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -9,7 +9,7 @@ now, which is the structural fix. ============================================================ */ -import { useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -17,7 +17,9 @@ import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' +import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { useToast } from '../ui/Toast' +import { useAuth } from '../auth/AuthContext' import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' @@ -44,6 +46,17 @@ const TAB_FILTERS = { Unread: { isread: false }, } +/** + * Tabs whose visible set is EXACTLY what the server returns for TAB_FILTERS[tab]. + * + * Only these may use the scope endpoint for "mark all". Processed / Rejected / + * Duplicates narrow client-side over rows the server already handed back in full, + * so a scope call from one of those carries no such predicate and would mark the + * entire mailbox — rows the user never saw, with no undo. Those tabs send the + * visible ids instead. + */ +const SERVER_SCOPED_TABS = new Set(['All Applications', 'Unread']) + /** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */ const NOW = new Date('2026-07-09T20:00') @@ -114,14 +127,15 @@ const RESUME_STATUS = { failed: 'Failed', dlq: 'Failed', skipped: 'Pending', } +const SHORTLIST_JOB_WARNING = 'Choose one of the suggested jobs above to add this candidate to the shortlist.' + /** * GET /inbox/fetch?record_id= -> the detail behind one application row. * * Returns serialize_message, a different shape from serialize_application, so it * is remapped onto the row shape here and OVERLAID on the list row rather than - * replacing it: serialize_message carries the body and the real decoded - * attachments, but omits resume_text, so the list row keeps supplying that. - * suggested_job_post_ids is dropped, same as everywhere else on this page. + * replacing it: serialize_message carries the body, decoded attachments, and + * the hydrated suggested_job_posts / assigned_job_post the role picker needs. */ async function fetchMessageDetail(recordId) { const res = await inboxApi.getMessage(recordId) @@ -154,6 +168,11 @@ async function fetchMessageDetail(recordId) { matchReasoning: row.match_reasoning || '', matchError: row.match_error || '', matchedAt: parseDate(row.matched_at), + resumeText: row.resume_text || '', + suggestedIds: (row.suggested_job_post_ids || []).map(String), + suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [], + assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, + assignedPost: row.assigned_job_post || null, } } @@ -190,6 +209,8 @@ async function fetchApplications(params) { experience: row.experience, recruiter: row.recruiter, duplicate: Boolean(row.duplicate), + suggestedIds: (row.suggested_job_post_ids || []).map(String), + assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, } }) } @@ -197,46 +218,273 @@ async function fetchApplications(params) { // Shared with the sidebar badge through qk.mailbox.counts — see inboxApi.fetchCounts. const fetchInboxCounts = inboxApi.fetchCounts +/** 500 is MAX_BULK_READ_IDS in backend/inbox/views.py; a longer list gets a 413. */ +const BULK_READ_CHUNK = 500 + +async function setReadChunked(ids, read) { + let updated = 0 + for (let i = 0; i < ids.length; i += BULK_READ_CHUNK) { + // Sequential on purpose. Each chunk is one UPDATE over a few hundred rows of + // the same table; firing them together only puts them in each other's way. + // eslint-disable-next-line no-await-in-loop + const res = await inboxApi.bulkSetRead(ids.slice(i, i + BULK_READ_CHUNK), read) + updated += res?.data?.updated ?? 0 + } + return { updated, requested: ids.length } +} + /** - * POST /inbox/{record_id}/read — flips message_read false -> true for one row. - * - * Optimistic, so the row un-bolds on click instead of after the round trip, and - * rolls back if the server rejects. Both mailbox caches hold {id, unread} rows, - * so one setQueriesData over qk.mailbox.all() covers the Email tab and the - * application tabs at once; `processing` is derived from the same column, so it - * moves with it. - * - * NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, - * so a view-only user gets a 403 here and the row snaps back to unread. + * Rewrites ONE cache entry for a read-state flip: a row list, or the + * single-message detail object. Anything else (the counts object) passes through + * untouched — it has no `id`, and its delta is applied separately. */ -function useMarkRead(toast) { +function patchReadState(data, idSet, read) { + const patchRow = (r) => (idSet.has(r.id) + ? { + ...r, + unread: !read, + // `processing` also carries Imported / Processed / Rejected, which are a + // different column. Only the read-derived pair moves with this one. + processing: r.processing === 'Unread' || r.processing === 'Read' + ? (read ? 'Read' : 'Unread') + : r.processing, + } + : r) + if (Array.isArray(data)) return data.map(patchRow) + if (data && typeof data === 'object' && data.id && idSet.has(data.id)) return patchRow(data) + return data +} + +/** + * PATCH /inbox/read — flips message_read for one row or for two hundred. + * + * The single-row route (POST /inbox/{id}/read) still exists, but every flip goes + * through the bulk one so there is exactly ONE optimistic patch to reason about. + * + * Optimistic, so rows un-bold on click instead of after the round trip, and roll + * back if the server rejects. Both mailbox caches hold {id, unread} rows, so one + * setQueriesData over qk.mailbox.all() covers the Email tab and the application + * tabs at once; `processing` is derived from the same column, so it moves with it. + * + * NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, so + * a view-only user gets a 403 here and the rows snap back. The bulk bars disable + * themselves for those users; click-to-read cannot, so it still relies on rollback. + */ +function useSetRead(toast) { const qc = useQueryClient() return useMutation({ - mutationFn: (recordId) => inboxApi.markRead(recordId), - onMutate: async (recordId) => { + mutationFn: ({ ids, read }) => setReadChunked(ids, read), + onMutate: async ({ ids, read }) => { await qc.cancelQueries({ queryKey: qk.mailbox.all() }) const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() }) - qc.setQueriesData({ queryKey: qk.mailbox.all() }, (rows) => ( - Array.isArray(rows) - ? rows.map((r) => (r.id === recordId - ? { ...r, unread: false, processing: r.processing === 'Unread' ? 'Read' : r.processing } - : r)) - : rows - )) + const idSet = new Set(ids) + + // Which rows actually FLIP, counted BEFORE the patch: the counts object is a + // bag of totals with no per-row detail to derive the delta from afterwards. + // A Set, because the same id appears in several cached lists at once. + const flipping = new Set() + for (const [, data] of previous) { + if (!Array.isArray(data)) continue + for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id) + } + + qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read)) + // The Unread tab badge and the sidebar badge share this entry. Without the + // delta they sit stale until the refetch lands — invisible for one row, + // glaring for two hundred. `previous` already covers it for rollback, + // because ['mailbox'] is a prefix of ['mailbox','counts']. + qc.setQueryData(qk.mailbox.counts(), (c) => (c + ? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) } + : c)) return { previous } }, - onError: (err, _recordId, ctx) => { + onError: (err, _vars, ctx) => { for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) - toast(friendlyAuthError(err, 'Could not mark as read.'), 'error') + toast(friendlyAuthError(err, 'Could not update read state.'), 'error') }, onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), }) } +/** + * PATCH /inbox/read-all — every row matching a server-side list filter. + * + * Deliberately NOT optimistic: the scope is a WHERE clause, so the client cannot + * know which rows it hit until the count comes back. It reports that count rather + * than guessing, which is also the honest answer when the view was already read. + */ +function useSetReadAll(toast) { + const qc = useQueryClient() + return useMutation({ + mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }), + onSuccess: (res, { read }) => { + const n = res?.data?.updated ?? 0 + toast( + n === 0 + ? `Nothing to mark ${read ? 'read' : 'unread'} here` + : `${n} ${n === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, + 'success', + ) + }, + onError: (err) => toast(friendlyAuthError(err, 'Could not update read state.'), 'error'), + onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), + }) +} + +/** + * Multi-select over a row list: the Set, the toggles, and the pruning. + * + * The pruning is not optional. Rows leave a list under their own steam — the + * Unread tab drops a row the instant it is marked read — so a Set left alone + * keeps counting ids that are no longer on screen, and "Mark 12 read" quietly + * acts on 9 of them. + */ +function useRowSelection(rows) { + const [selectedIds, setSelectedIds] = useState(() => new Set()) + const visibleIds = useMemo(() => rows.map((r) => r.id), [rows]) + + useEffect(() => { + setSelectedIds((prev) => { + if (prev.size === 0) return prev + const visible = new Set(visibleIds) + const next = new Set() + for (const id of prev) if (visible.has(id)) next.add(id) + // Same size means nothing was pruned. Returning `prev` keeps the Set + // identity stable, so this effect cannot retrigger itself. + return next.size === prev.size ? prev : next + }) + }, [visibleIds]) + + const toggle = useCallback((id) => setSelectedIds((prev) => { + const next = new Set(prev) + if (next.has(id)) next.delete(id) + else next.add(id) + return next + }), []) + + const clear = useCallback(() => setSelectedIds((prev) => (prev.size ? new Set() : prev)), []) + + const toggleAll = useCallback(() => setSelectedIds((prev) => ( + prev.size >= visibleIds.length ? new Set() : new Set(visibleIds) + )), [visibleIds]) + + return { + selectedIds, + toggle, + toggleAll, + clear, + allSelected: visibleIds.length > 0 && selectedIds.size === visibleIds.length, + } +} + +/** + * The row tick. stopPropagation is load-bearing: without it a tick also runs the + * row's onClick, which opens the detail pane AND auto-marks it read — instantly + * undoing the "mark unread" the user is selecting rows for. + */ +function RowCheck({ checked, onToggle, label }) { + return ( + { e.stopPropagation(); onToggle() }} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + e.stopPropagation() + onToggle() + } + }} + > + + + ) +} + +/** + * Select-all plus the read/unread actions for one list. + * + * Every button names what it will hit — "Mark 12 read", or "Mark all read" with + * the scope spelled out in its tooltip — rather than a bare "Mark read" whose + * reach depends on selection state the user has to keep in their head. "All" is + * always the CURRENT view, never the whole mailbox from behind a filter. + */ +function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) { + const { selectedIds, toggleAll, clear, allSelected } = selection + const n = selectedIds.size + const disabled = !canEdit || busy + const tip = !canEdit ? 'Requires inbox.edit' : undefined + const allTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}` + + return ( +
+ + + {n > 0 ? `${n} selected` : `${total} ${total === 1 ? 'message' : 'messages'}`} + +
+ {n > 0 ? ( + <> + + + + + ) : ( + <> + + + + )} +
+
+ ) +} + export default function Inbox() { const { toast } = useToast() const navigate = useNavigate() const qc = useQueryClient() + const { can } = useAuth() + const canEdit = can('inbox.edit') const { data: recruiters = [] } = useQuery(seedQuery('recruiters')) const updateInbox = useSeedMutation('inbox') @@ -331,7 +579,53 @@ export default function Inbox() { ? { ...selectedRow, ...(detailQuery.data ?? {}) } : null - const markRead = useMarkRead(toast) + // Bound to `list`, not `inbox`: the tick boxes sit on the rows the user can + // actually see, and the hook prunes the Set as that set changes. + const selection = useRowSelection(list) + const setRead = useSetRead(toast) + const setReadAll = useSetReadAll(toast) + + /** + * What "Mark all" means here, in words, for the button tooltip. Kept next to + * setReadEverything so the label and the scope cannot drift apart. + */ + const scopeLabel = q.trim() + ? `the ${list.length} row${list.length === 1 ? '' : 's'} matching this search` + : tab === 'All Applications' + ? 'every application' + : `the ${tab} tab` + + function setReadSelected(read) { + const ids = [...selection.selectedIds] + if (!ids.length) return + setRead.mutate({ ids, read }, { + onSuccess: () => { + toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') + selection.clear() + }, + }) + } + + function setReadEverything(read) { + // The scope endpoint may only be used where the server-side filter IS the + // view. The search box narrows client-side on name/position/source while the + // server's `search` matches subject/from/body, and three of the tabs narrow + // client-side entirely — handing either to a WHERE clause would mark rows + // that were never on screen. Those cases send the visible ids instead, which + // is exact and, since the list endpoint is unpaginated, complete. + if (SERVER_SCOPED_TABS.has(tab) && !q.trim()) { + setReadAll.mutate({ read, filter: tabFilter }) + return + } + const ids = list.map((i) => i.id) + if (!ids.length) return + setRead.mutate({ ids, read }, { + onSuccess: () => { + toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') + selection.clear() + }, + }) + } const setState = useMutation({ mutationFn: ({ id, state }) => inboxApi.setProcessingState(id, state), @@ -360,7 +654,7 @@ export default function Inbox() { function select(id) { setSelectedId(id) const item = inbox.find((i) => i.id === id) - if (item?.unread) markRead.mutate(id) + if (item?.unread) setRead.mutate({ ids: [id], read: true }) } function importItem(item) { @@ -407,7 +701,7 @@ export default function Inbox() {
{ setTab(t); setSelectedId(null) }} + onChange={(t) => { setTab(t); setSelectedId(null); selection.clear() }} tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))} />
@@ -423,6 +717,17 @@ export default function Inbox() { setQ(e.target.value)} placeholder="Search applications…" /> + {applicationsQuery.isSuccess && ( + + )}
{applicationsQuery.isPending && ( Fetching applications from the server. @@ -441,6 +746,11 @@ export default function Inbox() { className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`} onClick={() => select(i.id)} > + selection.toggle(i.id)} + label={`Select ${i.name}`} + />
@@ -492,6 +802,8 @@ export default function Inbox() { item={selected} loading={detailQuery.isPending} busy={setState.isPending || markDuplicate.isPending} + canEdit={canEdit} + toast={toast} onPreview={() => setPreviewing(selected)} onImport={() => importItem(selected)} onMove={() => moveToPipeline(selected)} @@ -572,12 +884,91 @@ function orDash(value, suffix = '') { } function ApplicationDetail({ - item: i, loading, busy, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate, + item: i, loading, busy, canEdit, toast, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate, }) { - const navigate = useNavigate() + const qc = useQueryClient() 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 [selection, setSelection] = useState(null) + const [manualPost, setManualPost] = useState(null) + const [showPicker, setShowPicker] = useState(false) + const [whyOpen, setWhyOpen] = useState(false) + + // Do not auto-pick the first suggestion: Shortlist stays locked until the + // recruiter actually chooses a card (or the row already has an assigned job). + useEffect(() => { + setManualPost(null) + setWhyOpen(false) + setSelection(i.assignedId || null) + }, [i.id, i.assignedId]) + + const suggestionCards = useMemo(() => { + const byId = new Map((i.suggestedPosts || []).map((p) => [String(p.id), p])) + return (i.suggestedIds || []).map((id, idx) => ({ + rank: idx + 1, + post: byId.get(id) || { id, unavailable: true }, + })) + }, [i.suggestedPosts, i.suggestedIds]) + + const selectedPost = useMemo(() => { + if (!selection) return null + if (manualPost && String(manualPost.id) === String(selection)) return manualPost + if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost + const hit = suggestionCards.find((c) => String(c.post.id) === String(selection)) + return hit?.post || null + }, [selection, manualPost, i.assignedPost, suggestionCards]) + + const assignMutation = useMutation({ + mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId), + onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'), + onSuccess: (_res, vars) => { + const title = selectedPost?.title || 'role' + if (vars.jobPostId) toast(`${i.name} → ${title}`, 'success') + else toast(`${i.name} unassigned`, 'success') + }, + onSettled: (_res, _err, vars) => { + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) }) + }, + }) + + const rematchMutation = useMutation({ + mutationFn: (recordId) => inboxApi.rematch(recordId), + onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'), + onSuccess: () => toast('Match re-queued', 'success'), + onSettled: (_r, _e, recordId) => { + qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) }) + qc.invalidateQueries({ queryKey: qk.mailbox.all() }) + }, + }) + + const jobChosen = Boolean(i.assignedId || selection) + const alreadyProcessed = i.processing === 'Processed' + const shortlistLocked = !alreadyProcessed && !jobChosen + const matchFailed = ['failed', 'no_text', 'dlq'].includes(i.matchStatus) + const resumeText = i.resumeText || '' + const assigned = i.assignedPost + const panelBusy = busy || assignMutation.isPending || rematchMutation.isPending + const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending + + async function handleMove() { + if (busy || alreadyProcessed) return + if (!jobChosen) { + toast(SHORTLIST_JOB_WARNING, 'warning') + return + } + const jobId = selection || i.assignedId + if (jobId && String(jobId) !== String(i.assignedId || '')) { + try { + await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId }) + } catch { + return + } + } + onMove() + } + return (
@@ -629,67 +1020,233 @@ function ApplicationDetail({ )}
- {/* Same Subject-strip + framed-body template as /matching. */} - {!loading && ( -
-
Subject: {i.position || '(no subject)'}
- {looksLikeHtml(i.bodyHtml) ? ( - - ) : ( -
-              {i.body || 'This email has no message body.'}
-            
- )} -
- )} - - {i.hasAttachment && ( -
-
-
-
- {orDash(i.attachment)} - {i.files?.[0]?.size != null && ( - · {Math.round(i.files[0].size / 1024)} KB - )} + {assigned && ( +
+
+
+ +
+
Assigned to {assigned.title}
+
+ {[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'} +
-
-
-              {i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
-            
+
+ + +
)} +
+
+ {!loading && ( +
+
Subject: {i.position || '(no subject)'}
+ {looksLikeHtml(i.bodyHtml) ? ( + + ) : ( +
+                  {i.body || 'This email has no message body.'}
+                
+ )} +
+ )} + + {i.hasAttachment && ( +
+
+
+
+ {orDash(i.attachment)} + {i.files?.[0]?.size != null && ( + · {Math.round(i.files[0].size / 1024)} KB + )} +
+ +
+
+                  {resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
+                
+
+
+ )} +
+ +
+ {matchFailed ? ( +
+
{i.matchError || 'Matching failed for this application.'}
+ +
+ ) : ( +
+
AI verdict
+

{i.matchSummary || 'No match summary yet.'}

+ {i.matchedAt && ( +
Matched {fmtDate(i.matchedAt)}
+ )} + {i.matchReasoning && ( + + )} + {whyOpen && ( +

{i.matchReasoning}

+ )} +
+ )} + +
Suggested roles
+ {suggestionCards.length === 0 && !manualPost ? ( + +
+ + +
+
+ ) : ( + suggestionCards.map(({ rank, post }) => ( + setSelection(String(id))} + resumeText={resumeText} + /> + )) + )} + {manualPost && ( + setSelection(String(id))} + resumeText={resumeText} + /> + )} + + +
+
+
- - -
+ + {showPicker && ( + setShowPicker(false)} + onPick={(post) => { + setManualPost(post) + setSelection(String(post.id)) + }} + /> + )}
) } @@ -725,6 +1282,8 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) { /** The live tab: real fetch, real loading state, real error state. */ function EmailTab({ query, toast }) { const qc = useQueryClient() + const { can } = useAuth() + const canEdit = can('inbox.edit') const [selectedId, setSelectedId] = useState(null) const [replying, setReplying] = useState(null) const [replyBody, setReplyBody] = useState('') @@ -734,7 +1293,29 @@ function EmailTab({ query, toast }) { const selected = emails.find((e) => e.id === selectedId) const unread = emails.filter((e) => e.unread).length - const markRead = useMarkRead(toast) + const selection = useRowSelection(emails) + const setRead = useSetRead(toast) + const setReadAll = useSetReadAll(toast) + + function setReadSelected(read) { + const ids = [...selection.selectedIds] + if (!ids.length) return + setRead.mutate({ ids, read }, { + onSuccess: () => { + toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success') + selection.clear() + }, + }) + } + + /** + * This list is /inbox/fetch with no params — every persisted message, no + * filter, no pagination — so the empty scope really is the whole mailbox and + * the WHERE clause matches what is on screen exactly. + */ + function setReadEverything(read) { + setReadAll.mutate({ read, filter: {} }) + } const sync = useMutation({ mutationFn: () => inboxApi.syncMailbox(), @@ -779,7 +1360,7 @@ function EmailTab({ query, toast }) { function selectEmail(e) { setSelectedId(e.id) - if (e.unread) markRead.mutate(e.id) + if (e.unread) setRead.mutate({ ids: [e.id], read: true }) } const isImported = (e) => importedIds.has(e.id) @@ -803,6 +1384,17 @@ function EmailTab({ query, toast }) {
+ {query.isSuccess && emails.length > 0 && ( + + )} {query.isPending && Fetching mailbox from the server.} {query.isError && ( @@ -818,6 +1410,11 @@ function EmailTab({ query, toast }) { className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`} onClick={() => selectEmail(e)} > + selection.toggle(e.id)} + label={`Select mail from ${e.from}`} + />
{e.from}
diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 596e300..f02e9c3 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -11,10 +11,10 @@ import { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import Modal from '../ui/Modal' import EmailBody, { looksLikeHtml } from '../ui/EmailBody' import { Tabs } from '../ui/Tabs' import { Avatar, Badge, EmptyState, Icon } from '../ui/primitives' +import { JobCard, PickRoleModal } from '../ui/SuggestedRoles' import { useToast } from '../ui/Toast' import { useAuth } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' @@ -80,14 +80,6 @@ function htmlToText(value) { return (doc.body?.textContent || '').replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim() } -/** Requirement chip lights green when the resume text contains it (client-side). */ -function reqInResume(req, resumeText) { - if (!req || !resumeText) return false - const needle = String(req).trim().toLowerCase() - if (!needle) return false - return resumeText.toLowerCase().includes(needle) -} - function mapApplication(row) { const name = row.name || row.email || 'Unknown' const suggested = Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids : [] @@ -168,129 +160,6 @@ function AssignmentBadge({ item, titleById }) { return No match } -function JobCard({ post, rank, selected, onSelect, resumeText, manual }) { - const unavailable = Boolean(post?.unavailable) || !post?.title - const title = post?.title || 'Unavailable' - const meta = [ - post?.employment_type, - post?.location, - post?.experience_min != null || post?.experience_max != null - ? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs` - : null, - ].filter(Boolean).join(' · ') - - return ( -
!unavailable && onSelect(post.id)} - onKeyDown={(e) => { - if (unavailable) return - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - onSelect(post.id) - } - }} - style={{ - cursor: unavailable ? 'not-allowed' : 'pointer', - opacity: unavailable ? 0.55 : 1, - borderColor: selected ? 'var(--primary)' : undefined, - boxShadow: selected ? 'var(--ring)' : undefined, - marginBottom: 8, - alignItems: 'flex-start', - }} - > -
-
- {manual ? 'Manual' : `AI #${rank}`} -
{title}
- {unavailable ? ( - Unavailable - ) : ( - {post.status || 'draft'} - )} - {selected && } -
- {meta &&
{meta}
} - {!unavailable && (post.requirements || []).length > 0 && ( -
- {(post.requirements || []).slice(0, 8).map((req) => { - const hit = reqInResume(req, resumeText) - return ( - - {req} - - ) - })} -
- )} -
-
- ) -} - -function PickRoleModal({ onClose, onPick }) { - const [q, setQ] = useState('') - const { data = [], isPending, isError, error } = useQuery({ - queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }), - queryFn: async () => { - const res = await jobPostsApi.list({ search: q || undefined, top: 30 }) - return Array.isArray(res?.data) ? res.data : [] - }, - }) - - return ( - Cancel} - > -
- - setQ(e.target.value)} placeholder="Search title or location…" autoFocus /> -
- {isPending && Fetching job posts.} - {isError && ( - - {friendlyAuthError(error, 'Request failed')} - - )} - {!isPending && !isError && data.length === 0 && ( - Try a different search. - )} -
- {data.map((p) => ( -
{ onPick(p); onClose() }} - > -
-
{p.title}
-
- {[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'} -
-
- {p.status} -
- ))} -
-
- ) -} - export default function Matching() { const { toast } = useToast() const { can } = useAuth() diff --git a/frontend/src/ui/SuggestedRoles.jsx b/frontend/src/ui/SuggestedRoles.jsx new file mode 100644 index 0000000..e78da6a --- /dev/null +++ b/frontend/src/ui/SuggestedRoles.jsx @@ -0,0 +1,147 @@ +/* ============================================================ + Suggested-role picker — shared by Job Matching and Recruitment Inbox. + + JobCard is the AI/manual radiogroup row. PickRoleModal is the "choose a + different role" search popup. Requirement chips light green when the + resume text contains them (client-side substring, same as Matching). + ============================================================ */ + +import { useState } from 'react' +import { useQuery } from '@tanstack/react-query' + +import Modal from './Modal' +import { Badge, EmptyState, Icon } from './primitives' +import { friendlyAuthError } from '../lib/errors' +import { qk } from '../lib/queryKeys' +import * as jobPostsApi from '../api/jobPosts' + +/** Requirement chip lights green when the resume text contains it (client-side). */ +export function reqInResume(req, resumeText) { + if (!req || !resumeText) return false + const needle = String(req).trim().toLowerCase() + if (!needle) return false + return resumeText.toLowerCase().includes(needle) +} + +export function JobCard({ post, rank, selected, onSelect, resumeText, manual }) { + const unavailable = Boolean(post?.unavailable) || !post?.title + const title = post?.title || 'Unavailable' + const meta = [ + post?.employment_type, + post?.location, + post?.experience_min != null || post?.experience_max != null + ? `${post?.experience_min ?? '?'}–${post?.experience_max ?? '?'} yrs` + : null, + ].filter(Boolean).join(' · ') + + return ( +
!unavailable && onSelect(post.id)} + onKeyDown={(e) => { + if (unavailable) return + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onSelect(post.id) + } + }} + style={{ + cursor: unavailable ? 'not-allowed' : 'pointer', + opacity: unavailable ? 0.55 : 1, + borderColor: selected ? 'var(--primary)' : undefined, + boxShadow: selected ? 'var(--ring)' : undefined, + marginBottom: 8, + alignItems: 'flex-start', + }} + > +
+
+ {manual ? 'Manual' : `AI #${rank}`} +
{title}
+ {unavailable ? ( + Unavailable + ) : ( + {post.status || 'draft'} + )} + {selected && } +
+ {meta &&
{meta}
} + {!unavailable && (post.requirements || []).length > 0 && ( +
+ {(post.requirements || []).slice(0, 8).map((req) => { + const hit = reqInResume(req, resumeText) + return ( + + {req} + + ) + })} +
+ )} +
+
+ ) +} + +export function PickRoleModal({ onClose, onPick }) { + const [q, setQ] = useState('') + const { data = [], isPending, isError, error } = useQuery({ + queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }), + queryFn: async () => { + const res = await jobPostsApi.list({ search: q || undefined, top: 30 }) + return Array.isArray(res?.data) ? res.data : [] + }, + }) + + return ( + Cancel} + > +
+ + setQ(e.target.value)} placeholder="Search title or location…" autoFocus /> +
+ {isPending && Fetching job posts.} + {isError && ( + + {friendlyAuthError(error, 'Request failed')} + + )} + {!isPending && !isError && data.length === 0 && ( + Try a different search. + )} +
+ {data.map((p) => ( +
{ onPick(p); onClose() }} + > +
+
{p.title}
+
+ {[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'} +
+
+ {p.status} +
+ ))} +
+
+ ) +} -- 2.40.1 From 71d9803db2db3e5e9809a8140f806c1444a1a5d2 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 19 Aug 2026 18:52:48 +0500 Subject: [PATCH 3/7] Assing corrected --- frontend/src/screens/Inbox.jsx | 2 +- frontend/src/screens/Matching.jsx | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 22d626b..546cfed 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -1203,7 +1203,7 @@ function ApplicationDetail({ assignMutation.mutate({ recordId: i.id, jobPostId: selection }) }} > - {selectedPost?.title ? `Assign to ${selectedPost.title}` : 'Assign'} + {selectedPost?.title ? `Assign`:'Assign'}
diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index f02e9c3..d8b9a1c 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -742,8 +742,7 @@ function MatchingWorkspace({ onClick={onAssign} > {selectedPost?.title - ? `Assign to ${selectedPost.title}` - : 'Assign'} + ? 'Assign' :'Assign'}
-- 2.40.1 From 24f3c5df5394a887ed6a15cca1de1012ba0b96d4 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 19 Aug 2026 19:07:53 +0500 Subject: [PATCH 4/7] mark correc t --- frontend/src/screens/Candidates.jsx | 66 +++++++++--- frontend/src/screens/Inbox.jsx | 157 ++++++++++++++++++---------- 2 files changed, 155 insertions(+), 68 deletions(-) diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 7e65e1f..067afd3 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -17,18 +17,22 @@ import Modal from '../ui/Modal' import { Pagination, useDataTable } from '../ui/DataTable' import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives' import { useToast } from '../ui/Toast' -import CandidateProfile, { useJobTitles } from './ScoredCandidateProfile' +import CandidateProfile from './CandidateProfile' +import { useJobTitles } from './ScoredCandidateProfile' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as pipelineApi from '../api/pipeline' import { useFormState } from '../components/AuthLayout' -import { persist } from '../data/seedQueries' +import { persist, useSeedMutation } from '../data/seedQueries' import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed' const EMPTY_FILTERS = { account: '' } +/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */ +const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired'] + /** The seeded `candidate` role (backend/role/models.py::EnumRoles). */ const CANDIDATE_ROLE_ID = 8 @@ -41,8 +45,8 @@ const CANDIDATE_ROLE_ID = 8 them have. The consequence is that the ATS columns have no source on this screen — see - toCandidateUserView. Open a candidate to get their score, which - ScoredCandidateProfile still reads from the scored endpoint. */ + toCandidateUserView. Open a candidate to get their score, which the shared + Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */ async function fetchCandidates() { const res = await candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID }) const rows = Array.isArray(res?.data) ? res.data : [] @@ -93,6 +97,7 @@ export default function Candidates() { const qc = useQueryClient() const location = useLocation() const navigate = useNavigate() + const updateCandidates = useSeedMutation('candidates') const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates }) const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) @@ -121,6 +126,16 @@ export default function Candidates() { [jobsById], ) + /* Same click-time score fetch Talent Pool uses: GET /pipeline/candidate/score/fetch + only while the profile modal is open, cached per userId. */ + const scoreQuery = useQuery({ + queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }), + queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }), + select: pipelineApi.toAtsScore, + enabled: Boolean(profileFor?.userId), + }) + const atsScore = scoreQuery.data?.overall_score ?? null + /* The relevance blend (score + matched-skill ratio + recency) went with the scoring columns — none of its three inputs exists on a users row. */ @@ -181,7 +196,6 @@ export default function Candidates() { { key: 'email', label: 'Email', sortable: true }, { key: 'isActive', label: 'Account', sortable: true }, { key: 'applied', label: 'Added', sortable: true }, - { key: '_a', label: 'Actions', align: 'right' }, ], [], ) @@ -208,6 +222,24 @@ export default function Candidates() { setAtsFor(c) } + function toggleFav(c) { + updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x))) + setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p)) + toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success') + } + + function advance(c) { + const i = STAGE_ORDER.indexOf(c.stage) + if (i === -1 || i >= STAGE_ORDER.length - 1) { + toast(`${c.name} cannot be advanced further`, 'warning') + return + } + const stage = STAGE_ORDER[i + 1] + updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x))) + setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p)) + toast(`${c.name} moved to ${stage}`, 'success') + } + /* After a manual add, the CV goes through the same persisted scoring pipeline CV Import and the profile ATS match use (POST /candidate/score): the score lands in the scored `candidates` table, and re-uploading the same bytes @@ -344,7 +376,11 @@ export default function Candidates() { ) : ( t.pageRows.map((c) => ( - + openProfile(c)} + >
@@ -367,11 +403,6 @@ export default function Candidates() { {c.applied ? c.applied.toLocaleDateString() : '—'} - -
- -
- )) )} @@ -394,9 +425,18 @@ export default function Candidates() { {profileFor && ( c.id === profileFor.id) ?? profileFor} - jobTitle={jobTitleOf(profileFor)} + candidate={{ + ...(candidates.find((c) => c.id === profileFor.id) ?? profileFor), + initials: initialsOf(profileFor.name), + color: avatarColor(profileFor.name), + stage: profileFor.stage || 'Shortlist', + userId: profileFor.userId || profileFor.id, + }} + atsScore={atsScore} + recommendation={scoreQuery.data?.band ?? null} onClose={() => setProfileFor(null)} + onAdvance={advance} + onToggleFav={toggleFav} onAtsMatch={(c) => { setProfileFor(null); openAts(c) }} /> )} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 546cfed..95644dc 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -9,7 +9,7 @@ now, which is the structural fix. ============================================================ */ -import { useCallback, useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' @@ -270,34 +270,36 @@ function patchReadState(data, idSet, read) { * a view-only user gets a 403 here and the rows snap back. The bulk bars disable * themselves for those users; click-to-read cannot, so it still relies on rollback. */ +async function optimisticRead(qc, ids, read) { + await qc.cancelQueries({ queryKey: qk.mailbox.all() }) + const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() }) + const idSet = new Set(ids) + + // Which rows actually FLIP, counted BEFORE the patch: the counts object is a + // bag of totals with no per-row detail to derive the delta from afterwards. + // A Set, because the same id appears in several cached lists at once. + const flipping = new Set() + for (const [, data] of previous) { + if (!Array.isArray(data)) continue + for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id) + } + + qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read)) + // The Unread tab badge and the sidebar badge share this entry. Without the + // delta they sit stale until the refetch lands — invisible for one row, + // glaring for two hundred. `previous` already covers it for rollback, + // because ['mailbox'] is a prefix of ['mailbox','counts']. + qc.setQueryData(qk.mailbox.counts(), (c) => (c + ? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) } + : c)) + return { previous } +} + function useSetRead(toast) { const qc = useQueryClient() return useMutation({ mutationFn: ({ ids, read }) => setReadChunked(ids, read), - onMutate: async ({ ids, read }) => { - await qc.cancelQueries({ queryKey: qk.mailbox.all() }) - const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() }) - const idSet = new Set(ids) - - // Which rows actually FLIP, counted BEFORE the patch: the counts object is a - // bag of totals with no per-row detail to derive the delta from afterwards. - // A Set, because the same id appears in several cached lists at once. - const flipping = new Set() - for (const [, data] of previous) { - if (!Array.isArray(data)) continue - for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id) - } - - qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read)) - // The Unread tab badge and the sidebar badge share this entry. Without the - // delta they sit stale until the refetch lands — invisible for one row, - // glaring for two hundred. `previous` already covers it for rollback, - // because ['mailbox'] is a prefix of ['mailbox','counts']. - qc.setQueryData(qk.mailbox.counts(), (c) => (c - ? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) } - : c)) - return { previous } - }, + onMutate: ({ ids, read }) => optimisticRead(qc, ids, read), onError: (err, _vars, ctx) => { for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) toast(friendlyAuthError(err, 'Could not update read state.'), 'error') @@ -309,14 +311,19 @@ function useSetRead(toast) { /** * PATCH /inbox/read-all — every row matching a server-side list filter. * - * Deliberately NOT optimistic: the scope is a WHERE clause, so the client cannot - * know which rows it hit until the count comes back. It reports that count rather - * than guessing, which is also the honest answer when the view was already read. + * The WRITE is a WHERE clause, but the optimistic patch still runs over the ids + * the caller can see: this endpoint is only ever used where the visible list IS + * the server's whole result (see SERVER_SCOPED_TABS, and the list endpoint takes + * no pagination), so those ids are the scope, not a sample of it. Without the + * patch the rows sit unchanged for a whole round trip plus a refetch, which reads + * as a dead button — and the reconciling refetch on settle corrects it anyway if + * the scope ever did reach further. */ function useSetReadAll(toast) { const qc = useQueryClient() return useMutation({ mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }), + onMutate: ({ ids, read }) => optimisticRead(qc, ids ?? [], read), onSuccess: (res, { read }) => { const n = res?.data?.updated ?? 0 toast( @@ -326,7 +333,10 @@ function useSetReadAll(toast) { 'success', ) }, - onError: (err) => toast(friendlyAuthError(err, 'Could not update read state.'), 'error'), + onError: (err, _vars, ctx) => { + for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data) + toast(friendlyAuthError(err, 'Could not update read state.'), 'error') + }, onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }), }) } @@ -405,20 +415,52 @@ function RowCheck({ checked, onToggle, label }) { ) } +const READ_TICK_MS = 1000 + /** * Select-all plus the read/unread actions for one list. * - * Every button names what it will hit — "Mark 12 read", or "Mark all read" with - * the scope spelled out in its tooltip — rather than a bare "Mark read" whose - * reach depends on selection state the user has to keep in their head. "All" is - * always the CURRENT view, never the whole mailbox from behind a filter. + * The leading ✓ is not a mode indicator. It only appears on the Mark read / + * Mark all read button that was just clicked, then clears from both after 1s. + * + * `rows` rather than a count, because the bar needs each row's read state, and + * `selectedIds` narrows it to what the selected pair of buttons would touch. */ -function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) { +function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) { const { selectedIds, toggleAll, clear, allSelected } = selection const n = selectedIds.size - const disabled = !canEdit || busy + const total = rows.length + + // What the visible pair of buttons acts on: the ticked rows when there is a + // selection, the whole view otherwise. + const target = n > 0 ? rows.filter((r) => selectedIds.has(r.id)) : rows + const unreadCount = target.reduce((acc, r) => acc + (r.unread ? 1 : 0), 0) + const readCount = target.length - unreadCount + + const blocked = !canEdit || busy const tip = !canEdit ? 'Requires inbox.edit' : undefined - const allTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}` + const scopeTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}` + const nothingTo = (word) => `Nothing to mark ${word} here` + + // `selected` = Mark read, `all` = Mark all read. Never both at once in the + // bar, but the flash still resets both so a leftover tick cannot linger + // after the selection-vs-all swap. + const [ticked, setTicked] = useState(null) + const tickTimer = useRef(null) + + useEffect(() => () => { + if (tickTimer.current) clearTimeout(tickTimer.current) + }, []) + + function flashRead(which, action) { + if (tickTimer.current) clearTimeout(tickTimer.current) + setTicked(which) + action() + tickTimer.current = setTimeout(() => { + setTicked(null) + tickTimer.current = null + }, READ_TICK_MS) + } return (
- {n > 0 ? `${n} selected` : `${total} ${total === 1 ? 'message' : 'messages'}`} + {n > 0 + ? `${n} selected` + : `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
{n > 0 ? ( <> @@ -458,19 +503,20 @@ function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, <> )} @@ -614,7 +660,8 @@ export default function Inbox() { // that were never on screen. Those cases send the visible ids instead, which // is exact and, since the list endpoint is unpaginated, complete. if (SERVER_SCOPED_TABS.has(tab) && !q.trim()) { - setReadAll.mutate({ read, filter: tabFilter }) + // `ids` drives the optimistic patch only; the write itself is the filter. + setReadAll.mutate({ read, filter: tabFilter, ids: list.map((i) => i.id) }) return } const ids = list.map((i) => i.id) @@ -719,7 +766,7 @@ export default function Inbox() {
{applicationsQuery.isSuccess && ( e.id) }) } const sync = useMutation({ @@ -1386,7 +1433,7 @@ function EmailTab({ query, toast }) {
{query.isSuccess && emails.length > 0 && ( Date: Wed, 19 Aug 2026 19:12:42 +0500 Subject: [PATCH 5/7] horizontal expansion --- frontend/src/screens/Inbox.jsx | 46 +++++++++++++++++----------------- frontend/src/styles/styles.css | 40 +++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 23 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 95644dc..39d63b3 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -463,39 +463,39 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, } return ( -
+
- + 0 + ? `${n} selected` + : `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`} + > {n > 0 ? `${n} selected` - : `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`} + : `${total}${unreadCount ? ` · ${unreadCount}` : ''}`} -
+
{n > 0 ? ( <> @@ -508,7 +508,7 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, onClick={() => flashRead('all', () => onSetAllRead(true))} > {ticked === 'all' && } - Mark all read{unreadCount ? ` (${unreadCount})` : ''} + Read )} @@ -756,14 +756,8 @@ export default function Inbox() { {tab === 'Email' ? ( ) : ( -
-
-
-
- - setQ(e.target.value)} placeholder="Search applications…" /> -
-
+
+
{applicationsQuery.isSuccess && ( )} +
+
+ + setQ(e.target.value)} placeholder="Search applications…" /> +
+
{applicationsQuery.isPending && ( Fetching applications from the server. @@ -1429,8 +1429,8 @@ function EmailTab({ query, toast }) {
-
-
+
+
{query.isSuccess && emails.length > 0 && ( Date: Wed, 19 Aug 2026 19:30:02 +0500 Subject: [PATCH 6/7] mark as read and unread woth correct position --- frontend/src/screens/Inbox.jsx | 46 ++++++++++++++++++++++++++-------- 1 file changed, 35 insertions(+), 11 deletions(-) diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 39d63b3..9ac144e 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -25,8 +25,8 @@ import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' import { - atsRecommendationClass, avatarColor, fmtDate, fmtShort, initials as initialsOf, - inboxSources, relTime, sourceMeta, + atsRecommendationClass, avatarColor, fmtDate, initials as initialsOf, + inboxSources, sourceMeta, } from '../data/seed' const TABS = ['All Applications', 'Unread', 'Processed', 'Rejected', 'Duplicates', 'Email'] @@ -57,9 +57,6 @@ const TAB_FILTERS = { */ const SERVER_SCOPED_TABS = new Set(['All Applications', 'Unread']) -/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */ -const NOW = new Date('2026-07-09T20:00') - /** * message_received_time / message_sent_time are plain string columns * (backend/inbox/models.py:54-56), not timestamps. An unparseable value yields @@ -72,6 +69,33 @@ function parseDate(value) { return Number.isNaN(d.getTime()) ? null : d } +function startOfDay(d) { + return new Date(d.getFullYear(), d.getMonth(), d.getDate()) +} + +/** + * Outlook-style list timestamps in the client's local timezone. + * Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy + AM/PM time. + */ +function outlookListTime(value) { + if (!value) return '—' + const d = value instanceof Date ? value : new Date(value) + if (Number.isNaN(d.getTime())) return '—' + const time = d.toLocaleTimeString(undefined, { + hour: 'numeric', + minute: '2-digit', + hour12: true, + }) + const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000) + if (daysAgo < 7) { + const weekday = d.toLocaleDateString(undefined, { weekday: 'short' }) + return `${weekday} ${time}` + } + const dd = String(d.getDate()).padStart(2, '0') + const mm = String(d.getMonth() + 1).padStart(2, '0') + return `${dd}/${mm}/${d.getFullYear()} ${time}` +} + /** * `source` arrives as the raw To address, because that is where the board tag * lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip @@ -487,7 +511,7 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, onClick={() => flashRead('selected', () => onSetRead(true))} > {ticked === 'selected' && } - Read + Mark as Read @@ -508,7 +532,7 @@ function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, onClick={() => flashRead('all', () => onSetAllRead(true))} > {ticked === 'all' && } - Read + Mark all Read )} @@ -816,7 +840,7 @@ export default function Inbox() {
- {i.received ? relTime(Math.round((NOW - i.received) / 60000)) : '—'} + {outlookListTime(i.received)}
{/* No ATS score exists server-side — the agent returns a verdict, not a number. The chip stays off rather than @@ -1473,7 +1497,7 @@ function EmailTab({ query, toast }) { {isImported(e) && Imported}
-
{e.when ? fmtShort(e.when) : '—'}
+
{outlookListTime(e.when)}
))}
-- 2.40.1 From 039a37747a4782fbbad560da5126a04f63f75803 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 19 Aug 2026 19:34:16 +0500 Subject: [PATCH 7/7] theme updated --- frontend/package.json | 3 +- frontend/src/theme/ThemeProvider.jsx | 29 ++++++ frontend/src/ui/EmailBody.jsx | 38 ++++++- frontend/theme.test.mjs | 143 +++++++++++++++++++++++++++ 4 files changed, 207 insertions(+), 6 deletions(-) create mode 100644 frontend/theme.test.mjs diff --git a/frontend/package.json b/frontend/package.json index 11b6fd5..c734d12 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,7 +10,8 @@ "preview": "vite preview", "smoke": "node smoke.test.mjs", "test:token": "node token.test.mjs", - "verify": "vite build && node smoke.test.mjs && node token.test.mjs" + "test:theme": "node theme.test.mjs", + "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs" }, "dependencies": { "@tanstack/react-query": "^5.101.4", diff --git a/frontend/src/theme/ThemeProvider.jsx b/frontend/src/theme/ThemeProvider.jsx index fa9a61d..dc785fe 100644 --- a/frontend/src/theme/ThemeProvider.jsx +++ b/frontend/src/theme/ThemeProvider.jsx @@ -47,6 +47,35 @@ export function useTheme() { return ctx } +/** + * A counter that increments every time [data-theme] flips. Use it as an effect + * or useMemo dependency. + * + * Almost nothing needs this: a CSS custom property change repaints the whole + * document for free, which is why the app re-themes without any React + * involvement. It exists for the handful of places that CANNOT ride a variable + * — anything that bakes a colour into a canvas, a string, or a separate + * document at render time. Those read the palette through getComputedStyle + * exactly once and then hold a stale copy forever, because changing a custom + * property repaints CSS but never re-runs JavaScript. + * + * It watches the ATTRIBUTE rather than subscribing to this provider's state, on + * purpose. initTheme() runs before React mounts and applyTheme() can be called + * from outside the tree, so the attribute is the only source that is always + * current — and a component using this hook then needs no provider at all. + * Chart.jsx observes the same attribute directly, for the same reason. + */ +export function useThemeVersion() { + const [version, setVersion] = useState(0) + useEffect(() => { + if (typeof MutationObserver !== 'function') return undefined + const mo = new MutationObserver(() => setVersion((v) => v + 1)) + mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] }) + return () => mo.disconnect() + }, []) + return version +} + export default function ThemeProvider({ children }) { const [theme, setThemeState] = useState( () => document.documentElement.getAttribute('data-theme') || 'light', diff --git a/frontend/src/ui/EmailBody.jsx b/frontend/src/ui/EmailBody.jsx index 2c80ee3..443f400 100644 --- a/frontend/src/ui/EmailBody.jsx +++ b/frontend/src/ui/EmailBody.jsx @@ -21,9 +21,16 @@ Remote images stay blocked until the user asks for them. A tracking pixel in an applicant email would otherwise tell the sender exactly when a recruiter opened it. + + That CSS isolation has one cost worth stating plainly: custom properties do + not inherit across an iframe boundary, so this is the only component in the + app that cannot re-theme itself for free. Its palette is snapshotted into the + frame's