add pagination inhistory
parent
63d5232cac
commit
a33f4e18fc
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 } })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue