add pagination inhistory

pull/51/head
ahmed.mujtaba 2026-09-02 17:00:44 +05:00
parent 63d5232cac
commit a33f4e18fc
10 changed files with 261 additions and 96 deletions

View File

@ -266,6 +266,30 @@ class Inbox(SQLModel, table=True):
except Exception as e: except Exception as e:
raise HTTPException(status_code=500,detail=str(e)) raise HTTPException(status_code=500,detail=str(e))
@classmethod
async def get_users_by_job_post_id(cls,session:AsyncSession,job_post_id):
"""Distinct users.id on inbox rows assigned to this job post.
Join is inbox inbox_messages.assigned_job_post_id only suggestions
are not a link. Invalid ids yield an empty set, not a 500.
"""
try:
try:
jid=uuid.UUID(str(job_post_id))
except (TypeError,ValueError,AttributeError):
return set()
qry=(
select(cls.user_id)
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
.where(Inbox_Messages.assigned_job_post_id==jid)
.where(cls.user_id.is_not(None))
.distinct()
)
result=await session.execute(qry)
return {uid for uid in result.scalars().all() if uid is not None}
except Exception as e:
raise HTTPException(status_code=500,detail=str(e))
@classmethod @classmethod
def scoped_to_job(cls, statement, department=None, recruiter_id=None): def scoped_to_job(cls, statement, department=None, recruiter_id=None):
"""Optional department / recruiter via inbox_messages → job_posts.""" """Optional department / recruiter via inbox_messages → job_posts."""

View File

@ -231,6 +231,7 @@ async def fetch_users(
role_id:Optional[int]=Query(None), role_id:Optional[int]=Query(None),
top:Optional[int]=Query(None), top:Optional[int]=Query(None),
skip:Optional[int]=Query(None), skip:Optional[int]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
search:Optional[str]=Query(None), search:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
@ -242,7 +243,7 @@ async def fetch_users(
detail="Hiring managers can only list candidates on their requisitions", detail="Hiring managers can only list candidates on their requisitions",
) )
service=User(session=session) service=User(session=session)
data=await service.get_users(role_id=role_id,top=top,skip=skip) data=await service.get_users(role_id=role_id,top=top,skip=skip,assigned_job_post_id=assigned_job_post_id)
return JSONResponse(content={"data":data,"status_code":200}) return JSONResponse(content={"data":data,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -254,6 +255,7 @@ async def fetch_users(
async def count_candidate_users( async def count_candidate_users(
role_id:Optional[int]=Query(None), role_id:Optional[int]=Query(None),
search:Optional[str]=Query(None), search:Optional[str]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),
): ):
@ -265,7 +267,7 @@ async def count_candidate_users(
detail="Hiring managers can only list candidates on their requisitions", detail="Hiring managers can only list candidates on their requisitions",
) )
service=User(session=session) service=User(session=session)
total=await service.count_users(search=search,role_id=role_id) total=await service.count_users(search=search,role_id=role_id,assigned_job_post_id=assigned_job_post_id)
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200}) return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
except HTTPException: except HTTPException:
raise raise
@ -942,6 +944,7 @@ async def fetch_manager_candidates(
async def fetch_candidate( async def fetch_candidate(
user_id:str=Query(None), user_id:str=Query(None),
limit:int=Query(10,ge=1,le=100), limit:int=Query(10,ge=1,le=100),
assigned_job_post_id:UUID=Query(None),
offset:int=Query(0,ge=0), offset:int=Query(0,ge=0),
search:str=Query(None), search:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
@ -950,7 +953,7 @@ async def fetch_candidate(
try: try:
service=CandidateView(session=session) service=CandidateView(session=session)
data=await service.get_candidate( data=await service.get_candidate(
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user, user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
) )
@ -982,7 +985,7 @@ async def update_candidate(
@router.get("/candidate/history/fetch") @router.get("/candidate/history/fetch")
async def fetch_candidate_history( async def fetch_candidate_history(
user_id:str=Query(...), user_id:str=Query(...),
limit:int=Query(200,ge=1,le=500), limit:int=Query(10,ge=1,le=100),
offset:int=Query(0,ge=0), offset:int=Query(0,ge=0),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session), session: AsyncSession = Depends(get_session),

View File

@ -785,7 +785,7 @@ class CandidateView:
cap=max(1,int(limit or 50)) cap=max(1,int(limit or 50))
return merged[start:start+cap],total return merged[start:start+cap],total
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None): async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None):
try: try:
if not user_id and is_hiring_manager(current_user): if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL) raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
@ -794,9 +794,7 @@ class CandidateView:
self.session,current_user,user_id=user_id, self.session,current_user,user_id=user_id,
) )
detail=bool(user_id) detail=bool(user_id)
# Detail mode must see every application for the candidate, not one page. rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search)
fetch_limit=1000 if detail else limit
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
if detail: if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else []) records=rows if isinstance(rows,list) else ([rows] if rows else [])
if records and is_hiring_manager(current_user): if records and is_hiring_manager(current_user):
@ -843,7 +841,7 @@ class CandidateView:
if not isinstance(inbox_payloads,list): if not isinstance(inbox_payloads,list):
inbox_payloads=[inbox_payloads] if inbox_payloads else [] inbox_payloads=[inbox_payloads] if inbox_payloads else []
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool( manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
self.session,limit=fetch_limit,offset=0,search=search, self.session,limit=limit,offset=0,search=search,
) )
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")} seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
manual_payloads=[] manual_payloads=[]
@ -883,7 +881,11 @@ class CandidateView:
}) })
if uid: if uid:
seen.add(uid) seen.add(uid)
return inbox_payloads+manual_payloads data=inbox_payloads+manual_payloads
if assigned_job_post_id:
job_id=str(assigned_job_post_id)
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
return data
except HTTPException: except HTTPException:
raise raise
except Exception as e: except Exception as e:

