From 24f3c5df5394a887ed6a15cca1de1012ba0b96d4 Mon Sep 17 00:00:00 2001 From: "ahmed.mujtaba" Date: Wed, 19 Aug 2026 19:07:53 +0500 Subject: [PATCH] mark correc t --- frontend/src/screens/Candidates.jsx | 66 +++++++++--- frontend/src/screens/Inbox.jsx | 157 ++++++++++++++++++---------- 2 files changed, 155 insertions(+), 68 deletions(-) diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 7e65e1f..067afd3 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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() { ) : ( t.pageRows.map((c) => ( - + openProfile(c)} + >
@@ -367,11 +403,6 @@ export default function Candidates() { {c.applied ? c.applied.toLocaleDateString() : '—'} - -
- -
- )) )} @@ -394,9 +425,18 @@ export default function Candidates() { {profileFor && ( 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) }} /> )} diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 546cfed..95644dc 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -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 (
- {n > 0 ? `${n} selected` : `${total} ${total === 1 ? 'message' : 'messages'}`} + {n > 0 + ? `${n} selected` + : `${total} ${total === 1 ? 'message' : 'messages'}${unreadCount ? ` · ${unreadCount} unread` : ''}`}
{n > 0 ? ( <> @@ -458,19 +503,20 @@ function BulkReadBar({ total, selection, onSetRead, onSetAllRead, busy, canEdit, <> )} @@ -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() {
{applicationsQuery.isSuccess && ( e.id) }) } const sync = useMutation({ @@ -1386,7 +1433,7 @@ function EmailTab({ query, toast }) {
{query.isSuccess && emails.length > 0 && (