UI_CHANGES #18
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
platform: str = Field(default="")
|
||||
created_by: uuid.UUID | None = Field(default=None, foreign_key="users.id")
|
||||
experience: str = Field(default="")
|
||||
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Applied.
|
||||
# Candidate_application_Status value (PENDING, SCREENING, …). Empty reads as Shortlist.
|
||||
status: str = Field(default="")
|
||||
# Free text, not a users FK: a referrer is often someone outside the system
|
||||
referral_by: str = Field(default="", sa_column_kwargs={"server_default": ""})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
"preview": "vite preview",
|
||||
"smoke": "node smoke.test.mjs",
|
||||
"test:token": "node token.test.mjs",
|
||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs"
|
||||
"test:theme": "node theme.test.mjs",
|
||||
"verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.101.4",
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -17,15 +17,15 @@ import { request } from '../lib/apiClient'
|
|||
*
|
||||
* The enum has 11 values and the board 7 columns, so this is deliberately
|
||||
* many-to-one: CLOSED is the column DEFAULT (untriaged, never mailed anywhere)
|
||||
* and reads as Applied rather than as an outcome, ONHOLD parks in Screening, and
|
||||
* and reads as Shortlist rather than as an outcome, ONHOLD parks in Screening, and
|
||||
* APPROVED is the pre-HIRED spelling of a hire.
|
||||
*
|
||||
* Anything unmapped falls through to Applied rather than vanishing from the
|
||||
* Anything unmapped falls through to Shortlist rather than vanishing from the
|
||||
* board — a card with no column is a candidate nobody sees.
|
||||
*/
|
||||
export const STAGE_FROM_STATUS = {
|
||||
PENDING: 'Applied',
|
||||
CLOSED: 'Applied',
|
||||
PENDING: 'Shortlist',
|
||||
CLOSED: 'Shortlist',
|
||||
PROCESS: 'Screening',
|
||||
ONHOLD: 'Screening',
|
||||
SCREENING: 'Screening',
|
||||
|
|
@ -41,10 +41,10 @@ export const STAGE_FROM_STATUS = {
|
|||
* Column -> the status WRITTEN on a drop. Not the inverse of the map above: the
|
||||
* legacy spellings (PROCESS, ONHOLD, APPROVED, CLOSED) are readable but are
|
||||
* never written, so the vocabulary converges on the canonical value as cards get
|
||||
* moved. Applied writes PENDING because the enum has no APPLIED member.
|
||||
* moved. Shortlist writes PENDING because the enum has no SHORTLIST member.
|
||||
*/
|
||||
export const STATUS_FROM_STAGE = {
|
||||
Applied: 'PENDING',
|
||||
Shortlist: 'PENDING',
|
||||
Screening: 'SCREENING',
|
||||
Assessment: 'ASSESSMENT',
|
||||
Interview: 'INTERVIEW',
|
||||
|
|
@ -85,12 +85,12 @@ export function listApplications({ jobId, limit, offset } = {}) {
|
|||
|
||||
/**
|
||||
* Fold the 11 status counts into the 7 board columns. Unmapped keys (UNKNOWN)
|
||||
* land in Applied, same as STAGE_FROM_STATUS's card fallback.
|
||||
* land in Shortlist, same as STAGE_FROM_STATUS's card fallback.
|
||||
*/
|
||||
export function toStageCounts(byStatus) {
|
||||
const counts = Object.fromEntries(Object.keys(STATUS_FROM_STAGE).map((name) => [name, 0]))
|
||||
for (const [status, n] of Object.entries(byStatus || {})) {
|
||||
const stage = STAGE_FROM_STATUS[status] ?? 'Applied'
|
||||
const stage = STAGE_FROM_STATUS[status] ?? 'Shortlist'
|
||||
counts[stage] = (counts[stage] ?? 0) + (n || 0)
|
||||
}
|
||||
return counts
|
||||
|
|
@ -179,7 +179,7 @@ export function toBoardCard(row, kind = 'inbox') {
|
|||
userId: row.user_id ?? null,
|
||||
name: row.name || row.email || 'Unknown',
|
||||
email: row.email ?? null,
|
||||
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
|
||||
stage: STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist',
|
||||
status: row.application_status ?? null,
|
||||
experience: row.experience || null,
|
||||
aiScore: row.ats_result?.overall_score ?? null,
|
||||
|
|
|
|||
|
|
@ -137,7 +137,7 @@ export function reply(prompt, { candidates, recruiters }) {
|
|||
<p><b>Pipeline health analysis</b></p>
|
||||
<ul>
|
||||
<li>{candidates.length} active candidates across 6 stages</li>
|
||||
<li>Conversion Applied → Interview: ~28%</li>
|
||||
<li>Conversion Shortlist → Interview: ~28%</li>
|
||||
<li>Bottleneck detected at <b>Assessment</b> stage (longest dwell time)</li>
|
||||
<li>Offer acceptance trending at 82%</li>
|
||||
</ul>
|
||||
|
|
|
|||
|
|
@ -33,7 +33,7 @@ export const TODAY = new Date('2026-07-09T09:00:00');
|
|||
const grades = ['L2', 'L3', 'L4', 'L5', 'L6', 'L7'];
|
||||
const jobStatuses = ['Open', 'On Hold', 'Closed', 'Draft'];
|
||||
const educationLevels = ["Bachelor's Degree", "Master's Degree", "PhD", "Associate Degree", "High School"];
|
||||
const stages = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected'];
|
||||
const stages = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired', 'Rejected'];
|
||||
const sources = ['LinkedIn', 'Company Site', 'Referral', 'Indeed', 'Job Fair', 'Agency', 'GitHub', 'AngelList'];
|
||||
const companies = ['Stripe', 'Airbnb', 'Datadog', 'Notion', 'Figma', 'Shopify', 'Snowflake', 'Twilio', 'Coinbase', 'Atlassian', 'Asana', 'Ramp', 'Brex', 'Vercel', 'Retool', 'Amplitude', 'Segment', 'MongoDB', 'HashiCorp', 'Cloudflare'];
|
||||
|
||||
|
|
@ -117,7 +117,7 @@ export const TODAY = new Date('2026-07-09T09:00:00');
|
|||
// ---------- Candidates ----------
|
||||
const openJobs = jobs.filter(j => j.status === 'Open');
|
||||
const candidates = [];
|
||||
const stageWeights = ['Applied', 'Applied', 'Applied', 'Screening', 'Screening', 'Assessment', 'Interview', 'Interview', 'Offer', 'Hired', 'Rejected', 'Rejected'];
|
||||
const stageWeights = ['Shortlist', 'Shortlist', 'Shortlist', 'Screening', 'Screening', 'Assessment', 'Interview', 'Interview', 'Offer', 'Hired', 'Rejected', 'Rejected'];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const name = fullName();
|
||||
const job = pick(openJobs.length ? openJobs : jobs);
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export function useApplications() {
|
|||
email: row.email ?? null,
|
||||
jobTitle: row.title ?? null,
|
||||
jobPostId: row.assigned_job_post_id ?? null,
|
||||
stage: pipelineApi.STAGE_FROM_STATUS[row.application_status] ?? 'Applied',
|
||||
stage: pipelineApi.STAGE_FROM_STATUS[row.application_status] ?? 'Shortlist',
|
||||
}))
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -17,18 +17,22 @@ import Modal from '../ui/Modal'
|
|||
import { Pagination, useDataTable } from '../ui/DataTable'
|
||||
import { Avatar, Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import CandidateProfile, { useJobTitles } from './ScoredCandidateProfile'
|
||||
import CandidateProfile from './CandidateProfile'
|
||||
import { useJobTitles } from './ScoredCandidateProfile'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as candidatesApi from '../api/candidates'
|
||||
import * as jobPostsApi from '../api/jobPosts'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import { useFormState } from '../components/AuthLayout'
|
||||
import { persist } from '../data/seedQueries'
|
||||
import { persist, useSeedMutation } from '../data/seedQueries'
|
||||
import { avatarColor, fmtDate, initials as initialsOf, sources, stages } from '../data/seed'
|
||||
|
||||
const EMPTY_FILTERS = { account: '' }
|
||||
|
||||
/** Same ladder Talent Pool uses for Advance Stage on the shared profile modal. */
|
||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
|
||||
/** The seeded `candidate` role (backend/role/models.py::EnumRoles). */
|
||||
const CANDIDATE_ROLE_ID = 8
|
||||
|
||||
|
|
@ -41,8 +45,8 @@ const CANDIDATE_ROLE_ID = 8
|
|||
them have.
|
||||
|
||||
The consequence is that the ATS columns have no source on this screen — see
|
||||
toCandidateUserView. Open a candidate to get their score, which
|
||||
ScoredCandidateProfile still reads from the scored endpoint. */
|
||||
toCandidateUserView. Open a candidate to get their score, which the shared
|
||||
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
|
||||
async function fetchCandidates() {
|
||||
const res = await candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
|
|
@ -93,6 +97,7 @@ export default function Candidates() {
|
|||
const qc = useQueryClient()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const updateCandidates = useSeedMutation('candidates')
|
||||
|
||||
const candidatesQuery = useQuery({ queryKey: qk.candidates.list(), queryFn: fetchCandidates })
|
||||
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
||||
|
|
@ -121,6 +126,16 @@ export default function Candidates() {
|
|||
[jobsById],
|
||||
)
|
||||
|
||||
/* Same click-time score fetch Talent Pool uses: GET /pipeline/candidate/score/fetch
|
||||
only while the profile modal is open, cached per userId. */
|
||||
const scoreQuery = useQuery({
|
||||
queryKey: qk.pipeline.candidateScore({ userId: profileFor?.userId ?? null }),
|
||||
queryFn: () => pipelineApi.fetchCandidateScore({ userId: profileFor.userId }),
|
||||
select: pipelineApi.toAtsScore,
|
||||
enabled: Boolean(profileFor?.userId),
|
||||
})
|
||||
const atsScore = scoreQuery.data?.overall_score ?? null
|
||||
|
||||
/* The relevance blend (score + matched-skill ratio + recency) went with the
|
||||
scoring columns — none of its three inputs exists on a users row. */
|
||||
|
||||
|
|
@ -181,7 +196,6 @@ export default function Candidates() {
|
|||
{ key: 'email', label: 'Email', sortable: true },
|
||||
{ key: 'isActive', label: 'Account', sortable: true },
|
||||
{ key: 'applied', label: 'Added', sortable: true },
|
||||
{ key: '_a', label: 'Actions', align: 'right' },
|
||||
],
|
||||
[],
|
||||
)
|
||||
|
|
@ -208,6 +222,24 @@ export default function Candidates() {
|
|||
setAtsFor(c)
|
||||
}
|
||||
|
||||
function toggleFav(c) {
|
||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, favorite: !x.favorite } : x)))
|
||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, favorite: !p.favorite } : p))
|
||||
toast(c.favorite ? 'Removed from favorites' : `${c.name} added to favorites`, 'success')
|
||||
}
|
||||
|
||||
function advance(c) {
|
||||
const i = STAGE_ORDER.indexOf(c.stage)
|
||||
if (i === -1 || i >= STAGE_ORDER.length - 1) {
|
||||
toast(`${c.name} cannot be advanced further`, 'warning')
|
||||
return
|
||||
}
|
||||
const stage = STAGE_ORDER[i + 1]
|
||||
updateCandidates((cs) => cs.map((x) => (x.id === c.id ? { ...x, stage, status: stage } : x)))
|
||||
setProfileFor((p) => (p && p.id === c.id ? { ...p, stage, status: stage } : p))
|
||||
toast(`${c.name} moved to ${stage}`, 'success')
|
||||
}
|
||||
|
||||
/* After a manual add, the CV goes through the same persisted scoring pipeline
|
||||
CV Import and the profile ATS match use (POST /candidate/score): the score
|
||||
lands in the scored `candidates` table, and re-uploading the same bytes
|
||||
|
|
@ -344,7 +376,11 @@ export default function Candidates() {
|
|||
</tr>
|
||||
) : (
|
||||
t.pageRows.map((c) => (
|
||||
<tr key={c.id}>
|
||||
<tr
|
||||
key={c.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => openProfile(c)}
|
||||
>
|
||||
<td>
|
||||
<div className="user-cell">
|
||||
<Avatar name={c.name} initials={initialsOf(c.name)} color={avatarColor(c.name)} />
|
||||
|
|
@ -367,11 +403,6 @@ export default function Candidates() {
|
|||
{c.applied ? c.applied.toLocaleDateString() : '—'}
|
||||
</span>
|
||||
</td>
|
||||
<td style={{ textAlign: 'right' }}>
|
||||
<div className="row-actions">
|
||||
<button className="act-btn" data-tip="Profile" onClick={() => openProfile(c)}><Icon name="eye" /></button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
|
|
@ -394,9 +425,18 @@ export default function Candidates() {
|
|||
|
||||
{profileFor && (
|
||||
<CandidateProfile
|
||||
candidate={candidates.find((c) => c.id === profileFor.id) ?? profileFor}
|
||||
jobTitle={jobTitleOf(profileFor)}
|
||||
candidate={{
|
||||
...(candidates.find((c) => c.id === profileFor.id) ?? profileFor),
|
||||
initials: initialsOf(profileFor.name),
|
||||
color: avatarColor(profileFor.name),
|
||||
stage: profileFor.stage || 'Shortlist',
|
||||
userId: profileFor.userId || profileFor.id,
|
||||
}}
|
||||
atsScore={atsScore}
|
||||
recommendation={scoreQuery.data?.band ?? null}
|
||||
onClose={() => setProfileFor(null)}
|
||||
onAdvance={advance}
|
||||
onToggleFav={toggleFav}
|
||||
onAtsMatch={(c) => { setProfileFor(null); openAts(c) }}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -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()
|
||||
|
|
@ -873,8 +742,7 @@ function MatchingWorkspace({
|
|||
onClick={onAssign}
|
||||
>
|
||||
{selectedPost?.title
|
||||
? `Assign to ${selectedPost.title}`
|
||||
: 'Assign'}
|
||||
? 'Assign' :'Assign'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ import * as pipelineApi from '../api/pipeline'
|
|||
|
||||
/* Stage colours reference CSS tokens so the board re-tints with the theme. */
|
||||
export const KANBAN_STAGES = [
|
||||
{ name: 'Applied', color: 'var(--stage-1)' },
|
||||
{ name: 'Shortlist', color: 'var(--stage-1)' },
|
||||
{ name: 'Screening', color: 'var(--stage-2)' },
|
||||
{ name: 'Assessment', color: 'var(--stage-3)' },
|
||||
{ name: 'Interview', color: 'var(--stage-4)' },
|
||||
|
|
|
|||
|
|
@ -45,8 +45,8 @@ const RANGES = [
|
|||
/* Order matters: "reached" is a running sum from the end of this list back to
|
||||
the start. REJECTED is deliberately absent — see the header note. */
|
||||
const FUNNEL_ORDER = [
|
||||
{ key: 'PENDING', label: 'Applied' },
|
||||
{ key: 'CLOSED', label: 'Applied' },
|
||||
{ key: 'PENDING', label: 'Shortlist' },
|
||||
{ key: 'CLOSED', label: 'Shortlist' },
|
||||
{ key: 'SCREENING', label: 'Screened' },
|
||||
{ key: 'PROCESS', label: 'Screened' },
|
||||
{ key: 'ONHOLD', label: 'Screened' },
|
||||
|
|
|
|||
|
|
@ -47,15 +47,15 @@ import { avatarColor, departments, initials as initialsOf } from '../data/seed'
|
|||
/** The seed bucket holds 100 candidates; one template per person, no reuse. */
|
||||
const FETCH_LIMIT = 100
|
||||
|
||||
const STAGE_ORDER = ['Applied', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
const STAGE_ORDER = ['Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer', 'Hired']
|
||||
|
||||
/**
|
||||
* Candidate_application_Status (backend/inbox/enums.py) -> the seed stage
|
||||
* vocabulary every screen renders. CLOSED is the column default, i.e. untriaged,
|
||||
* so it reads as Applied rather than as an outcome.
|
||||
* so it reads as Shortlist rather than as an outcome.
|
||||
*/
|
||||
const STAGE_FROM_STATUS = {
|
||||
PENDING: 'Applied', CLOSED: 'Applied', PROCESS: 'Screening',
|
||||
PENDING: 'Shortlist', CLOSED: 'Shortlist', PROCESS: 'Screening',
|
||||
ONHOLD: 'Screening', APPROVED: 'Hired', REJECTED: 'Rejected',
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -719,7 +719,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
|
||||
/* ================= TABS ================= */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 22px; overflow-x: auto; }
|
||||
.tab { padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; }
|
||||
.tab { display: inline-flex; align-items: center; gap: 12px; padding: 11px 16px; font-weight: 600; font-size: 13.5px; color: var(--text-2); border-bottom: 2px solid transparent; white-space: nowrap; transition: .15s; margin-bottom: -1px; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--primary); border-bottom-color: var(--primary); }
|
||||
.tab-pane { display: none; animation: fadeUp .25s; }
|
||||
|
|
@ -857,6 +857,46 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
|
||||
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
|
||||
/* Inbox sidebar only: fit the list instead of scrolling sideways.
|
||||
Username (.ii-name) and subject (.ii-pos) are left alone. */
|
||||
.inbox-split { grid-template-columns: minmax(0, 380px) 1fr; }
|
||||
.inbox-queue { overflow-x: hidden; min-width: 0; }
|
||||
.inbox-queue .inbox-item { min-width: 0; }
|
||||
.inbox-queue .ii-meta { flex-wrap: wrap; min-width: 0; }
|
||||
.inbox-queue .source-chip {
|
||||
min-width: 0; max-width: 100%;
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.inbox-queue .toolbar-search { min-width: 0; }
|
||||
.inbox-bulk-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
flex-wrap: nowrap;
|
||||
min-width: 0;
|
||||
}
|
||||
.inbox-bulk-count {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--text-3);
|
||||
}
|
||||
.inbox-bulk-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.inbox-bulk-actions .btn-sm {
|
||||
padding: 5px 8px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* `--chip` is the source's own brand colour, set inline. It tints the
|
||||
background and fills the dot, while the label stays on theme text so
|
||||
11px copy keeps its contrast in both modes. */
|
||||
|
|
|
|||
|
|
@ -47,6 +47,35 @@ export function useTheme() {
|
|||
return ctx
|
||||
}
|
||||
|
||||
/**
|
||||
* A counter that increments every time [data-theme] flips. Use it as an effect
|
||||
* or useMemo dependency.
|
||||
*
|
||||
* Almost nothing needs this: a CSS custom property change repaints the whole
|
||||
* document for free, which is why the app re-themes without any React
|
||||
* involvement. It exists for the handful of places that CANNOT ride a variable
|
||||
* — anything that bakes a colour into a canvas, a string, or a separate
|
||||
* document at render time. Those read the palette through getComputedStyle
|
||||
* exactly once and then hold a stale copy forever, because changing a custom
|
||||
* property repaints CSS but never re-runs JavaScript.
|
||||
*
|
||||
* It watches the ATTRIBUTE rather than subscribing to this provider's state, on
|
||||
* purpose. initTheme() runs before React mounts and applyTheme() can be called
|
||||
* from outside the tree, so the attribute is the only source that is always
|
||||
* current — and a component using this hook then needs no provider at all.
|
||||
* Chart.jsx observes the same attribute directly, for the same reason.
|
||||
*/
|
||||
export function useThemeVersion() {
|
||||
const [version, setVersion] = useState(0)
|
||||
useEffect(() => {
|
||||
if (typeof MutationObserver !== 'function') return undefined
|
||||
const mo = new MutationObserver(() => setVersion((v) => v + 1))
|
||||
mo.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme'] })
|
||||
return () => mo.disconnect()
|
||||
}, [])
|
||||
return version
|
||||
}
|
||||
|
||||
export default function ThemeProvider({ children }) {
|
||||
const [theme, setThemeState] = useState(
|
||||
() => document.documentElement.getAttribute('data-theme') || 'light',
|
||||
|
|
|
|||
|
|
@ -21,9 +21,16 @@
|
|||
Remote images stay blocked until the user asks for them. A tracking pixel in
|
||||
an applicant email would otherwise tell the sender exactly when a recruiter
|
||||
opened it.
|
||||
|
||||
That CSS isolation has one cost worth stating plainly: custom properties do
|
||||
not inherit across an iframe boundary, so this is the only component in the
|
||||
app that cannot re-theme itself for free. Its palette is snapshotted into the
|
||||
frame's <style> at build time, and useThemeVersion is what reruns that build.
|
||||
============================================================ */
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useThemeVersion } from '../theme/ThemeProvider'
|
||||
|
||||
/** Elements that have no business in a rendered email. */
|
||||
const STRIP_TAGS = [
|
||||
|
|
@ -64,12 +71,20 @@ function sanitize(html) {
|
|||
return doc.body?.innerHTML || ''
|
||||
}
|
||||
|
||||
/** Inherit the host theme's colours so the email does not glare in dark mode. */
|
||||
/**
|
||||
* Inherit the host theme's colours so the email does not glare in dark mode.
|
||||
*
|
||||
* Called fresh on every srcDoc build — see the themeVersion dependency below.
|
||||
* Read the attribute for color-scheme rather than sniffing whether --bg happens
|
||||
* to start with an "f": that guess quietly inverts the frame's form controls and
|
||||
* scrollbars the moment a palette changes shade.
|
||||
*/
|
||||
function frameStyles() {
|
||||
const css = getComputedStyle(document.documentElement)
|
||||
const pick = (name, fallback) => (css.getPropertyValue(name) || fallback).trim()
|
||||
const dark = document.documentElement.getAttribute('data-theme') === 'dark'
|
||||
return `
|
||||
:root { color-scheme: ${pick('--bg', '#fff').startsWith('#f') ? 'light' : 'dark'}; }
|
||||
:root { color-scheme: ${dark ? 'dark' : 'light'}; }
|
||||
body {
|
||||
margin: 0;
|
||||
background: ${pick('--bg-sunken', '#f7f7f8')};
|
||||
|
|
@ -113,8 +128,21 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
const [allowRemoteImages, setAllowRemoteImages] = useState(false)
|
||||
const [height, setHeight] = useState(320)
|
||||
const [blockedImages, setBlockedImages] = useState(0)
|
||||
const themeVersion = useThemeVersion()
|
||||
|
||||
const clean = sanitize(html)
|
||||
// Both memoised because srcDoc IS the frame's source: handing React a new but
|
||||
// equal string tears the document down and rebuilds it. Unmemoised, every
|
||||
// unrelated parent render re-parsed the mail and reloaded the frame.
|
||||
const clean = useMemo(() => sanitize(html), [html])
|
||||
const srcDoc = useMemo(
|
||||
// themeVersion is the entire reason this list exists. frameStyles()
|
||||
// snapshots the CSS variables into the frame's <style>, and custom
|
||||
// properties do not cross into an iframe, so without a rebuild the email
|
||||
// keeps the palette it was born with while the rest of the app flips.
|
||||
() => buildSrcDoc(clean, allowRemoteImages),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[clean, allowRemoteImages, themeVersion],
|
||||
)
|
||||
|
||||
const measure = useCallback(() => {
|
||||
const frame = ref.current
|
||||
|
|
@ -171,7 +199,7 @@ export default function EmailBody({ html, maxHeight }) {
|
|||
onLoad={onLoad}
|
||||
// No allow-scripts. Ever. See the header comment.
|
||||
sandbox="allow-same-origin allow-popups allow-popups-to-escape-sandbox"
|
||||
srcDoc={buildSrcDoc(clean, allowRemoteImages)}
|
||||
srcDoc={srcDoc}
|
||||
style={{ height: maxHeight ? Math.min(height, maxHeight) : height }}
|
||||
/>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -27,8 +27,7 @@ export function Tabs({ tabs, value, onChange, className = 'tabs' }) {
|
|||
className={`tab${active ? ' active' : ''}`}
|
||||
onClick={() => onChange(key)}
|
||||
>
|
||||
{label}
|
||||
{t.count != null && <span className="tab-count">{t.count}</span>}
|
||||
{label}{t.count != null && <span className="tab-count">{t.count}</span>}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export function AvatarStack({ names = [], max = 3 }) {
|
|||
// The 30-entry status -> class map from js/ui.js:73-81, verbatim.
|
||||
export const STATUS_CLASS = {
|
||||
Open: 'b-green', Closed: 'b-gray', 'On Hold': 'b-amber', Draft: 'b-blue',
|
||||
Applied: 'b-blue', Screening: 'b-purple', Assessment: 'b-amber', Interview: 'b-indigo',
|
||||
Shortlist: 'b-blue', Screening: 'b-purple', Assessment: 'b-amber', Interview: 'b-indigo',
|
||||
Offer: 'b-teal', Hired: 'b-green', Rejected: 'b-red',
|
||||
Scheduled: 'b-blue', Completed: 'b-green', Cancelled: 'b-red', 'No Show': 'b-amber',
|
||||
Sent: 'b-blue', Accepted: 'b-green', Negotiating: 'b-amber', Declined: 'b-red', Expired: 'b-gray',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* Theme-propagation test.
|
||||
*
|
||||
* npm run test:theme
|
||||
*
|
||||
* Flipping [data-theme] repaints the entire app for free, because every colour
|
||||
* in styles.css is a custom property. This test guards the one component that
|
||||
* cannot ride that: EmailBody renders into an iframe, and custom properties do
|
||||
* not inherit across a document boundary, so its palette is snapshotted into the
|
||||
* frame's <style> and has to be rebuilt by hand.
|
||||
*
|
||||
* That made it the one place where a theme switch left stale colours on screen —
|
||||
* on /inbox (both tabs) and /matching. The assertions below are what stops it
|
||||
* regressing: a real MutationObserver, a real attribute flip, and the frame's
|
||||
* own srcDoc read back afterwards.
|
||||
*/
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
|
||||
import esbuild from 'esbuild'
|
||||
import { JSDOM } from 'jsdom'
|
||||
|
||||
// ---------------------------------------------------------------- environment
|
||||
const dom = new JSDOM('<!doctype html><html data-theme="light"><body><div id="root"></div></body></html>', {
|
||||
url: 'http://localhost:5173/',
|
||||
pretendToBeVisual: true,
|
||||
})
|
||||
|
||||
globalThis.window = dom.window
|
||||
globalThis.document = dom.window.document
|
||||
Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true })
|
||||
globalThis.HTMLElement = dom.window.HTMLElement
|
||||
globalThis.Element = dom.window.Element
|
||||
globalThis.Node = dom.window.Node
|
||||
globalThis.DOMParser = dom.window.DOMParser
|
||||
globalThis.getComputedStyle = dom.window.getComputedStyle
|
||||
globalThis.localStorage = dom.window.localStorage
|
||||
globalThis.requestAnimationFrame = (cb) => setTimeout(() => cb(Date.now()), 0)
|
||||
globalThis.cancelAnimationFrame = clearTimeout
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
// jsdom ships a real MutationObserver, and this test depends on it — the render
|
||||
// smoke test stubs it out, which would silently make every assertion here pass.
|
||||
globalThis.MutationObserver = dom.window.MutationObserver
|
||||
|
||||
// ---------------------------------------------------------------- bundle
|
||||
const outDir = mkdtempSync(join(tmpdir(), 'tf-theme-'))
|
||||
const outFile = join(outDir, 'entry.mjs')
|
||||
|
||||
await esbuild.build({
|
||||
stdin: {
|
||||
contents: `
|
||||
import { act } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import EmailBody from './ui/EmailBody'
|
||||
|
||||
export async function mount(container, html) {
|
||||
const root = createRoot(container)
|
||||
await act(async () => { root.render(<EmailBody html={html} />) })
|
||||
return root
|
||||
}
|
||||
export async function flush() { await act(async () => {}) }
|
||||
`,
|
||||
resolveDir: 'src',
|
||||
sourcefile: 'theme-entry.jsx',
|
||||
loader: 'jsx',
|
||||
},
|
||||
outfile: outFile,
|
||||
bundle: true,
|
||||
format: 'esm',
|
||||
platform: 'node',
|
||||
target: 'node20',
|
||||
jsx: 'automatic',
|
||||
loader: { '.js': 'jsx', '.jsx': 'jsx' },
|
||||
logLevel: 'error',
|
||||
define: {
|
||||
'process.env.NODE_ENV': '"development"',
|
||||
'import.meta.env': JSON.stringify({ DEV: true, PROD: false, MODE: 'development', VITE_API_BASE: '' }),
|
||||
},
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------- run
|
||||
let failed = 0
|
||||
const check = (label, ok, detail = '') => {
|
||||
console.log(`${ok ? 'ok ' : 'FAIL '} ${label}${ok || !detail ? '' : `\n ${detail}`}`)
|
||||
if (!ok) failed++
|
||||
}
|
||||
|
||||
/** The palette a real stylesheet would supply, set inline so jsdom resolves it. */
|
||||
function applyPalette(theme) {
|
||||
const root = dom.window.document.documentElement
|
||||
const vars = theme === 'dark'
|
||||
? { '--bg-sunken': '#0e1d1f', '--text': '#e8f2ef', '--border': '#24403f', '--primary': '#ceff71' }
|
||||
: { '--bg-sunken': '#f1f7f4', '--text': '#10231f', '--border': '#dbe8e2', '--primary': '#004d43' }
|
||||
for (const [k, v] of Object.entries(vars)) root.style.setProperty(k, v)
|
||||
root.setAttribute('data-theme', theme)
|
||||
}
|
||||
|
||||
const srcDocOf = (container) => container.querySelector('iframe')?.getAttribute('srcdoc') || ''
|
||||
|
||||
try {
|
||||
const mod = await import(pathToFileURL(outFile).href)
|
||||
|
||||
applyPalette('light')
|
||||
const container = dom.window.document.createElement('div')
|
||||
dom.window.document.body.appendChild(container)
|
||||
await mod.mount(container, '<p>Hello from a candidate.</p>')
|
||||
|
||||
const light = srcDocOf(container)
|
||||
check('renders an iframe', Boolean(light), 'no iframe in the output')
|
||||
check('light frame declares color-scheme: light', light.includes('color-scheme: light'))
|
||||
check('light frame picks up the light background', light.includes('#f1f7f4'), light.slice(0, 200))
|
||||
check('light frame picks up the light text colour', light.includes('#10231f'))
|
||||
check('the mail itself still renders', light.includes('Hello from a candidate.'))
|
||||
|
||||
// The flip under test. MutationObserver delivers on a microtask, so the
|
||||
// act() flush below is what lets the re-render land before we read back.
|
||||
applyPalette('dark')
|
||||
await mod.flush()
|
||||
await mod.flush()
|
||||
|
||||
const dark = srcDocOf(container)
|
||||
check('THE REGRESSION: frame rebuilds on theme flip', dark !== light,
|
||||
'srcDoc is byte-identical after the flip — the email kept the light palette')
|
||||
check('dark frame declares color-scheme: dark', dark.includes('color-scheme: dark'),
|
||||
dark.slice(0, 200))
|
||||
check('dark frame picks up the dark background', dark.includes('#0e1d1f'))
|
||||
check('dark frame picks up the dark text colour', dark.includes('#e8f2ef'))
|
||||
check('light colours are gone', !dark.includes('#f1f7f4') && !dark.includes('#10231f'))
|
||||
check('the mail survives the rebuild', dark.includes('Hello from a candidate.'))
|
||||
|
||||
// And back, so this is a toggle rather than a one-way upgrade.
|
||||
applyPalette('light')
|
||||
await mod.flush()
|
||||
await mod.flush()
|
||||
check('flips back to light', srcDocOf(container).includes('color-scheme: light'))
|
||||
} finally {
|
||||
rmSync(outDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
console.log(failed ? `\n${failed} theme check(s) failed` : '\nAll theme checks passed')
|
||||
process.exit(failed ? 1 : 0)
|
||||
Loading…
Reference in New Issue