View File

@ -1,4 +1,5 @@
from fastapi import HTTPException from fastapi import HTTPException
from inbox.models import Inbox
from notifications.views import Confirmation from notifications.views import Confirmation
from role.models import EnumRoles,Roles from role.models import EnumRoles,Roles
from users.models import Users from users.models import Users
@ -63,11 +64,14 @@ class User:
await service.send_confirmation(user) await service.send_confirmation(user)
return user return user
async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None): async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None,assigned_job_post_id:Optional[str]=None):
if role_id: if role_id:
users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id) users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id)
else: else:
users=await Users.get_users(self.session,top=top,skip=skip,search=search) users=await Users.get_users(self.session,top=top,skip=skip,search=search)
if assigned_job_post_id:
linked=await Inbox.get_users_by_job_post_id(self.session,assigned_job_post_id)
users=[u for u in users if u.id in linked]
return [serialize_user(u) for u in users] return [serialize_user(u) for u in users]
async def get_user_by_id(self,record_id): async def get_user_by_id(self,record_id):
@ -136,8 +140,12 @@ class User:
] ]
return data,len(data) return data,len(data)
async def count_users(self,search=None,role_id=None): async def count_users(self,search=None,role_id=None,assigned_job_post_id=None):
if not assigned_job_post_id:
return await Users.count_users(self.session,search,role_id=role_id) return await Users.count_users(self.session,search,role_id=role_id)
users=await Users.get_users(self.session,search=search,role_id=role_id)
linked=await Inbox.get_users_by_job_post_id(self.session,assigned_job_post_id)
return sum(1 for u in users if u.id in linked)
async def authenticate_user(self,email,password): async def authenticate_user(self,email,password):
user=await Users.get_user_by_email(self.session,email) user=await Users.get_user_by_email(self.session,email)

View File

