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/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/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/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/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
{candidates.length} active candidates across 6 stages
-
Conversion Applied → Interview: ~28%
+
Conversion Shortlist → Interview: ~28%
Bottleneck detected at Assessment stage (longest dwell time)
Offer acceptance trending at 82%
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/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) => (
-
))
)}
@@ -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 573ee47..9ac144e 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).
@@ -9,7 +9,7 @@
now, which is the structural fix.
============================================================ */
-import { 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'
@@ -17,28 +17,29 @@ 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'
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', '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 = {
@@ -46,43 +47,15 @@ const TAB_FILTERS = {
}
/**
- * 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.
+ * 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 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')
+const SERVER_SCOPED_TABS = new Set(['All Applications', 'Unread'])
/**
* message_received_time / message_sent_time are plain string columns
@@ -96,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
@@ -151,14 +151,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)
@@ -191,6 +192,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,
}
}
@@ -227,6 +233,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,
}
})
}
@@ -234,46 +242,319 @@ 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.
+ */
+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: (recordId) => inboxApi.markRead(recordId),
- onMutate: async (recordId) => {
- 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
- ))
- return { previous }
- },
- onError: (err, _recordId, ctx) => {
+ mutationFn: ({ ids, read }) => setReadChunked(ids, read),
+ 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 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.
+ *
+ * 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(
+ n === 0
+ ? `Nothing to mark ${read ? 'read' : 'unread'} here`
+ : `${n} ${n === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`,
+ 'success',
+ )
+ },
+ 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() }),
+ })
+}
+
+/**
+ * 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()
+ }
+ }}
+ >
+
+
+ )
+}
+
+const READ_TICK_MS = 1000
+
+/**
+ * Select-all plus the read/unread actions for one list.
+ *
+ * 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({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
+ const { selectedIds, toggleAll, clear, allSelected } = selection
+ const n = selectedIds.size
+ 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 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 (
+
)
}
@@ -810,131 +1350,11 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) {
)
}
-/* ============================================================
- Filtered Out — the intake gate's ledger (GET /inbox/triage).
-
- Every inbound mail is classified on subject + body and only the job
- applications get an inbox_messages row, so this tab is the ONLY place the
- dropped mail is visible. That is the point: a hard gate's real risk is the
- silent false negative, and Restore is what makes one recoverable.
-
- There is no body to preview here — the gate stores the verdict, never the
- mail. Restore re-fetches the original from upstream and runs it through the
- normal ingestion path.
- ============================================================ */
-function TriageTab({ query, view, onView, toast }) {
- const qc = useQueryClient()
- const rows = query.data?.rows ?? []
- const total = query.data?.total ?? 0
-
- const override = useMutation({
- mutationFn: ({ id, isApplication }) => inboxApi.overrideTriage(id, isApplication),
- onSuccess: (_d, vars) => {
- toast(
- vars.isApplication
- ? 'Restored — the message is being imported and matched.'
- : 'Marked as not an application.',
- 'success',
- )
- },
- onError: (err) => toast(friendlyAuthError(err, 'Could not update the verdict.'), 'error'),
- // qk.mailbox.all() covers the ledger, the application lists and the counts:
- // restoring writes a real inbox row, so all three are stale at once.
- onSettled: () => {
- qc.invalidateQueries({ queryKey: qk.mailbox.all() })
- qc.invalidateQueries({ queryKey: qk.mailbox.counts() })
- },
- })
-
- const pendingId = override.isPending ? override.variables?.id : null
-
- return (
- <>
-