mark correc t
parent
71d9803db2
commit
24f3c5df53
|
|
@ -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) }}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in New Issue