@ -169,27 +169,28 @@ export function toCandidateView(row) {
* role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup * role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
* default). We send it explicitly so a missing param cannot list the wrong people. * default). We send it explicitly so a missing param cannot list the wrong people.
* *
* Three things this route does NOT do, all verified against * This route:
* backend/job/app.py::fetch_users:
* - it returns `{data, status_code}` with NO `total` on the list; use * - it returns `{data, status_code}` with NO `total` on the list; use
* GET /candidate/fetch/users/count (once on page open) for the pager total; * GET /candidate/fetch/users/count (once on page open) for the pager total;
* - `top`/`skip` page the list; the Candidates screen sends the user's page * - `top`/`skip` page the list; the Candidates screen sends the user's page
* size as `top` and `(page-1)*top` as `skip`; * size as `top` and `(page-1)*top` as `skip`;
* - `assigned_job_post_id` keeps only users assigned to that job post
* (`inbox_messages.assigned_job_post_id`). Omit it for All Jobs.
* - it accepts a `search` query param but never forwards it to the service * - it accepts a `search` query param but never forwards it to the service
* layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op * layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
* server-side. Filtering stays client-side on the fetched page until that * server-side. Filtering stays client-side on the fetched page until that
* is fixed. * is fixed.
*/ */
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0 } = {}) { export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) {
return request('/candidate/fetch/users', { return request('/candidate/fetch/users', {
params: { role_id: roleId, top, skip }, params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId },
}) })
} }
/** Total candidate-role users. Called once when the Candidates page opens. */ /** Total candidate-role users. Called once when the Candidates page opens. */
export function countCandidateUsers({ roleId = 8, search } = {}) { export function countCandidateUsers({ roleId = 8, search, assignedJobPostId } = {}) {
return request('/candidate/fetch/users/count', { return request('/candidate/fetch/users/count', {
params: { role_id: roleId, search }, params: { role_id: roleId, search, assigned_job_post_id: assignedJobPostId },
}) })
} }
@ -236,9 +237,14 @@ export function toCandidateUserView(row) {
* *
* `search` is an ilike over users.name / users.email only it does NOT reach * `search` is an ilike over users.name / users.email only it does NOT reach
* the résumé text or the suggested job titles. * the résumé text or the suggested job titles.
*
* `assignedJobPostId` maps to `assigned_job_post_id` and keeps only people
* assigned to that job. Empty / omitted is All Jobs.
*/ */
export function list({ search, limit, offset } = {}) { export function list({ search, limit, offset, assignedJobPostId } = {}) {
return request('/candidate/fetch', { params: { search, limit, offset } }) return request('/candidate/fetch', {
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
})
} }
/** /**
@ -274,6 +280,25 @@ export function toRows(res) {
return res?.data ? [res.data] : [] return res?.data ? [res.data] : []
} }
/**
* Assigned job-post ids only. `job_posts` / suggested_job_post_ids are
* matcher hints, not an assignment the Candidates / Talent Pool job
* filter must not treat a suggestion as a link.
*/
export function jobIdsOf(row) {
if (!row || typeof row !== 'object') return []
const ids = []
const add = (value) => {
if (value == null || value === '') return
const id = String(value)
if (!ids.includes(id)) ids.push(id)
}
add(row.assigned_job_post_id)
add(row.assigned_job_post?.id)
add(row.job_post_id)
return ids
}
/** /**
* favorite/rating live on the `inbox` row, not on the user, so the server applies * favorite/rating live on the `inbox` row, not on the user, so the server applies
* the change to EVERY application belonging to the candidate and hands back the * the change to EVERY application belonging to the candidate and hands back the
@ -415,7 +440,7 @@ export function createActivity({ inboxId, type, status, description }) {
* detail query refetches on every write in the modal. Fetched lazily when the * detail query refetches on every write in the modal. Fetched lazily when the
* History tab opens, paginated server-side. * History tab opens, paginated server-side.
*/ */
export function listHistory(userId, { limit = 200, offset = 0 } = {}) { export function listHistory(userId, { limit = 10, offset = 0 } = {}) {
return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } }) return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
} }

