UI_CHANGES
parent
c106aad567
commit
ee2f0c8ef8
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,67 +1020,233 @@ function ApplicationDetail({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Same Subject-strip + framed-body template as /matching. */}
|
||||
{!loading && (
|
||||
<div style={{ marginBottom: 20 }}>
|
||||
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
|
||||
{looksLikeHtml(i.bodyHtml) ? (
|
||||
<EmailBody html={i.bodyHtml} />
|
||||
) : (
|
||||
<pre className="resume-thumb is-full email-plain">
|
||||
{i.body || 'This email has no message body.'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{i.hasAttachment && (
|
||||
<div className="card" style={{ boxShadow: 'none', background: 'var(--bg-sunken)', marginBottom: 20 }}>
|
||||
<div className="card-body">
|
||||
<div className="flex items-center gap-12" style={{ justifyContent: 'space-between', marginBottom: 12 }}>
|
||||
<div className="fw-600">
|
||||
<Icon name="paperclip" /> {orDash(i.attachment)}
|
||||
{i.files?.[0]?.size != null && (
|
||||
<span className="cell-sub"> · {Math.round(i.files[0].size / 1024)} KB</span>
|
||||
)}
|
||||
{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>
|
||||
<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.'}
|
||||
</pre>
|
||||
<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: 16 }}>
|
||||
<div className="email-head">Subject: {i.position || '(no subject)'}</div>
|
||||
{looksLikeHtml(i.bodyHtml) ? (
|
||||
<EmailBody html={i.bodyHtml} />
|
||||
) : (
|
||||
<pre className="resume-thumb is-full email-plain">
|
||||
{i.body || 'This email has no message body.'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{i.hasAttachment && (
|
||||
<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">
|
||||
<Icon name="paperclip" /> {orDash(i.attachment)}
|
||||
{i.files?.[0]?.size != null && (
|
||||
<span className="cell-sub"> · {Math.round(i.files[0].size / 1024)} KB</span>
|
||||
)}
|
||||
</div>
|
||||
<button className="btn btn-secondary btn-sm" onClick={onPreview}><Icon name="eye" /> Preview</button>
|
||||
</div>
|
||||
<pre className="resume-thumb">
|
||||
{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="Couldn’t 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>
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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()
|
||||
|
|
|
|||
|
|
@ -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="Couldn’t 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>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue