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

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) => ( - + 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 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 ( +
+ + 0 + ? `${n} selected` + : `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`} + > + {n > 0 + ? `${n} selected` + : `${total}${unreadCount ? ` · ${unreadCount}` : ''}`} + +
+ {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') @@ -332,57 +613,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) @@ -404,7 +649,54 @@ 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()) { + // `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) + 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), @@ -433,17 +725,13 @@ 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) { 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 }) } @@ -484,23 +772,27 @@ 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] }))} />
{tab === 'Email' ? ( - ) : tab === 'Filtered Out' ? ( - ) : ( -
-
+
+
+ {applicationsQuery.isSuccess && ( + + )}
@@ -525,6 +817,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}`} + />
@@ -543,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 @@ -576,9 +873,10 @@ export default function Inbox() { item={selected} loading={detailQuery.isPending} busy={setState.isPending || markDuplicate.isPending} + canEdit={canEdit} + toast={toast} onPreview={() => setPreviewing(selected)} onImport={() => importItem(selected)} - onParse={() => parseResume()} onMove={() => moveToPipeline(selected)} onNote={() => setNoting(selected)} onReject={() => reject(selected)} @@ -657,12 +955,91 @@ function orDash(value, suffix = '') { } function ApplicationDetail({ - item: i, loading, busy, onPreview, onImport, onParse, 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 (
@@ -714,70 +1091,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)) + }} + /> + )}
) } @@ -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 ( - <> -
- Intake gate · OpenAI - - {query.isPending ? 'Loading…' : query.isError ? 'Could not load' : `${total} message${total === 1 ? '' : 's'}`} - -
- {Object.keys(TRIAGE_VIEWS).map((key) => ( - - ))} -
-
- -
- {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() + const { can } = useAuth() + const canEdit = can('inbox.edit') const [selectedId, setSelectedId] = useState(null) const [replying, setReplying] = useState(null) const [replyBody, setReplyBody] = useState('') @@ -944,7 +1364,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: {}, ids: emails.map((e) => e.id) }) + } const sync = useMutation({ mutationFn: () => inboxApi.syncMailbox(), @@ -989,7 +1431,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) @@ -1011,8 +1453,19 @@ function EmailTab({ query, toast }) {
-
-
+
+
+ {query.isSuccess && emails.length > 0 && ( + + )} {query.isPending && Fetching mailbox from the server.} {query.isError && ( @@ -1028,6 +1481,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}
@@ -1039,7 +1497,7 @@ function EmailTab({ query, toast }) { {isImported(e) && Imported}
-
{e.when ? fmtShort(e.when) : '—'}
+
{outlookListTime(e.when)}
))}
diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index 596e300..d8b9a1c 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() @@ -873,8 +742,7 @@ function MatchingWorkspace({ onClick={onAssign} > {selectedPost?.title - ? `Assign to ${selectedPost.title}` - : 'Assign'} + ? 'Assign' :'Assign'}
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 a05f040..e5d2d24 100644 --- a/frontend/src/styles/styles.css +++ b/frontend/src/styles/styles.css @@ -719,7 +719,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; } @@ -857,6 +857,46 @@ canvas { width: 100%; max-width: 100%; display: block; } .ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; } .ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; } +/* Inbox sidebar only: fit the list instead of scrolling sideways. + Username (.ii-name) and subject (.ii-pos) are left alone. */ +.inbox-split { grid-template-columns: minmax(0, 380px) 1fr; } +.inbox-queue { overflow-x: hidden; min-width: 0; } +.inbox-queue .inbox-item { min-width: 0; } +.inbox-queue .ii-meta { flex-wrap: wrap; min-width: 0; } +.inbox-queue .source-chip { + min-width: 0; max-width: 100%; + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; +} +.inbox-queue .toolbar-search { min-width: 0; } +.inbox-bulk-bar { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + flex-wrap: nowrap; + min-width: 0; +} +.inbox-bulk-count { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; + color: var(--text-3); +} +.inbox-bulk-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} +.inbox-bulk-actions .btn-sm { + padding: 5px 8px; + font-size: 12px; + white-space: nowrap; +} /* `--chip` is the source's own brand colour, set inline. It tints the background and fills the dot, while the label stays on theme text so 11px copy keeps its contrast in both modes. */ 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