View File

@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal' import Modal from '../ui/Modal'
import OpenResumeButton from '../ui/OpenResumeButton' import OpenResumeButton from '../ui/OpenResumeButton'
import { Tabs } from '../ui/Tabs' import { Tabs } from '../ui/Tabs'
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives' import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
import { useToast } from '../ui/Toast' import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext' import { useAuth } from '../auth/AuthContext'
@ -760,12 +761,13 @@ function historyDayLabel(value) {
} }
function HistoryTab({ userId }) { function HistoryTab({ userId }) {
const [limit, setLimit] = useState(200) const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const q = useQuery({ const q = useQuery({
queryKey: qk.candidates.history(userId, { limit }), queryKey: qk.candidates.history(userId, { limit: pageSize, offset: skip }),
queryFn: async () => { queryFn: async () => {
const res = await candidatesApi.listHistory(userId, { limit }) const res = await candidatesApi.listHistory(userId, { limit: pageSize, offset: skip })
return { rows: res?.data ?? [], total: res?.total ?? 0 } return { rows: Array.isArray(res?.data) ? res.data : [], total: typeof res?.total === 'number' ? res.total : 0 }
}, },
enabled: Boolean(userId), enabled: Boolean(userId),
}) })
@ -783,7 +785,12 @@ function HistoryTab({ userId }) {
const rows = q.data?.rows ?? [] const rows = q.data?.rows ?? []
const total = q.data?.total ?? 0 const total = q.data?.total ?? 0
if (!rows.length) { const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
const from = total ? skip + 1 : 0
const to = total ? skip + rows.length : 0
if (!rows.length && total === 0) {
return <EmptyState icon="clock" title="No history yet">Actions on this candidate will appear here.</EmptyState> return <EmptyState icon="clock" title="No history yet">Actions on this candidate will appear here.</EmptyState>
} }
@ -805,10 +812,18 @@ function HistoryTab({ userId }) {
</div> </div>
</div> </div>
))} ))}
{total > rows.length && ( {total > 0 && (
<button className="btn btn-secondary btn-sm" style={{ marginTop: 8 }} onClick={() => setLimit((n) => n + 200)}> <Pagination
Load more from={from}
</button> to={to}
total={total}
page={currentPage}
pages={pages}
setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
/>
)} )}
</> </>
) )

View File

