UI_CHANGES
ahmed.mujtaba 2026-08-19 18:46:24 +05:00
parent c106aad567
commit ee2f0c8ef8
7 changed files with 1065 additions and 227 deletions

View File

@ -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,

View File

@ -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(

View File

@ -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:

View File

@ -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. */

View File

@ -9,7 +9,7 @@
now, which is the structural fix.
============================================================ */
import { useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -17,7 +17,9 @@ import Modal from '../ui/Modal'
import EmailBody, { looksLikeHtml } from '../ui/EmailBody'
import { Tabs } from '../ui/Tabs'
import { Avatar, Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives'
import { JobCard, PickRoleModal } from '../ui/SuggestedRoles'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { seedQuery, useSeedMutation } from '../data/seedQueries'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
@ -44,6 +46,17 @@ const TAB_FILTERS = {
Unread: { isread: false },
}
/**
* Tabs whose visible set is EXACTLY what the server returns for TAB_FILTERS[tab].
*
* Only these may use the scope endpoint for "mark all". Processed / Rejected /
* Duplicates narrow client-side over rows the server already handed back in full,
* so a scope call from one of those carries no such predicate and would mark the
* entire mailbox rows the user never saw, with no undo. Those tabs send the
* visible ids instead.
*/
const SERVER_SCOPED_TABS = new Set(['All Applications', 'Unread'])
/** The prototype computed "time ago" against a fixed 2026-07-09T20:00. */
const NOW = new Date('2026-07-09T20:00')
@ -114,14 +127,15 @@ const RESUME_STATUS = {
failed: 'Failed', dlq: 'Failed', skipped: 'Pending',
}
const SHORTLIST_JOB_WARNING = 'Choose one of the suggested jobs above to add this candidate to the shortlist.'
/**
* GET /inbox/fetch?record_id=<pk> -> the detail behind one application row.
*
* Returns serialize_message, a different shape from serialize_application, so it
* is remapped onto the row shape here and OVERLAID on the list row rather than
* replacing it: serialize_message carries the body and the real decoded
* attachments, but omits resume_text, so the list row keeps supplying that.
* suggested_job_post_ids is dropped, same as everywhere else on this page.
* replacing it: serialize_message carries the body, decoded attachments, and
* the hydrated suggested_job_posts / assigned_job_post the role picker needs.
*/
async function fetchMessageDetail(recordId) {
const res = await inboxApi.getMessage(recordId)
@ -154,6 +168,11 @@ async function fetchMessageDetail(recordId) {
matchReasoning: row.match_reasoning || '',
matchError: row.match_error || '',
matchedAt: parseDate(row.matched_at),
resumeText: row.resume_text || '',
suggestedIds: (row.suggested_job_post_ids || []).map(String),
suggestedPosts: Array.isArray(row.suggested_job_posts) ? row.suggested_job_posts : [],
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
assignedPost: row.assigned_job_post || null,
}
}
@ -190,6 +209,8 @@ async function fetchApplications(params) {
experience: row.experience,
recruiter: row.recruiter,
duplicate: Boolean(row.duplicate),
suggestedIds: (row.suggested_job_post_ids || []).map(String),
assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null,
}
})
}
@ -197,46 +218,273 @@ async function fetchApplications(params) {
// Shared with the sidebar badge through qk.mailbox.counts see inboxApi.fetchCounts.
const fetchInboxCounts = inboxApi.fetchCounts
/** 500 is MAX_BULK_READ_IDS in backend/inbox/views.py; a longer list gets a 413. */
const BULK_READ_CHUNK = 500
async function setReadChunked(ids, read) {
let updated = 0
for (let i = 0; i < ids.length; i += BULK_READ_CHUNK) {
// Sequential on purpose. Each chunk is one UPDATE over a few hundred rows of
// the same table; firing them together only puts them in each other's way.
// eslint-disable-next-line no-await-in-loop
const res = await inboxApi.bulkSetRead(ids.slice(i, i + BULK_READ_CHUNK), read)
updated += res?.data?.updated ?? 0
}
return { updated, requested: ids.length }
}
/**
* POST /inbox/{record_id}/read flips message_read false -> true for one row.
*
* Optimistic, so the row un-bolds on click instead of after the round trip, and
* rolls back if the server rejects. Both mailbox caches hold {id, unread} rows,
* so one setQueriesData over qk.mailbox.all() covers the Email tab and the
* application tabs at once; `processing` is derived from the same column, so it
* moves with it.
*
* NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW,
* so a view-only user gets a 403 here and the row snaps back to unread.
* Rewrites ONE cache entry for a read-state flip: a row list, or the
* single-message detail object. Anything else (the counts object) passes through
* untouched it has no `id`, and its delta is applied separately.
*/
function useMarkRead(toast) {
function patchReadState(data, idSet, read) {
const patchRow = (r) => (idSet.has(r.id)
? {
...r,
unread: !read,
// `processing` also carries Imported / Processed / Rejected, which are a
// different column. Only the read-derived pair moves with this one.
processing: r.processing === 'Unread' || r.processing === 'Read'
? (read ? 'Read' : 'Unread')
: r.processing,
}
: r)
if (Array.isArray(data)) return data.map(patchRow)
if (data && typeof data === 'object' && data.id && idSet.has(data.id)) return patchRow(data)
return data
}
/**
* PATCH /inbox/read flips message_read for one row or for two hundred.
*
* The single-row route (POST /inbox/{id}/read) still exists, but every flip goes
* through the bulk one so there is exactly ONE optimistic patch to reason about.
*
* Optimistic, so rows un-bold on click instead of after the round trip, and roll
* back if the server rejects. Both mailbox caches hold {id, unread} rows, so one
* setQueriesData over qk.mailbox.all() covers the Email tab and the application
* tabs at once; `processing` is derived from the same column, so it moves with it.
*
* NOTE: the route requires INBOX_EDIT while the lists only require INBOX_VIEW, so
* a view-only user gets a 403 here and the rows snap back. The bulk bars disable
* themselves for those users; click-to-read cannot, so it still relies on rollback.
*/
function useSetRead(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: (recordId) => inboxApi.markRead(recordId),
onMutate: async (recordId) => {
mutationFn: ({ ids, read }) => setReadChunked(ids, read),
onMutate: async ({ ids, read }) => {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (rows) => (
Array.isArray(rows)
? rows.map((r) => (r.id === recordId
? { ...r, unread: false, processing: r.processing === 'Unread' ? 'Read' : r.processing }
: r))
: rows
))
const idSet = new Set(ids)
// Which rows actually FLIP, counted BEFORE the patch: the counts object is a
// bag of totals with no per-row detail to derive the delta from afterwards.
// A Set, because the same id appears in several cached lists at once.
const flipping = new Set()
for (const [, data] of previous) {
if (!Array.isArray(data)) continue
for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id)
}
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read))
// The Unread tab badge and the sidebar badge share this entry. Without the
// delta they sit stale until the refetch lands invisible for one row,
// glaring for two hundred. `previous` already covers it for rollback,
// because ['mailbox'] is a prefix of ['mailbox','counts'].
qc.setQueryData(qk.mailbox.counts(), (c) => (c
? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) }
: c))
return { previous }
},
onError: (err, _recordId, ctx) => {
onError: (err, _vars, ctx) => {
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
toast(friendlyAuthError(err, 'Could not mark as read.'), 'error')
toast(friendlyAuthError(err, 'Could not update read state.'), 'error')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
})
}
/**
* PATCH /inbox/read-all every row matching a server-side list filter.
*
* Deliberately NOT optimistic: the scope is a WHERE clause, so the client cannot
* know which rows it hit until the count comes back. It reports that count rather
* than guessing, which is also the honest answer when the view was already read.
*/
function useSetReadAll(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }),
onSuccess: (res, { read }) => {
const n = res?.data?.updated ?? 0
toast(
n === 0
? `Nothing to mark ${read ? 'read' : 'unread'} here`
: `${n} ${n === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`,
'success',
)
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update read state.'), 'error'),
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
})
}
/**
* Multi-select over a row list: the Set, the toggles, and the pruning.
*
* The pruning is not optional. Rows leave a list under their own steam the
* Unread tab drops a row the instant it is marked read so a Set left alone
* keeps counting ids that are no longer on screen, and "Mark 12 read" quietly
* acts on 9 of them.
*/
function useRowSelection(rows) {
const [selectedIds, setSelectedIds] = useState(() => new Set())
const visibleIds = useMemo(() => rows.map((r) => r.id), [rows])
useEffect(() => {
setSelectedIds((prev) => {
if (prev.size === 0) return prev
const visible = new Set(visibleIds)
const next = new Set()
for (const id of prev) if (visible.has(id)) next.add(id)
// Same size means nothing was pruned. Returning `prev` keeps the Set
// identity stable, so this effect cannot retrigger itself.
return next.size === prev.size ? prev : next
})
}, [visibleIds])
const toggle = useCallback((id) => setSelectedIds((prev) => {
const next = new Set(prev)
if (next.has(id)) next.delete(id)
else next.add(id)
return next
}), [])
const clear = useCallback(() => setSelectedIds((prev) => (prev.size ? new Set() : prev)), [])
const toggleAll = useCallback(() => setSelectedIds((prev) => (
prev.size >= visibleIds.length ? new Set() : new Set(visibleIds)
)), [visibleIds])
return {
selectedIds,
toggle,
toggleAll,
clear,
allSelected: visibleIds.length > 0 && selectedIds.size === visibleIds.length,
}
}
/**
* The row tick. stopPropagation is load-bearing: without it a tick also runs the
* row's onClick, which opens the detail pane AND auto-marks it read instantly
* undoing the "mark unread" the user is selecting rows for.
*/
function RowCheck({ checked, onToggle, label }) {
return (
<span
className={`checkbox${checked ? ' on' : ''}`}
role="checkbox"
aria-checked={checked}
aria-label={label}
tabIndex={0}
style={{ alignSelf: 'center' }}
onClick={(e) => { e.stopPropagation(); onToggle() }}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault()
e.stopPropagation()
onToggle()
}
}}
>
<Icon name="check" />
</span>
)
}
/**
* Select-all plus the read/unread actions for one list.
*
* Every button names what it will hit "Mark 12 read", or "Mark all read" with
* the scope spelled out in its tooltip rather than a bare "Mark read" whose
* reach depends on selection state the user has to keep in their head. "All" is
* always the CURRENT view, never the whole mailbox from behind a filter.
*/
function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
const { selectedIds, toggleAll, clear, allSelected } = selection
const n = selectedIds.size
const disabled = !canEdit || busy
const tip = !canEdit ? 'Requires inbox.edit' : undefined
const allTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
return (
<div
className="flex items-center gap-12"
style={{ padding: '10px 16px', borderBottom: '1px solid var(--border)', flexWrap: 'wrap' }}
>
<RowCheck
checked={allSelected}
onToggle={toggleAll}
label={allSelected ? 'Clear selection' : 'Select all'}
/>
<span className="text-muted text-sm">
{n > 0 ? `${n} selected` : `${total} ${total === 1 ? 'message' : 'messages'}`}
</span>
<div className="flex gap-8" style={{ marginLeft: 'auto', flexWrap: 'wrap' }}>
{n > 0 ? (
<>
<button
className="btn btn-secondary btn-sm"
disabled={disabled}
title={tip}
onClick={() => onSetRead(true)}
>
<Icon name="check" /> Mark {n} read
</button>
<button
className="btn btn-secondary btn-sm"
disabled={disabled}
title={tip}
onClick={() => onSetRead(false)}
>
<Icon name="mail" /> Mark {n} unread
</button>
<button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>
</>
) : (
<>
<button
className="btn btn-secondary btn-sm"
disabled={disabled || total === 0}
title={allTip}
onClick={() => onSetAllRead(true)}
>
<Icon name="check" /> Mark all read
</button>
<button
className="btn btn-secondary btn-sm"
disabled={disabled || total === 0}
title={allTip}
onClick={() => onSetAllRead(false)}
>
<Icon name="mail" /> Mark all unread
</button>
</>
)}
</div>
</div>
)
}
export default function Inbox() {
const { toast } = useToast()
const navigate = useNavigate()
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const { data: recruiters = [] } = useQuery(seedQuery('recruiters'))
const updateInbox = useSeedMutation('inbox')
@ -331,7 +579,53 @@ export default function Inbox() {
? { ...selectedRow, ...(detailQuery.data ?? {}) }
: null
const markRead = useMarkRead(toast)
// Bound to `list`, not `inbox`: the tick boxes sit on the rows the user can
// actually see, and the hook prunes the Set as that set changes.
const selection = useRowSelection(list)
const setRead = useSetRead(toast)
const setReadAll = useSetReadAll(toast)
/**
* What "Mark all" means here, in words, for the button tooltip. Kept next to
* setReadEverything so the label and the scope cannot drift apart.
*/
const scopeLabel = q.trim()
? `the ${list.length} row${list.length === 1 ? '' : 's'} matching this search`
: tab === 'All Applications'
? 'every application'
: `the ${tab} tab`
function setReadSelected(read) {
const ids = [...selection.selectedIds]
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success')
selection.clear()
},
})
}
function setReadEverything(read) {
// The scope endpoint may only be used where the server-side filter IS the
// view. The search box narrows client-side on name/position/source while the
// server's `search` matches subject/from/body, and three of the tabs narrow
// client-side entirely handing either to a WHERE clause would mark rows
// that were never on screen. Those cases send the visible ids instead, which
// is exact and, since the list endpoint is unpaginated, complete.
if (SERVER_SCOPED_TABS.has(tab) && !q.trim()) {
setReadAll.mutate({ read, filter: tabFilter })
return
}
const ids = list.map((i) => i.id)
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success')
selection.clear()
},
})
}
const setState = useMutation({
mutationFn: ({ id, state }) => inboxApi.setProcessingState(id, state),
@ -360,7 +654,7 @@ export default function Inbox() {
function select(id) {
setSelectedId(id)
const item = inbox.find((i) => i.id === id)
if (item?.unread) markRead.mutate(id)
if (item?.unread) setRead.mutate({ ids: [id], read: true })
}
function importItem(item) {
@ -407,7 +701,7 @@ export default function Inbox() {
<div style={{ margin: '0 16px', paddingTop: 8 }}>
<Tabs
value={tab}
onChange={(t) => { setTab(t); setSelectedId(null) }}
onChange={(t) => { setTab(t); setSelectedId(null); selection.clear() }}
tabs={TABS.map((t) => ({ key: t, label: t, count: counts[t] }))}
/>
</div>
@ -423,6 +717,17 @@ export default function Inbox() {
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search applications…" />
</div>
</div>
{applicationsQuery.isSuccess && (
<BulkReadBar
total={list.length}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}
busy={setRead.isPending || setReadAll.isPending}
canEdit={canEdit}
scopeLabel={scopeLabel}
/>
)}
<div>
{applicationsQuery.isPending && (
<EmptyState icon="inbox" title="Loading…">Fetching applications from the server.</EmptyState>
@ -441,6 +746,11 @@ export default function Inbox() {
className={`inbox-item${i.unread ? ' unread' : ''}${selectedId === i.id ? ' active' : ''}`}
onClick={() => select(i.id)}
>
<RowCheck
checked={selection.selectedIds.has(i.id)}
onToggle={() => selection.toggle(i.id)}
label={`Select ${i.name}`}
/>
<Avatar name={i.name} initials={i.initials} color={i.color} />
<div className="ii-main">
<div className="ii-name">
@ -492,6 +802,8 @@ export default function Inbox() {
item={selected}
loading={detailQuery.isPending}
busy={setState.isPending || markDuplicate.isPending}
canEdit={canEdit}
toast={toast}
onPreview={() => setPreviewing(selected)}
onImport={() => importItem(selected)}
onMove={() => moveToPipeline(selected)}
@ -572,12 +884,91 @@ function orDash(value, suffix = '') {
}
function ApplicationDetail({
item: i, loading, busy, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate,
item: i, loading, busy, canEdit, toast, onPreview, onImport, onMove, onNote, onReject, onToggleDuplicate,
}) {
const navigate = useNavigate()
const qc = useQueryClient()
const recLabel = i.atsScore >= 82 ? 'Strong Match' : i.atsScore >= 65 ? 'Potential Match' : 'Weak Match'
const ringColor = i.atsScore >= 82 ? 'var(--success)' : i.atsScore >= 65 ? 'var(--warning)' : 'var(--danger)'
const [selection, setSelection] = useState(null)
const [manualPost, setManualPost] = useState(null)
const [showPicker, setShowPicker] = useState(false)
const [whyOpen, setWhyOpen] = useState(false)
// Do not auto-pick the first suggestion: Shortlist stays locked until the
// recruiter actually chooses a card (or the row already has an assigned job).
useEffect(() => {
setManualPost(null)
setWhyOpen(false)
setSelection(i.assignedId || null)
}, [i.id, i.assignedId])
const suggestionCards = useMemo(() => {
const byId = new Map((i.suggestedPosts || []).map((p) => [String(p.id), p]))
return (i.suggestedIds || []).map((id, idx) => ({
rank: idx + 1,
post: byId.get(id) || { id, unavailable: true },
}))
}, [i.suggestedPosts, i.suggestedIds])
const selectedPost = useMemo(() => {
if (!selection) return null
if (manualPost && String(manualPost.id) === String(selection)) return manualPost
if (i.assignedPost && String(i.assignedPost.id) === String(selection)) return i.assignedPost
const hit = suggestionCards.find((c) => String(c.post.id) === String(selection))
return hit?.post || null
}, [selection, manualPost, i.assignedPost, suggestionCards])
const assignMutation = useMutation({
mutationFn: ({ recordId, jobPostId }) => inboxApi.assignJobPost(recordId, jobPostId),
onError: (err) => toast(friendlyAuthError(err, 'Could not assign job post.'), 'error'),
onSuccess: (_res, vars) => {
const title = selectedPost?.title || 'role'
if (vars.jobPostId) toast(`${i.name}${title}`, 'success')
else toast(`${i.name} unassigned`, 'success')
},
onSettled: (_res, _err, vars) => {
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
qc.invalidateQueries({ queryKey: qk.mailbox.message(vars.recordId) })
},
})
const rematchMutation = useMutation({
mutationFn: (recordId) => inboxApi.rematch(recordId),
onError: (err) => toast(friendlyAuthError(err, 'Could not retry match.'), 'error'),
onSuccess: () => toast('Match re-queued', 'success'),
onSettled: (_r, _e, recordId) => {
qc.invalidateQueries({ queryKey: qk.mailbox.message(recordId) })
qc.invalidateQueries({ queryKey: qk.mailbox.all() })
},
})
const jobChosen = Boolean(i.assignedId || selection)
const alreadyProcessed = i.processing === 'Processed'
const shortlistLocked = !alreadyProcessed && !jobChosen
const matchFailed = ['failed', 'no_text', 'dlq'].includes(i.matchStatus)
const resumeText = i.resumeText || ''
const assigned = i.assignedPost
const panelBusy = busy || assignMutation.isPending || rematchMutation.isPending
const canAssign = canEdit && selection && selection !== i.assignedId && !assignMutation.isPending
async function handleMove() {
if (busy || alreadyProcessed) return
if (!jobChosen) {
toast(SHORTLIST_JOB_WARNING, 'warning')
return
}
const jobId = selection || i.assignedId
if (jobId && String(jobId) !== String(i.assignedId || '')) {
try {
await assignMutation.mutateAsync({ recordId: i.id, jobPostId: jobId })
} catch {
return
}
}
onMove()
}
return (
<div style={{ padding: 24 }}>
<div className="flex items-center gap-16" style={{ marginBottom: 20 }}>
@ -629,9 +1020,60 @@ function ApplicationDetail({
)}
</div>
{/* Same Subject-strip + framed-body template as /matching. */}
{assigned && (
<div
className="card"
style={{
boxShadow: 'none',
background: 'var(--primary-soft)',
border: '1px solid var(--primary-border)',
marginBottom: 18,
}}
>
<div className="card-body flex items-center gap-12" style={{ justifyContent: 'space-between', flexWrap: 'wrap' }}>
<div className="flex items-center gap-8">
<Icon name="check-circle" />
<div>
<div>Assigned to <b>{assigned.title}</b></div>
<div className="cell-sub">
{[assigned.employment_type, assigned.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
</div>
<div className="flex gap-8">
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Change
</button>
<button
className="btn btn-ghost btn-sm"
disabled={!canEdit || assignMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => assignMutation.mutate({ recordId: i.id, jobPostId: null })}
>
Unassign
</button>
</div>
</div>
</div>
)}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 18,
alignItems: 'start',
marginBottom: 20,
}}
>
<div style={{ flex: '1 1 320px', minWidth: 0 }}>
{!loading && (
<div style={{ marginBottom: 20 }}>
<div style={{ marginBottom: 16 }}>
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
{looksLikeHtml(i.bodyHtml) ? (
<EmailBody html={i.bodyHtml} />
@ -644,7 +1086,7 @@ function ApplicationDetail({
)}
{i.hasAttachment && (
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)' }}>
<div className="card-body">
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
<div className="fw-600">
@ -656,40 +1098,155 @@ function ApplicationDetail({
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
</div>
<pre className="resume-thumb">
{i.resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
{resumeText || 'Resume text not extracted yet — the matching task has not run for this application.'}
</pre>
</div>
</div>
)}
</div>
<div role="radiogroup" aria-label="Suggested roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
{matchFailed ? (
<div className="alert alert-danger" style={{ marginBottom: 16 }}>
<div style={{ marginBottom: 8 }}>{i.matchError || 'Matching failed for this application.'}</div>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematchMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => rematchMutation.mutate(i.id)}
>
<Icon name="sparkles" /> Retry match
</button>
</div>
) : (
<div style={{ marginBottom: 16 }}>
<div className="fw-600" style={{ marginBottom: 6 }}>AI verdict</div>
<p style={{ marginBottom: 4 }}>{i.matchSummary || 'No match summary yet.'}</p>
{i.matchedAt && (
<div className="cell-sub">Matched {fmtDate(i.matchedAt)}</div>
)}
{i.matchReasoning && (
<button
className="btn btn-ghost btn-sm"
style={{ marginTop: 8, paddingLeft: 0 }}
onClick={() => setWhyOpen((v) => !v)}
>
{whyOpen ? '▾' : '▸'} Why these roles?
</button>
)}
{whyOpen && (
<p className="text-muted text-sm" style={{ marginTop: 8 }}>{i.matchReasoning}</p>
)}
</div>
)}
<div className="fw-600" style={{ marginBottom: 8 }}>Suggested roles</div>
{suggestionCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No suggested roles">
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-secondary btn-sm"
disabled={!canEdit || rematchMutation.isPending}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => rematchMutation.mutate(i.id)}
>
Retry match
</button>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a role
</button>
</div>
</EmptyState>
) : (
suggestionCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
resumeText={resumeText}
/>
)}
<button
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a different role
</button>
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
{selectedPost?.title ? `Assign to ${selectedPost.title}` : 'Assign'}
</button>
</div>
</div>
<div className="flex gap-8" style={{ flexWrap: 'wrap' }}>
<button className="btn btn-primary" onClick={onImport} disabled={busy || i.processing === 'Imported'}>
<button className="btn btn-primary" onClick={onImport} disabled={panelBusy || i.processing === 'Imported'}>
<Icon name="user-plus" /> {i.processing === 'Imported' ? 'Imported' : 'Import Candidate'}
</button>
<button
className="btn btn-secondary"
onClick={() => navigate(`/matching?record=${i.id}`)}
aria-disabled={shortlistLocked || panelBusy || alreadyProcessed}
disabled={panelBusy || alreadyProcessed}
style={shortlistLocked ? { opacity: 0.55, cursor: 'not-allowed' } : undefined}
title={shortlistLocked ? SHORTLIST_JOB_WARNING : undefined}
onClick={handleMove}
>
<Icon name="target" /> Assign Job
</button>
<button className="btn btn-secondary" onClick={onMove} disabled={busy || i.processing === 'Processed'}>
<Icon name="layers" /> Move to Pipeline
<Icon name="layers" /> Move to Shortlist
</button>
<button className="btn btn-secondary" onClick={onNote} disabled title="Notes attach to a candidate profile — open the candidate first">
<Icon name="edit" /> Add Note
</button>
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={busy}>
<button className="btn btn-secondary" onClick={onToggleDuplicate} disabled={panelBusy}>
<Icon name="alert" /> {i.duplicate ? 'Clear Duplicate' : 'Mark Duplicate'}
</button>
<button
className="btn btn-ghost"
style={{ color: 'var(--danger)' }}
onClick={onReject}
disabled={busy || i.processing === 'Rejected'}
disabled={panelBusy || i.processing === 'Rejected'}
>
<Icon name="trash" /> Reject
</button>
</div>
{showPicker && (
<PickRoleModal
onClose={() => setShowPicker(false)}
onPick={(post) => {
setManualPost(post)
setSelection(String(post.id))
}}
/>
)}
</div>
)
}
@ -725,6 +1282,8 @@ function AssignRecruiter({ item, recruiters, onClose, onSave }) {
/** The live tab: real fetch, real loading state, real error state. */
function EmailTab({ query, toast }) {
const qc = useQueryClient()
const { can } = useAuth()
const canEdit = can('inbox.edit')
const [selectedId, setSelectedId] = useState(null)
const [replying, setReplying] = useState(null)
const [replyBody, setReplyBody] = useState('')
@ -734,7 +1293,29 @@ function EmailTab({ query, toast }) {
const selected = emails.find((e) => e.id === selectedId)
const unread = emails.filter((e) => e.unread).length
const markRead = useMarkRead(toast)
const selection = useRowSelection(emails)
const setRead = useSetRead(toast)
const setReadAll = useSetReadAll(toast)
function setReadSelected(read) {
const ids = [...selection.selectedIds]
if (!ids.length) return
setRead.mutate({ ids, read }, {
onSuccess: () => {
toast(`${ids.length} ${ids.length === 1 ? 'message' : 'messages'} marked ${read ? 'read' : 'unread'}`, 'success')
selection.clear()
},
})
}
/**
* This list is /inbox/fetch with no params every persisted message, no
* filter, no pagination so the empty scope really is the whole mailbox and
* the WHERE clause matches what is on screen exactly.
*/
function setReadEverything(read) {
setReadAll.mutate({ read, filter: {} })
}
const sync = useMutation({
mutationFn: () => inboxApi.syncMailbox(),
@ -779,7 +1360,7 @@ function EmailTab({ query, toast }) {
function selectEmail(e) {
setSelectedId(e.id)
if (e.unread) markRead.mutate(e.id)
if (e.unread) setRead.mutate({ ids: [e.id], read: true })
}
const isImported = (e) => importedIds.has(e.id)
@ -803,6 +1384,17 @@ function EmailTab({ query, toast }) {
<div className="split">
<div className="split-list">
{query.isSuccess && emails.length > 0 && (
<BulkReadBar
total={emails.length}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}
busy={setRead.isPending || setReadAll.isPending}
canEdit={canEdit}
scopeLabel="every message in the mailbox"
/>
)}
{query.isPending && <EmptyState icon="mail" title="Loading…">Fetching mailbox from the server.</EmptyState>}
{query.isError && (
<EmptyState icon="mail" title="Couldnt load mailbox">
@ -818,6 +1410,11 @@ function EmailTab({ query, toast }) {
className={`inbox-item${e.unread ? ' unread' : ''}${selectedId === e.id ? ' active' : ''}`}
onClick={() => selectEmail(e)}
>
<RowCheck
checked={selection.selectedIds.has(e.id)}
onToggle={() => selection.toggle(e.id)}
label={`Select mail from ${e.from}`}
/>
<Avatar name={e.from} />
<div className="ii-main">
<div className="ii-name">{e.from}</div>

View File

@ -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 <Badge className="b-amber">No match</Badge>
}
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 (
<div
role="radio"
aria-checked={selected}
tabIndex={0}
className="list-row"
onClick={() => !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',
}}
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
<Badge className="b-gray">Unavailable</Badge>
) : (
<Badge>{post.status || 'draft'}</Badge>
)}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && (post.requirements || []).length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{(post.requirements || []).slice(0, 8).map((req) => {
const hit = reqInResume(req, resumeText)
return (
<span
key={req}
className="tag"
style={hit ? {
background: 'var(--success-soft)',
color: 'var(--success-fg)',
} : undefined}
>
{req}
</span>
)
})}
</div>
)}
</div>
</div>
)
}
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 (
<Modal
title="Choose a different role"
subtitle="Search open job posts"
size="modal-lg"
onClose={onClose}
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
>
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
</div>
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
{isError && (
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(error, 'Request failed')}
</EmptyState>
)}
{!isPending && !isError && data.length === 0 && (
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
)}
<div className="list-tight">
{data.map((p) => (
<div
key={p.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => { onPick(p); onClose() }}
>
<div className="lr-main">
<div className="lr-title">{p.title}</div>
<div className="lr-sub">
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge>{p.status}</Badge>
</div>
))}
</div>
</Modal>
)
}
export default function Matching() {
const { toast } = useToast()
const { can } = useAuth()

View File

@ -0,0 +1,147 @@
/* ============================================================
Suggested-role picker shared by Job Matching and Recruitment Inbox.
JobCard is the AI/manual radiogroup row. PickRoleModal is the "choose a
different role" search popup. Requirement chips light green when the
resume text contains them (client-side substring, same as Matching).
============================================================ */
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import Modal from './Modal'
import { Badge, EmptyState, Icon } from './primitives'
import { friendlyAuthError } from '../lib/errors'
import { qk } from '../lib/queryKeys'
import * as jobPostsApi from '../api/jobPosts'
/** Requirement chip lights green when the resume text contains it (client-side). */
export function reqInResume(req, resumeText) {
if (!req || !resumeText) return false
const needle = String(req).trim().toLowerCase()
if (!needle) return false
return resumeText.toLowerCase().includes(needle)
}
export function JobCard({ post, rank, selected, onSelect, resumeText, manual }) {
const unavailable = Boolean(post?.unavailable) || !post?.title
const title = post?.title || 'Unavailable'
const meta = [
post?.employment_type,
post?.location,
post?.experience_min != null || post?.experience_max != null
? `${post?.experience_min ?? '?'}${post?.experience_max ?? '?'} yrs`
: null,
].filter(Boolean).join(' · ')
return (
<div
role="radio"
aria-checked={selected}
tabIndex={0}
className="list-row"
onClick={() => !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',
}}
>
<div className="lr-main" style={{ minWidth: 0 }}>
<div className="flex items-center gap-8" style={{ marginBottom: 4, flexWrap: 'wrap' }}>
<span className="tag">{manual ? 'Manual' : `AI #${rank}`}</span>
<div className="lr-title">{title}</div>
{unavailable ? (
<Badge className="b-gray">Unavailable</Badge>
) : (
<Badge>{post.status || 'draft'}</Badge>
)}
{selected && <Icon name="check-circle" />}
</div>
{meta && <div className="cell-sub">{meta}</div>}
{!unavailable && (post.requirements || []).length > 0 && (
<div className="k-tags" style={{ marginTop: 8 }}>
{(post.requirements || []).slice(0, 8).map((req) => {
const hit = reqInResume(req, resumeText)
return (
<span
key={req}
className="tag"
style={hit ? {
background: 'var(--success-soft)',
color: 'var(--success-fg)',
} : undefined}
>
{req}
</span>
)
})}
</div>
)}
</div>
</div>
)
}
export function PickRoleModal({ onClose, onPick }) {
const [q, setQ] = useState('')
const { data = [], isPending, isError, error } = useQuery({
queryKey: qk.jobPosts.list({ search: q || undefined, top: 30 }),
queryFn: async () => {
const res = await jobPostsApi.list({ search: q || undefined, top: 30 })
return Array.isArray(res?.data) ? res.data : []
},
})
return (
<Modal
title="Choose a different role"
subtitle="Search open job posts"
size="modal-lg"
onClose={onClose}
footer={<button className="btn btn-secondary" onClick={onClose}>Cancel</button>}
>
<div className="toolbar-search" style={{ maxWidth: 'none', marginBottom: 14 }}>
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search title or location…" autoFocus />
</div>
{isPending && <EmptyState icon="briefcase" title="Loading…">Fetching job posts.</EmptyState>}
{isError && (
<EmptyState icon="alert" title="Couldnt load roles">
{friendlyAuthError(error, 'Request failed')}
</EmptyState>
)}
{!isPending && !isError && data.length === 0 && (
<EmptyState icon="briefcase" title="No roles found">Try a different search.</EmptyState>
)}
<div className="list-tight">
{data.map((p) => (
<div
key={p.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => { onPick(p); onClose() }}
>
<div className="lr-main">
<div className="lr-title">{p.title}</div>
<div className="lr-sub">
{[p.employment_type, p.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
<Badge>{p.status}</Badge>
</div>
))}
</div>
</Modal>
)
}