diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 8875179..7de274a 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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.""" diff --git a/backend/job/app.py b/backend/job/app.py index c10794c..e9dacd6 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -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), diff --git a/backend/job/candidate/views.py b/backend/job/candidate/views.py index 124138c..193f40d 100644 --- a/backend/job/candidate/views.py +++ b/backend/job/candidate/views.py @@ -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: diff --git a/backend/users/views.py b/backend/users/views.py index 319db4e..c350738 100644 --- a/backend/users/views.py +++ b/backend/users/views.py @@ -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): - return await Users.count_users(self.session,search,role_id=role_id) + 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) diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index 48aeca4..ee27bfa 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -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 } }) } diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index a4d4bf3..cceae3b 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -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 Actions on this candidate will appear here. } @@ -805,10 +812,18 @@ function HistoryTab({ userId }) { ))} - {total > rows.length && ( - + {total > 0 && ( + setSkip((p - 1) * pageSize)} + pageButtons={pageWindow(currentPage, pages)} + pageSize={pageSize} + onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }} + /> )} ) diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 9e021f6..36b1f76 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -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)} /> + {listQuery.isPending && } {listQuery.isError && ( @@ -225,7 +264,11 @@ function HiringManagerCandidates() { 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() { setQ(e.target.value)} placeholder="Search name, skill, company…" /> + @@ -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. */} setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} /> setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} /> @@ -519,8 +579,10 @@ function RecruiterCandidates() { {t.pageRows.length === 0 ? ( - - 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.'} @@ -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} /> diff --git a/frontend/src/screens/Progress.jsx b/frontend/src/screens/Progress.jsx index fc56c36..c668def 100644 --- a/frontend/src/screens/Progress.jsx +++ b/frontend/src/screens/Progress.jsx @@ -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 (
@@ -377,29 +382,19 @@ export default function Progress() { )}
-
- - {rangeStart}–{rangeEnd} of {filtered.length} - -
- - -
-
+ {filtered.length > 0 && ( + + )}
diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 78548ee..5ae864b 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -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() { setQ(e.target.value)} placeholder="Search by name, skill, company…" /> +