@ -50,29 +50,48 @@ const CANDIDATE_ROLE_ID = 8
The consequence is that the ATS columns have no source on this screen see The consequence is that the ATS columns have no source on this screen see
toCandidateUserView. Open a candidate to get their score, which the shared toCandidateUserView. Open a candidate to get their score, which the shared
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */ Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
async function fetchCandidates({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) { async function fetchCandidates({ top = DEFAULT_PAGE_SIZE, skip = 0, assignedJobPostId } = {}) {
const [usersRes, appsRes] = await Promise.all([ const [usersRes, appsRes] = await Promise.all([
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip }), candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip, assignedJobPostId }),
candidatesApi.list({ limit: 100 }).catch(() => null), candidatesApi.list({ limit: 100, assignedJobPostId }).catch(() => null),
]) ])
const rows = Array.isArray(usersRes?.data) ? usersRes.data : [] const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
const sourceByUser = new Map() const sourceByUser = new Map()
const jobsByUser = new Map()
for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) { for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) {
const uid = app.user_id const uid = app.user_id
if (!uid || sourceByUser.has(uid)) continue if (!uid) continue
if (app.source) sourceByUser.set(String(uid), app.source) const key = String(uid)
if (app.source && !sourceByUser.has(key)) sourceByUser.set(key, app.source)
const ids = jobsByUser.get(key) ?? []
for (const id of candidatesApi.jobIdsOf(app)) {
if (!ids.includes(id)) ids.push(id)
}
jobsByUser.set(key, ids)
} }
return rows.map((row) => { return rows.map((row) => {
const view = candidatesApi.toCandidateUserView(row) const view = candidatesApi.toCandidateUserView(row)
const source = sourceByUser.get(String(view.userId)) const key = String(view.userId)
return source ? { ...view, source } : view const source = sourceByUser.get(key)
const jobIds = jobsByUser.get(key) ?? []
return {
...view,
source: source || view.source,
jobIds,
jobId: jobIds[0] ?? null,
}
}) })
} }
async function fetchJobs() { async function fetchJobs() {
const res = await candidatesApi.listJobs() const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : [] const rows = Array.isArray(res?.data) ? res.data : []
return rows.map((row) => ({ id: row.id, title: row.title })) return rows
.filter((row) => row && row.id != null)
.map((row) => ({
id: String(row.id),
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
}))
} }
function recommendationOf(c) { function recommendationOf(c) {
@ -130,6 +149,7 @@ function HiringManagerCandidates() {
const navigate = useNavigate() const navigate = useNavigate()
const location = useLocation() const location = useLocation()
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
useEffect(() => { useEffect(() => {
const id = location.state?.openCandidate const id = location.state?.openCandidate
@ -144,14 +164,27 @@ function HiringManagerCandidates() {
}, },
}) })
const rowsAll = listQuery.data ?? [] const rowsAll = listQuery.data ?? []
const jobs = useMemo(() => {
const seen = new Set()
const out = []
for (const r of rowsAll) {
const id = r.job_post_id
if (id == null || seen.has(String(id))) continue
seen.add(String(id))
out.push({ id: String(id), title: r.job_title || 'Untitled' })
}
out.sort((a, b) => a.title.localeCompare(b.title))
return out
}, [rowsAll])
const rows = useMemo(() => { const rows = useMemo(() => {
if (!q.trim()) return rowsAll
const needle = q.trim().toLowerCase() const needle = q.trim().toLowerCase()
return rowsAll.filter((r) => { return rowsAll.filter((r) => {
if (jobId && String(r.job_post_id) !== jobId) return false
if (!needle) return true
const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase() const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase()
return hay.includes(needle) return hay.includes(needle)
}) })
}, [rowsAll, q]) }, [rowsAll, q, jobId])
const columns = [ const columns = [
{ {
@ -214,6 +247,12 @@ function HiringManagerCandidates() {
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
</div> </div>
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
</div> </div>
{listQuery.isPending && <SkeletonRows rows={4} />} {listQuery.isPending && <SkeletonRows rows={4} />}
{listQuery.isError && ( {listQuery.isError && (
@ -225,7 +264,11 @@ function HiringManagerCandidates() {
<DataTable <DataTable
columns={columns} columns={columns}
rows={rows} rows={rows}
empty="No candidates are allocated to jobs opened from your requisitions yet." empty={
jobId || q
? 'No candidates match these filters.'
: 'No candidates are allocated to jobs opened from your requisitions yet.'
}
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)} onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
/> />
)} )}
@ -243,6 +286,7 @@ function RecruiterCandidates() {
const updateCandidates = useSeedMutation('candidates') const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS) const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false) const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('recent') const [sortMode, setSortMode] = useState('recent')
@ -253,16 +297,18 @@ function RecruiterCandidates() {
const [adding, setAdding] = useState(false) const [adding, setAdding] = useState(false)
const countQuery = useQuery({ const countQuery = useQuery({
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID }), queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID, assignedJobPostId: jobId || undefined }),
queryFn: async () => { queryFn: async () => {
const res = await candidatesApi.countCandidateUsers({ roleId: CANDIDATE_ROLE_ID }) const res = await candidatesApi.countCandidateUsers({
roleId: CANDIDATE_ROLE_ID,
assignedJobPostId: jobId || undefined,
})
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0) return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
}, },
staleTime: Infinity,
}) })
const candidatesQuery = useQuery({ const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ top: pageSize, skip }), queryKey: qk.candidates.list({ top: pageSize, skip, assignedJobPostId: jobId || undefined }),
queryFn: () => fetchCandidates({ top: pageSize, skip }), queryFn: () => fetchCandidates({ top: pageSize, skip, assignedJobPostId: jobId || undefined }),
}) })
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs }) const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const deptsQuery = useQuery({ const deptsQuery = useQuery({
@ -468,6 +514,19 @@ function RecruiterCandidates() {
<Icon name="search" /> <Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" /> <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
</div> </div>
<select
className="select"
value={jobId}
onChange={(e) => {
setJobId(e.target.value)
setSkip(0)
}}
>
<option value="">All Jobs</option>
{(jobsQuery.data ?? []).map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}> <button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
<Icon name="filter" /> Filters <Icon name="filter" /> Filters
</button> </button>
@ -488,9 +547,10 @@ function RecruiterCandidates() {
className="filter-panel" className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }} style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
> >
{/* Job / Matched Skill / Source / ATS Score / scoring Status are gone {/* Matched Skill / Source / ATS Score / scoring Status stay out of
with the scoring columns: on a users row every one of them would this panel: those fields are not on a users row. Job is a
match nothing and silently empty the table. */} toolbar dropdown, sent as assigned_job_post_id on
GET /candidate/fetch/users. */}
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} /> <Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
<Facet label="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} /> <Facet label="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} />
</div> </div>
@ -519,8 +579,10 @@ function RecruiterCandidates() {
{t.pageRows.length === 0 ? ( {t.pageRows.length === 0 ? (
<tr> <tr>
<td colSpan={columns.length}> <td colSpan={columns.length}>
<EmptyState title="No candidates yet"> <EmptyState title={candidates.length ? 'No matches' : 'No candidates yet'}>
Score resumes in CV Import to fill this table. {candidates.length
? 'Try a different search or job filter.'
: 'Score resumes in CV Import to fill this table.'}
</EmptyState> </EmptyState>
</td> </td>
</tr> </tr>
@ -568,7 +630,7 @@ function RecruiterCandidates() {
setPage={(p) => setSkip((p - 1) * pageSize)} setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)} pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize} pageSize={pageSize}
onPageSizeChange={(n) => setPageSize(n)} onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
pageSizeMax={500} pageSizeMax={500}
/> />
</div> </div>

View File

@ -10,13 +10,12 @@ import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query' import { useQuery } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader' import PageHeader from '../ui/PageHeader'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives' import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { qk } from '../lib/queryKeys' import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors' import { friendlyAuthError } from '../lib/errors'
import * as jobStatsApi from '../api/jobStats' import * as jobStatsApi from '../api/jobStats'
const PAGE_SIZE = 10
const STAGES = [ const STAGES = [
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' }, { key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
{ key: 'screened', label: 'Screened', tone: 'purple' }, { key: 'screened', label: 'Screened', tone: 'purple' },
@ -209,7 +208,8 @@ export default function Progress() {
const [selectedId, setSelectedId] = useState(deepLinkJobId) const [selectedId, setSelectedId] = useState(deepLinkJobId)
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [status, setStatus] = useState('all') const [status, setStatus] = useState('all')
const [page, setPage] = useState(0) const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const statsQuery = useQuery({ const statsQuery = useQuery({
queryKey: qk.jobs.stats({ top: 500, skip: 0 }), queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
@ -240,20 +240,27 @@ export default function Progress() {
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title)) .sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
}, [jobs, query, status]) }, [jobs, query, status])
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)) const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
const safePage = Math.min(page, pageCount - 1) const currentPage = Math.min(page, pages)
const pageRows = filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE) const start = (currentPage - 1) * pageSize
const pageRows = filtered.slice(start, start + pageSize)
const from = filtered.length ? start + 1 : 0
const to = Math.min(start + pageSize, filtered.length)
useEffect(() => { useEffect(() => {
setPage(0) setPage(1)
}, [query, status]) }, [query, status])
useEffect(() => {
setPage((p) => pageAfterSizeChange(p, filtered.length, pageSize))
}, [pageSize, filtered.length])
// Deep-link: once jobs load, jump the sidebar page to that role. // Deep-link: once jobs load, jump the sidebar page to that role.
useEffect(() => { useEffect(() => {
if (!deepLinkJobId || !filtered.length) return if (!deepLinkJobId || !filtered.length) return
const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId)) const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId))
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE)) if (idx >= 0) setPage(Math.floor(idx / pageSize) + 1)
}, [deepLinkJobId, filtered]) }, [deepLinkJobId, filtered, pageSize])
useEffect(() => { useEffect(() => {
if (!jobs.length) { if (!jobs.length) {
@ -274,7 +281,7 @@ export default function Progress() {
const next = String(id || '') const next = String(id || '')
setSelectedId(next) setSelectedId(next)
const idx = filtered.findIndex((j) => String(j.id) === next) const idx = filtered.findIndex((j) => String(j.id) === next)
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE)) if (idx >= 0) setPage(Math.floor(idx / pageSize) + 1)
setSearchParams((prev) => { setSearchParams((prev) => {
const nextParams = new URLSearchParams(prev) const nextParams = new URLSearchParams(prev)
if (next) nextParams.set('job', next) if (next) nextParams.set('job', next)
@ -290,8 +297,6 @@ export default function Progress() {
const totalApplicants = sumField(jobs, 'total') const totalApplicants = sumField(jobs, 'total')
const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length
const rangeStart = filtered.length ? safePage * PAGE_SIZE + 1 : 0
const rangeEnd = Math.min(filtered.length, safePage * PAGE_SIZE + PAGE_SIZE)
return ( return (
<div className="page progress-page"> <div className="page progress-page">
@ -377,29 +382,19 @@ export default function Progress() {
)} )}
</div> </div>
<div className="progress-sidebar-pager"> {filtered.length > 0 && (
<span> <Pagination
{rangeStart}{rangeEnd} of {filtered.length} from={from}
</span> to={to}
<div className="progress-pager-actions"> total={filtered.length}
<button page={currentPage}
type="button" pages={pages}
className="btn btn-secondary btn-sm" setPage={setPage}
disabled={safePage <= 0} pageButtons={pageWindow(currentPage, pages)}
onClick={() => setPage((p) => Math.max(0, p - 1))} pageSize={pageSize}
> onPageSizeChange={setPageSize}
Previous />
</button> )}
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
Next
</button>
</div>
</div>
</aside> </aside>
<main className="progress-main"> <main className="progress-main">

View File

@ -110,6 +110,7 @@ function merge(row, template) {
// for the seed-only profile modal, but the filter reads `departments`. // for the seed-only profile modal, but the filter reads `departments`.
departments, departments,
department: departments[0] || template.department, department: departments[0] || template.department,
jobIds: candidatesApi.jobIdsOf(row),
// Prefer real Form / platform tags from manual_upload; seed only as fallback. // Prefer real Form / platform tags from manual_upload; seed only as fallback.
source: row.source || template.source, source: row.source || template.source,
// NO seed fallback. `ai_score` is the candidate's current ats_results row, // NO seed fallback. `ai_score` is the candidate's current ats_results row,
@ -143,6 +144,7 @@ export default function TalentPool() {
const [q, setQ] = useState('') const [q, setQ] = useState('')
const [dept, setDept] = useState('') const [dept, setDept] = useState('')
const [jobId, setJobId] = useState('')
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE) const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [profileFor, setProfileFor] = useState(null) const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null) const [atsFor, setAtsFor] = useState(null)
@ -156,8 +158,8 @@ export default function TalentPool() {
} }
const query = useQuery({ const query = useQuery({
queryKey: qk.candidates.list({ limit: pageSize }), queryKey: qk.candidates.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
queryFn: () => candidatesApi.list({ limit: pageSize }), queryFn: () => candidatesApi.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
}) })
const deptsQuery = useQuery({ const deptsQuery = useQuery({
queryKey: qk.jobPosts.departments(), queryKey: qk.jobPosts.departments(),
@ -167,6 +169,20 @@ export default function TalentPool() {
}, },
}) })
const departments = deptsQuery.data ?? [] const departments = deptsQuery.data ?? []
const jobsQuery = useQuery({
queryKey: qk.jobPosts.list({ top: 100, scope: 'talent-pool' }),
queryFn: async () => {
const res = await candidatesApi.listJobs()
const rows = Array.isArray(res?.data) ? res.data : []
return rows
.filter((row) => row && row.id != null)
.map((row) => ({
id: String(row.id),
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
}))
},
})
const jobs = jobsQuery.data ?? []
const pool = useMemo( const pool = useMemo(
() => buildPool(candidatesApi.toRows(query.data), templates), () => buildPool(candidatesApi.toRows(query.data), templates),
@ -230,7 +246,7 @@ export default function TalentPool() {
/* CSV of the FILTERED grid, built client-side there is no /candidate export /* CSV of the FILTERED grid, built client-side there is no /candidate export
endpoint (jobs and reports each own theirs). Exporting `list` rather than endpoint (jobs and reports each own theirs). Exporting `list` rather than
`pool` means the file always matches what the recruiter is looking at, `pool` means the file always matches what the recruiter is looking at,
search and department filter included. Company/skills are seed-overlay search, job, and department filter included. Company/skills are seed-overlay
values, same as the cards render. */ values, same as the cards render. */
function exportCsv() { function exportCsv() {
if (!list.length) { if (!list.length) {
@ -278,6 +294,12 @@ export default function TalentPool() {
<Icon name="search" /> <Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" /> <input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
</div> </div>
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
<option value="">All Jobs</option>
{jobs.map((j) => (
<option key={j.id} value={j.id}>{j.title}</option>
))}
</select>
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}> <select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
<option value="">All Departments</option> <option value="">All Departments</option>
{departments.map((d) => <option key={d} value={d}>{d}</option>)} {departments.map((d) => <option key={d} value={d}>{d}</option>)}
@ -306,7 +328,7 @@ export default function TalentPool() {
) : query.isPending ? ( ) : query.isPending ? (
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState> <EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
) : ( ) : (
<EmptyState title="No talent found">Try a different search or department.</EmptyState> <EmptyState title="No talent found">Try a different search, job, or department.</EmptyState>
)} )}
</div> </div>
) : ( ) : (

View File

@ -1856,11 +1856,20 @@ canvas { width: 100%; max-width: 100%; display: block; }
.progress-status-dot.tone-success { background: var(--success); } .progress-status-dot.tone-success { background: var(--success); }
.progress-status-dot.tone-warning { background: var(--warning); } .progress-status-dot.tone-warning { background: var(--warning); }
.progress-sidebar-pager { .progress-sidebar .pagination {
display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-direction: column;
padding: 12px; border-top: 1px solid var(--border); font-size: var(--fs-sm); color: var(--text-2); align-items: stretch;
gap: 8px;
padding: 10px 12px;
overflow: visible;
}
.progress-sidebar .page-info { width: 100%; }
.progress-sidebar .page-controls { flex-wrap: wrap; }
.progress-sidebar .page-nav {
justify-content: flex-start;
flex: 1 1 100%;
width: 100%;
} }
.progress-pager-actions { display: flex; gap: 8px; }
.progress-main { padding: 20px; min-width: 0; color: var(--text); } .progress-main { padding: 20px; min-width: 0; color: var(--text); }
.progress-detail { display: flex; flex-direction: column; gap: 20px; } .progress-detail { display: flex; flex-direction: column; gap: 20px; }