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:
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
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
"""Optional department / recruiter via inbox_messages → job_posts."""

View File

@ -231,6 +231,7 @@ async def fetch_users(
role_id:Optional[int]=Query(None),
top:Optional[int]=Query(None),
skip:Optional[int]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
search:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
@ -242,7 +243,7 @@ async def fetch_users(
detail="Hiring managers can only list candidates on their requisitions",
)
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})
except HTTPException:
raise
@ -254,6 +255,7 @@ async def fetch_users(
async def count_candidate_users(
role_id:Optional[int]=Query(None),
search:Optional[str]=Query(None),
assigned_job_post_id:Optional[str]=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),
):
@ -265,7 +267,7 @@ async def count_candidate_users(
detail="Hiring managers can only list candidates on their requisitions",
)
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})
except HTTPException:
raise
@ -942,6 +944,7 @@ async def fetch_manager_candidates(
async def fetch_candidate(
user_id:str=Query(None),
limit:int=Query(10,ge=1,le=100),
assigned_job_post_id:UUID=Query(None),
offset:int=Query(0,ge=0),
search:str=Query(None),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
@ -950,7 +953,7 @@ async def fetch_candidate(
try:
service=CandidateView(session=session)
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")
async def fetch_candidate_history(
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),
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
session: AsyncSession = Depends(get_session),

View File

@ -785,7 +785,7 @@ class CandidateView:
cap=max(1,int(limit or 50))
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:
if not user_id and is_hiring_manager(current_user):
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
@ -794,9 +794,7 @@ class CandidateView:
self.session,current_user,user_id=user_id,
)
detail=bool(user_id)
# Detail mode must see every application for the candidate, not one page.
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)
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search)
if detail:
records=rows if isinstance(rows,list) else ([rows] if rows else [])
if records and is_hiring_manager(current_user):
@ -843,7 +841,7 @@ class CandidateView:
if not isinstance(inbox_payloads,list):
inbox_payloads=[inbox_payloads] if inbox_payloads else []
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")}
manual_payloads=[]
@ -883,7 +881,11 @@ class CandidateView:
})
if 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:
raise
except Exception as e:

View File

@ -1,4 +1,5 @@
from fastapi import HTTPException
from inbox.models import Inbox
from notifications.views import Confirmation
from role.models import EnumRoles,Roles
from users.models import Users
@ -63,11 +64,14 @@ class User:
await service.send_confirmation(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:
users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id)
else:
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]
async def get_user_by_id(self,record_id):
@ -136,8 +140,12 @@ class User:
]
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)
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):
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
* default). We send it explicitly so a missing param cannot list the wrong people.
*
* Three things this route does NOT do, all verified against
* backend/job/app.py::fetch_users:
* This route:
* - 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;
* - `top`/`skip` page the list; the Candidates screen sends the user's page
* 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
* 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
* 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', {
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. */
export function countCandidateUsers({ roleId = 8, search } = {}) {
export function countCandidateUsers({ roleId = 8, search, assignedJobPostId } = {}) {
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
* 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 } = {}) {
return request('/candidate/fetch', { params: { search, limit, offset } })
export function list({ search, limit, offset, assignedJobPostId } = {}) {
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] : []
}
/**
* 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
* 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
* 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 } })
}

View File

@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import OpenResumeButton from '../ui/OpenResumeButton'
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 { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
@ -760,12 +761,13 @@ function historyDayLabel(value) {
}
function HistoryTab({ userId }) {
const [limit, setLimit] = useState(200)
const [skip, setSkip] = useState(0)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const q = useQuery({
queryKey: qk.candidates.history(userId, { limit }),
queryKey: qk.candidates.history(userId, { limit: pageSize, offset: skip }),
queryFn: async () => {
const res = await candidatesApi.listHistory(userId, { limit })
return { rows: res?.data ?? [], total: res?.total ?? 0 }
const res = await candidatesApi.listHistory(userId, { limit: pageSize, offset: skip })
return { rows: Array.isArray(res?.data) ? res.data : [], total: typeof res?.total === 'number' ? res.total : 0 }
},
enabled: Boolean(userId),
})
@ -783,7 +785,12 @@ function HistoryTab({ userId }) {
const rows = q.data?.rows ?? []
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>
}
@ -805,10 +812,18 @@ function HistoryTab({ userId }) {
</div>
</div>
))}
{total > rows.length && (
<button className="btn btn-secondary btn-sm" style={{ marginTop: 8 }} onClick={() => setLimit((n) => n + 200)}>
Load more
</button>
{total > 0 && (
<Pagination
from={from}
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
toCandidateUserView. Open a candidate to get their score, which the shared
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([
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip }),
candidatesApi.list({ limit: 100 }).catch(() => null),
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip, assignedJobPostId }),
candidatesApi.list({ limit: 100, assignedJobPostId }).catch(() => null),
])
const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
const sourceByUser = new Map()
const jobsByUser = new Map()
for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) {
const uid = app.user_id
if (!uid || sourceByUser.has(uid)) continue
if (app.source) sourceByUser.set(String(uid), app.source)
if (!uid) continue
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) => {
const view = candidatesApi.toCandidateUserView(row)
const source = sourceByUser.get(String(view.userId))
return source ? { ...view, source } : view
const key = String(view.userId)
const source = sourceByUser.get(key)
const jobIds = jobsByUser.get(key) ?? []
return {
...view,
source: source || view.source,
jobIds,
jobId: jobIds[0] ?? null,
}
})
}
async function fetchJobs() {
const res = await candidatesApi.listJobs()
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) {
@ -130,6 +149,7 @@ function HiringManagerCandidates() {
const navigate = useNavigate()
const location = useLocation()
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
useEffect(() => {
const id = location.state?.openCandidate
@ -144,14 +164,27 @@ function HiringManagerCandidates() {
},
})
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(() => {
if (!q.trim()) return rowsAll
const needle = q.trim().toLowerCase()
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()
return hay.includes(needle)
})
}, [rowsAll, q])
}, [rowsAll, q, jobId])
const columns = [
{
@ -214,6 +247,12 @@ function HiringManagerCandidates() {
onChange={(e) => setQ(e.target.value)}
/>
</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>
{listQuery.isPending && <SkeletonRows rows={4} />}
{listQuery.isError && (
@ -225,7 +264,11 @@ function HiringManagerCandidates() {
<DataTable
columns={columns}
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}`)}
/>
)}
@ -243,6 +286,7 @@ function RecruiterCandidates() {
const updateCandidates = useSeedMutation('candidates')
const [q, setQ] = useState('')
const [jobId, setJobId] = useState('')
const [filters, setFilters] = useState(EMPTY_FILTERS)
const [showFilters, setShowFilters] = useState(false)
const [sortMode, setSortMode] = useState('recent')
@ -253,16 +297,18 @@ function RecruiterCandidates() {
const [adding, setAdding] = useState(false)
const countQuery = useQuery({
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID }),
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID, assignedJobPostId: jobId || undefined }),
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)
},
staleTime: Infinity,
})
const candidatesQuery = useQuery({
queryKey: qk.candidates.list({ top: pageSize, skip }),
queryFn: () => fetchCandidates({ top: pageSize, skip }),
queryKey: qk.candidates.list({ top: pageSize, skip, assignedJobPostId: jobId || undefined }),
queryFn: () => fetchCandidates({ top: pageSize, skip, assignedJobPostId: jobId || undefined }),
})
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
const deptsQuery = useQuery({
@ -468,6 +514,19 @@ function RecruiterCandidates() {
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
</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)}>
<Icon name="filter" /> Filters
</button>
@ -488,9 +547,10 @@ function RecruiterCandidates() {
className="filter-panel"
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
>
{/* Job / Matched Skill / Source / ATS Score / scoring Status are gone
with the scoring columns: on a users row every one of them would
match nothing and silently empty the table. */}
{/* Matched Skill / Source / ATS Score / scoring Status stay out of
this panel: those fields are not on a users row. Job is a
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="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} />
</div>
@ -519,8 +579,10 @@ function RecruiterCandidates() {
{t.pageRows.length === 0 ? (
<tr>
<td colSpan={columns.length}>
<EmptyState title="No candidates yet">
Score resumes in CV Import to fill this table.
<EmptyState title={candidates.length ? 'No matches' : 'No candidates yet'}>
{candidates.length
? 'Try a different search or job filter.'
: 'Score resumes in CV Import to fill this table.'}
</EmptyState>
</td>
</tr>
@ -568,7 +630,7 @@ function RecruiterCandidates() {
setPage={(p) => setSkip((p - 1) * pageSize)}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => setPageSize(n)}
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
pageSizeMax={500}
/>
</div>

View File

@ -10,13 +10,12 @@ import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobStatsApi from '../api/jobStats'
const PAGE_SIZE = 10
const STAGES = [
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
{ key: 'screened', label: 'Screened', tone: 'purple' },
@ -209,7 +208,8 @@ export default function Progress() {
const [selectedId, setSelectedId] = useState(deepLinkJobId)
const [query, setQuery] = useState('')
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({
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))
}, [jobs, query, status])
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount - 1)
const pageRows = filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE)
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
const currentPage = Math.min(page, pages)
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(() => {
setPage(0)
setPage(1)
}, [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.
useEffect(() => {
if (!deepLinkJobId || !filtered.length) return
const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId))
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
}, [deepLinkJobId, filtered])
if (idx >= 0) setPage(Math.floor(idx / pageSize) + 1)
}, [deepLinkJobId, filtered, pageSize])
useEffect(() => {
if (!jobs.length) {
@ -274,7 +281,7 @@ export default function Progress() {
const next = String(id || '')
setSelectedId(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) => {
const nextParams = new URLSearchParams(prev)
if (next) nextParams.set('job', next)
@ -290,8 +297,6 @@ export default function Progress() {
const totalApplicants = sumField(jobs, 'total')
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 (
<div className="page progress-page">
@ -377,29 +382,19 @@ export default function Progress() {
)}
</div>
<div className="progress-sidebar-pager">
<span>
{rangeStart}{rangeEnd} of {filtered.length}
</span>
<div className="progress-pager-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
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>
{filtered.length > 0 && (
<Pagination
from={from}
to={to}
total={filtered.length}
page={currentPage}
pages={pages}
setPage={setPage}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={setPageSize}
/>
)}
</aside>
<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`.
departments,
department: departments[0] || template.department,
jobIds: candidatesApi.jobIdsOf(row),
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
source: row.source || template.source,
// 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 [dept, setDept] = useState('')
const [jobId, setJobId] = useState('')
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [profileFor, setProfileFor] = useState(null)
const [atsFor, setAtsFor] = useState(null)
@ -156,8 +158,8 @@ export default function TalentPool() {
}
const query = useQuery({
queryKey: qk.candidates.list({ limit: pageSize }),
queryFn: () => candidatesApi.list({ limit: pageSize }),
queryKey: qk.candidates.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
queryFn: () => candidatesApi.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
})
const deptsQuery = useQuery({
queryKey: qk.jobPosts.departments(),
@ -167,6 +169,20 @@ export default function TalentPool() {
},
})
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(
() => 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
endpoint (jobs and reports each own theirs). Exporting `list` rather than
`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. */
function exportCsv() {
if (!list.length) {
@ -278,6 +294,12 @@ export default function TalentPool() {
<Icon name="search" />
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
</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)}>
<option value="">All Departments</option>
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
@ -306,7 +328,7 @@ export default function TalentPool() {
) : query.isPending ? (
<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>
) : (

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-warning { background: var(--warning); }
.progress-sidebar-pager {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 12px; border-top: 1px solid var(--border); font-size: var(--fs-sm); color: var(--text-2);
.progress-sidebar .pagination {
flex-direction: column;
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-detail { display: flex; flex-direction: column; gap: 20px; }