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.'}