mark correc t

UI_CHANGES
ahmed.mujtaba 2026-08-19 19:07:53 +05:00
parent 71d9803db2
commit 24f3c5df53
2 changed files with 155 additions and 68 deletions

View File

@ -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) }}
/>
)}

View File

@ -9,7 +9,7 @@
now, which is the structural fix.
============================================================ */
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -270,34 +270,36 @@ function patchReadState(data, idSet, read) {
* a view-only user gets a 403 here and the rows snap back. The bulk bars disable
* themselves for those users; click-to-read cannot, so it still relies on rollback.
*/
async function optimisticRead(qc, ids, read) {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
const idSet = new Set(ids)
// Which rows actually FLIP, counted BEFORE the patch: the counts object is a
// bag of totals with no per-row detail to derive the delta from afterwards.
// A Set, because the same id appears in several cached lists at once.
const flipping = new Set()
for (const [, data] of previous) {
if (!Array.isArray(data)) continue
for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id)
}
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read))
// The Unread tab badge and the sidebar badge share this entry. Without the
// delta they sit stale until the refetch lands invisible for one row,
// glaring for two hundred. `previous` already covers it for rollback,
// because ['mailbox'] is a prefix of ['mailbox','counts'].
qc.setQueryData(qk.mailbox.counts(), (c) => (c
? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) }
: c))
return { previous }
}
function useSetRead(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ ids, read }) => setReadChunked(ids, read),
onMutate: async ({ ids, read }) => {
await qc.cancelQueries({ queryKey: qk.mailbox.all() })
const previous = qc.getQueriesData({ queryKey: qk.mailbox.all() })
const idSet = new Set(ids)
// Which rows actually FLIP, counted BEFORE the patch: the counts object is a
// bag of totals with no per-row detail to derive the delta from afterwards.
// A Set, because the same id appears in several cached lists at once.
const flipping = new Set()
for (const [, data] of previous) {
if (!Array.isArray(data)) continue
for (const r of data) if (idSet.has(r.id) && Boolean(r.unread) === read) flipping.add(r.id)
}
qc.setQueriesData({ queryKey: qk.mailbox.all() }, (data) => patchReadState(data, idSet, read))
// The Unread tab badge and the sidebar badge share this entry. Without the
// delta they sit stale until the refetch lands invisible for one row,
// glaring for two hundred. `previous` already covers it for rollback,
// because ['mailbox'] is a prefix of ['mailbox','counts'].
qc.setQueryData(qk.mailbox.counts(), (c) => (c
? { ...c, unread: Math.max(0, (c.unread ?? 0) + (read ? -flipping.size : flipping.size)) }
: c))
return { previous }
},
onMutate: ({ ids, read }) => optimisticRead(qc, ids, read),
onError: (err, _vars, ctx) => {
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
toast(friendlyAuthError(err, 'Could not update read state.'), 'error')
@ -309,14 +311,19 @@ function useSetRead(toast) {
/**
* 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.
* The WRITE is a WHERE clause, but the optimistic patch still runs over the ids
* the caller can see: this endpoint is only ever used where the visible list IS
* the server's whole result (see SERVER_SCOPED_TABS, and the list endpoint takes
* no pagination), so those ids are the scope, not a sample of it. Without the
* patch the rows sit unchanged for a whole round trip plus a refetch, which reads
* as a dead button and the reconciling refetch on settle corrects it anyway if
* the scope ever did reach further.
*/
function useSetReadAll(toast) {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ read, filter }) => inboxApi.setReadAll({ read, ...filter }),
onMutate: ({ ids, read }) => optimisticRead(qc, ids ?? [], read),
onSuccess: (res, { read }) => {
const n = res?.data?.updated ?? 0
toast(
@ -326,7 +333,10 @@ function useSetReadAll(toast) {
'success',
)
},
onError: (err) => toast(friendlyAuthError(err, 'Could not update read state.'), 'error'),
onError: (err, _vars, ctx) => {
for (const [key, data] of ctx?.previous ?? []) qc.setQueryData(key, data)
toast(friendlyAuthError(err, 'Could not update read state.'), 'error')
},
onSettled: () => qc.invalidateQueries({ queryKey: qk.mailbox.all() }),
})
}
@ -405,20 +415,52 @@ function RowCheck({ checked, onToggle, label }) {
)
}
const READ_TICK_MS = 1000
/**
* 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.
* The leading is not a mode indicator. It only appears on the Mark read /
* Mark all read button that was just clicked, then clears from both after 1s.
*
* `rows` rather than a count, because the bar needs each row's read state, and
* `selectedIds` narrows it to what the selected pair of buttons would touch.
*/
function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
function BulkReadBar({ rows, selection, onSetRead, onSetAllRead, busy, canEdit, scopeLabel }) {
const { selectedIds, toggleAll, clear, allSelected } = selection
const n = selectedIds.size
const disabled = !canEdit || busy
const total = rows.length
// What the visible pair of buttons acts on: the ticked rows when there is a
// selection, the whole view otherwise.
const target = n > 0 ? rows.filter((r) => selectedIds.has(r.id)) : rows
const unreadCount = target.reduce((acc, r) => acc + (r.unread ? 1 : 0), 0)
const readCount = target.length - unreadCount
const blocked = !canEdit || busy
const tip = !canEdit ? 'Requires inbox.edit' : undefined
const allTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
const scopeTip = !canEdit ? 'Requires inbox.edit' : `Applies to ${scopeLabel}`
const nothingTo = (word) => `Nothing to mark ${word} here`
// `selected` = Mark read, `all` = Mark all read. Never both at once in the
// bar, but the flash still resets both so a leftover tick cannot linger
// after the selection-vs-all swap.
const [ticked, setTicked] = useState(null)
const tickTimer = useRef(null)
useEffect(() => () => {
if (tickTimer.current) clearTimeout(tickTimer.current)
}, [])
function flashRead(which, action) {
if (tickTimer.current) clearTimeout(tickTimer.current)
setTicked(which)
action()
tickTimer.current = setTimeout(() => {
setTicked(null)
tickTimer.current = null
}, READ_TICK_MS)
}
return (
<div
@ -431,26 +473,29 @@ function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit,
label={allSelected ? 'Clear selection' : 'Select all'}
/>
<span className="text-muted text-sm">
{n > 0 ? `${n} selected` : `${total} ${total === 1 ? 'message' : 'messages'}`}
{n > 0
? `${n} selected`
: `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
</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)}
disabled={blocked || unreadCount === 0}
title={unreadCount === 0 ? nothingTo('read') : tip}
onClick={() => flashRead('selected', () => onSetRead(true))}
>
<Icon name="check" /> Mark {n} read
{ticked === 'selected' && <Icon name="check" />}
Mark read{unreadCount ? ` (${unreadCount})` : ''}
</button>
<button
className="btn btn-secondary btn-sm"
disabled={disabled}
title={tip}
disabled={blocked || readCount === 0}
title={readCount === 0 ? nothingTo('unread') : tip}
onClick={() => onSetRead(false)}
>
<Icon name="mail" /> Mark {n} unread
<Icon name="mail" /> Mark unread{readCount ? ` (${readCount})` : ''}
</button>
<button className="btn btn-ghost btn-sm" onClick={clear}>Clear</button>
</>
@ -458,19 +503,20 @@ function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit,
<>
<button
className="btn btn-secondary btn-sm"
disabled={disabled || total === 0}
title={allTip}
onClick={() => onSetAllRead(true)}
disabled={blocked || unreadCount === 0}
title={unreadCount === 0 ? nothingTo('read') : scopeTip}
onClick={() => flashRead('all', () => onSetAllRead(true))}
>
<Icon name="check" /> Mark all read
{ticked === 'all' && <Icon name="check" />}
Mark all read{unreadCount ? ` (${unreadCount})` : ''}
</button>
<button
className="btn btn-secondary btn-sm"
disabled={disabled || total === 0}
title={allTip}
disabled={blocked || readCount === 0}
title={readCount === 0 ? nothingTo('unread') : scopeTip}
onClick={() => onSetAllRead(false)}
>
<Icon name="mail" /> Mark all unread
<Icon name="mail" /> Mark all unread{readCount ? ` (${readCount})` : ''}
</button>
</>
)}
@ -614,7 +660,8 @@ export default function Inbox() {
// 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 })
// `ids` drives the optimistic patch only; the write itself is the filter.
setReadAll.mutate({ read, filter: tabFilter, ids: list.map((i) => i.id) })
return
}
const ids = list.map((i) => i.id)
@ -719,7 +766,7 @@ export default function Inbox() {
</div>
{applicationsQuery.isSuccess && (
<BulkReadBar
total={list.length}
rows={list}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}
@ -1314,7 +1361,7 @@ function EmailTab({ query, toast }) {
* the WHERE clause matches what is on screen exactly.
*/
function setReadEverything(read) {
setReadAll.mutate({ read, filter: {} })
setReadAll.mutate({ read, filter: {}, ids: emails.map((e) => e.id) })
}
const sync = useMutation({
@ -1386,7 +1433,7 @@ function EmailTab({ query, toast }) {
<div className="split-list">
{query.isSuccess && emails.length > 0 && (
<BulkReadBar
total={emails.length}
rows={emails}
selection={selection}
onSetRead={setReadSelected}
onSetAllRead={setReadEverything}