add pagination inhistory
parent
63d5232cac
commit
a33f4e18fc
|
|
@ -266,6 +266,30 @@ class Inbox(SQLModel, table=True):
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
raise HTTPException(status_code=500,detail=str(e))
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
async def get_users_by_job_post_id(cls,session:AsyncSession,job_post_id):
|
||||||
|
"""Distinct users.id on inbox rows assigned to this job post.
|
||||||
|
|
||||||
|
Join is inbox → inbox_messages.assigned_job_post_id only — suggestions
|
||||||
|
are not a link. Invalid ids yield an empty set, not a 500.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
try:
|
||||||
|
jid=uuid.UUID(str(job_post_id))
|
||||||
|
except (TypeError,ValueError,AttributeError):
|
||||||
|
return set()
|
||||||
|
qry=(
|
||||||
|
select(cls.user_id)
|
||||||
|
.join(Inbox_Messages,cls.message_id==Inbox_Messages.id)
|
||||||
|
.where(Inbox_Messages.assigned_job_post_id==jid)
|
||||||
|
.where(cls.user_id.is_not(None))
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
result=await session.execute(qry)
|
||||||
|
return {uid for uid in result.scalars().all() if uid is not None}
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500,detail=str(e))
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
|
def scoped_to_job(cls, statement, department=None, recruiter_id=None):
|
||||||
"""Optional department / recruiter via inbox_messages → job_posts."""
|
"""Optional department / recruiter via inbox_messages → job_posts."""
|
||||||
|
|
|
||||||
|
|
@ -231,6 +231,7 @@ async def fetch_users(
|
||||||
role_id:Optional[int]=Query(None),
|
role_id:Optional[int]=Query(None),
|
||||||
top:Optional[int]=Query(None),
|
top:Optional[int]=Query(None),
|
||||||
skip:Optional[int]=Query(None),
|
skip:Optional[int]=Query(None),
|
||||||
|
assigned_job_post_id:Optional[str]=Query(None),
|
||||||
search:Optional[str]=Query(None),
|
search:Optional[str]=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
@ -242,7 +243,7 @@ async def fetch_users(
|
||||||
detail="Hiring managers can only list candidates on their requisitions",
|
detail="Hiring managers can only list candidates on their requisitions",
|
||||||
)
|
)
|
||||||
service=User(session=session)
|
service=User(session=session)
|
||||||
data=await service.get_users(role_id=role_id,top=top,skip=skip)
|
data=await service.get_users(role_id=role_id,top=top,skip=skip,assigned_job_post_id=assigned_job_post_id)
|
||||||
return JSONResponse(content={"data":data,"status_code":200})
|
return JSONResponse(content={"data":data,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -254,6 +255,7 @@ async def fetch_users(
|
||||||
async def count_candidate_users(
|
async def count_candidate_users(
|
||||||
role_id:Optional[int]=Query(None),
|
role_id:Optional[int]=Query(None),
|
||||||
search:Optional[str]=Query(None),
|
search:Optional[str]=Query(None),
|
||||||
|
assigned_job_post_id:Optional[str]=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
):
|
):
|
||||||
|
|
@ -265,7 +267,7 @@ async def count_candidate_users(
|
||||||
detail="Hiring managers can only list candidates on their requisitions",
|
detail="Hiring managers can only list candidates on their requisitions",
|
||||||
)
|
)
|
||||||
service=User(session=session)
|
service=User(session=session)
|
||||||
total=await service.count_users(search=search,role_id=role_id)
|
total=await service.count_users(search=search,role_id=role_id,assigned_job_post_id=assigned_job_post_id)
|
||||||
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
|
return JSONResponse(content={"data":{"total":total},"total":total,"status_code":200})
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
|
|
@ -942,6 +944,7 @@ async def fetch_manager_candidates(
|
||||||
async def fetch_candidate(
|
async def fetch_candidate(
|
||||||
user_id:str=Query(None),
|
user_id:str=Query(None),
|
||||||
limit:int=Query(10,ge=1,le=100),
|
limit:int=Query(10,ge=1,le=100),
|
||||||
|
assigned_job_post_id:UUID=Query(None),
|
||||||
offset:int=Query(0,ge=0),
|
offset:int=Query(0,ge=0),
|
||||||
search:str=Query(None),
|
search:str=Query(None),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
|
|
@ -950,7 +953,7 @@ async def fetch_candidate(
|
||||||
try:
|
try:
|
||||||
service=CandidateView(session=session)
|
service=CandidateView(session=session)
|
||||||
data=await service.get_candidate(
|
data=await service.get_candidate(
|
||||||
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,
|
user_id=user_id,limit=limit,offset=offset,search=search,current_user=current_user,assigned_job_post_id=assigned_job_post_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -982,7 +985,7 @@ async def update_candidate(
|
||||||
@router.get("/candidate/history/fetch")
|
@router.get("/candidate/history/fetch")
|
||||||
async def fetch_candidate_history(
|
async def fetch_candidate_history(
|
||||||
user_id:str=Query(...),
|
user_id:str=Query(...),
|
||||||
limit:int=Query(200,ge=1,le=500),
|
limit:int=Query(10,ge=1,le=100),
|
||||||
offset:int=Query(0,ge=0),
|
offset:int=Query(0,ge=0),
|
||||||
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)),
|
||||||
session: AsyncSession = Depends(get_session),
|
session: AsyncSession = Depends(get_session),
|
||||||
|
|
|
||||||
|
|
@ -785,7 +785,7 @@ class CandidateView:
|
||||||
cap=max(1,int(limit or 50))
|
cap=max(1,int(limit or 50))
|
||||||
return merged[start:start+cap],total
|
return merged[start:start+cap],total
|
||||||
|
|
||||||
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None):
|
async def get_candidate(self,user_id=None,limit=10,offset=0,search=None,current_user=None,assigned_job_post_id=None):
|
||||||
try:
|
try:
|
||||||
if not user_id and is_hiring_manager(current_user):
|
if not user_id and is_hiring_manager(current_user):
|
||||||
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
raise HTTPException(status_code=403,detail=MANAGER_SCOPE_DETAIL)
|
||||||
|
|
@ -794,9 +794,7 @@ class CandidateView:
|
||||||
self.session,current_user,user_id=user_id,
|
self.session,current_user,user_id=user_id,
|
||||||
)
|
)
|
||||||
detail=bool(user_id)
|
detail=bool(user_id)
|
||||||
# Detail mode must see every application for the candidate, not one page.
|
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=limit,offset=offset,search=search)
|
||||||
fetch_limit=1000 if detail else limit
|
|
||||||
rows=await Inbox.get_candidate_profile(session=self.session,user_id=user_id,limit=fetch_limit,offset=offset,search=search)
|
|
||||||
if detail:
|
if detail:
|
||||||
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
records=rows if isinstance(rows,list) else ([rows] if rows else [])
|
||||||
if records and is_hiring_manager(current_user):
|
if records and is_hiring_manager(current_user):
|
||||||
|
|
@ -843,7 +841,7 @@ class CandidateView:
|
||||||
if not isinstance(inbox_payloads,list):
|
if not isinstance(inbox_payloads,list):
|
||||||
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
inbox_payloads=[inbox_payloads] if inbox_payloads else []
|
||||||
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
manual_rows=await Manual_UPLOAD_CANDIDATE.list_for_talent_pool(
|
||||||
self.session,limit=fetch_limit,offset=0,search=search,
|
self.session,limit=limit,offset=0,search=search,
|
||||||
)
|
)
|
||||||
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
seen={p.get("user_id") for p in inbox_payloads if p.get("user_id")}
|
||||||
manual_payloads=[]
|
manual_payloads=[]
|
||||||
|
|
@ -883,7 +881,11 @@ class CandidateView:
|
||||||
})
|
})
|
||||||
if uid:
|
if uid:
|
||||||
seen.add(uid)
|
seen.add(uid)
|
||||||
return inbox_payloads+manual_payloads
|
data=inbox_payloads+manual_payloads
|
||||||
|
if assigned_job_post_id:
|
||||||
|
job_id=str(assigned_job_post_id)
|
||||||
|
data=[p for p in data if str(p.get("assigned_job_post_id") or "")==job_id]
|
||||||
|
return data
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
raise
|
raise
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
from fastapi import HTTPException
|
from fastapi import HTTPException
|
||||||
|
from inbox.models import Inbox
|
||||||
from notifications.views import Confirmation
|
from notifications.views import Confirmation
|
||||||
from role.models import EnumRoles,Roles
|
from role.models import EnumRoles,Roles
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
@ -63,11 +64,14 @@ class User:
|
||||||
await service.send_confirmation(user)
|
await service.send_confirmation(user)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None):
|
async def get_users(self,top:Optional[int]=None,skip:Optional[int]=None,search:Optional[str]=None,role_id:Optional[int]=None,assigned_job_post_id:Optional[str]=None):
|
||||||
if role_id:
|
if role_id:
|
||||||
users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id)
|
users=await Users.get_users(self.session,top=top,skip=skip,search=search,role_id=role_id)
|
||||||
else:
|
else:
|
||||||
users=await Users.get_users(self.session,top=top,skip=skip,search=search)
|
users=await Users.get_users(self.session,top=top,skip=skip,search=search)
|
||||||
|
if assigned_job_post_id:
|
||||||
|
linked=await Inbox.get_users_by_job_post_id(self.session,assigned_job_post_id)
|
||||||
|
users=[u for u in users if u.id in linked]
|
||||||
return [serialize_user(u) for u in users]
|
return [serialize_user(u) for u in users]
|
||||||
|
|
||||||
async def get_user_by_id(self,record_id):
|
async def get_user_by_id(self,record_id):
|
||||||
|
|
@ -136,8 +140,12 @@ class User:
|
||||||
]
|
]
|
||||||
return data,len(data)
|
return data,len(data)
|
||||||
|
|
||||||
async def count_users(self,search=None,role_id=None):
|
async def count_users(self,search=None,role_id=None,assigned_job_post_id=None):
|
||||||
return await Users.count_users(self.session,search,role_id=role_id)
|
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):
|
async def authenticate_user(self,email,password):
|
||||||
user=await Users.get_user_by_email(self.session,email)
|
user=await Users.get_user_by_email(self.session,email)
|
||||||
|
|
|
||||||
|
|
@ -169,27 +169,28 @@ export function toCandidateView(row) {
|
||||||
* role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
|
* role_id 8 is the seeded `candidate` role (id 4 is hiring_manager, the signup
|
||||||
* default). We send it explicitly so a missing param cannot list the wrong people.
|
* default). We send it explicitly so a missing param cannot list the wrong people.
|
||||||
*
|
*
|
||||||
* Three things this route does NOT do, all verified against
|
* This route:
|
||||||
* backend/job/app.py::fetch_users:
|
|
||||||
* - it returns `{data, status_code}` with NO `total` on the list; use
|
* - it returns `{data, status_code}` with NO `total` on the list; use
|
||||||
* GET /candidate/fetch/users/count (once on page open) for the pager total;
|
* GET /candidate/fetch/users/count (once on page open) for the pager total;
|
||||||
* - `top`/`skip` page the list; the Candidates screen sends the user's page
|
* - `top`/`skip` page the list; the Candidates screen sends the user's page
|
||||||
* size as `top` and `(page-1)*top` as `skip`;
|
* size as `top` and `(page-1)*top` as `skip`;
|
||||||
|
* - `assigned_job_post_id` keeps only users assigned to that job post
|
||||||
|
* (`inbox_messages.assigned_job_post_id`). Omit it for All Jobs.
|
||||||
* - it accepts a `search` query param but never forwards it to the service
|
* - it accepts a `search` query param but never forwards it to the service
|
||||||
* layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
|
* layer (`get_users(role_id=, top=, skip=)`), so searching is a no-op
|
||||||
* server-side. Filtering stays client-side on the fetched page until that
|
* server-side. Filtering stays client-side on the fetched page until that
|
||||||
* is fixed.
|
* is fixed.
|
||||||
*/
|
*/
|
||||||
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0 } = {}) {
|
export function listCandidateUsers({ roleId = 8, top = 10, skip = 0, assignedJobPostId } = {}) {
|
||||||
return request('/candidate/fetch/users', {
|
return request('/candidate/fetch/users', {
|
||||||
params: { role_id: roleId, top, skip },
|
params: { role_id: roleId, top, skip, assigned_job_post_id: assignedJobPostId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Total candidate-role users. Called once when the Candidates page opens. */
|
/** Total candidate-role users. Called once when the Candidates page opens. */
|
||||||
export function countCandidateUsers({ roleId = 8, search } = {}) {
|
export function countCandidateUsers({ roleId = 8, search, assignedJobPostId } = {}) {
|
||||||
return request('/candidate/fetch/users/count', {
|
return request('/candidate/fetch/users/count', {
|
||||||
params: { role_id: roleId, search },
|
params: { role_id: roleId, search, assigned_job_post_id: assignedJobPostId },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -236,9 +237,14 @@ export function toCandidateUserView(row) {
|
||||||
*
|
*
|
||||||
* `search` is an ilike over users.name / users.email only — it does NOT reach
|
* `search` is an ilike over users.name / users.email only — it does NOT reach
|
||||||
* the résumé text or the suggested job titles.
|
* the résumé text or the suggested job titles.
|
||||||
|
*
|
||||||
|
* `assignedJobPostId` maps to `assigned_job_post_id` and keeps only people
|
||||||
|
* assigned to that job. Empty / omitted is All Jobs.
|
||||||
*/
|
*/
|
||||||
export function list({ search, limit, offset } = {}) {
|
export function list({ search, limit, offset, assignedJobPostId } = {}) {
|
||||||
return request('/candidate/fetch', { params: { search, limit, offset } })
|
return request('/candidate/fetch', {
|
||||||
|
params: { search, limit, offset, assigned_job_post_id: assignedJobPostId },
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -274,6 +280,25 @@ export function toRows(res) {
|
||||||
return res?.data ? [res.data] : []
|
return res?.data ? [res.data] : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Assigned job-post ids only. `job_posts` / suggested_job_post_ids are
|
||||||
|
* matcher hints, not an assignment — the Candidates / Talent Pool job
|
||||||
|
* filter must not treat a suggestion as a link.
|
||||||
|
*/
|
||||||
|
export function jobIdsOf(row) {
|
||||||
|
if (!row || typeof row !== 'object') return []
|
||||||
|
const ids = []
|
||||||
|
const add = (value) => {
|
||||||
|
if (value == null || value === '') return
|
||||||
|
const id = String(value)
|
||||||
|
if (!ids.includes(id)) ids.push(id)
|
||||||
|
}
|
||||||
|
add(row.assigned_job_post_id)
|
||||||
|
add(row.assigned_job_post?.id)
|
||||||
|
add(row.job_post_id)
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* favorite/rating live on the `inbox` row, not on the user, so the server applies
|
* favorite/rating live on the `inbox` row, not on the user, so the server applies
|
||||||
* the change to EVERY application belonging to the candidate and hands back the
|
* the change to EVERY application belonging to the candidate and hands back the
|
||||||
|
|
@ -415,7 +440,7 @@ export function createActivity({ inboxId, type, status, description }) {
|
||||||
* detail query refetches on every write in the modal. Fetched lazily when the
|
* detail query refetches on every write in the modal. Fetched lazily when the
|
||||||
* History tab opens, paginated server-side.
|
* History tab opens, paginated server-side.
|
||||||
*/
|
*/
|
||||||
export function listHistory(userId, { limit = 200, offset = 0 } = {}) {
|
export function listHistory(userId, { limit = 10, offset = 0 } = {}) {
|
||||||
return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
|
return request('/candidate/history/fetch', { params: { user_id: userId, limit, offset } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
import Modal from '../ui/Modal'
|
import Modal from '../ui/Modal'
|
||||||
import OpenResumeButton from '../ui/OpenResumeButton'
|
import OpenResumeButton from '../ui/OpenResumeButton'
|
||||||
import { Tabs } from '../ui/Tabs'
|
import { Tabs } from '../ui/Tabs'
|
||||||
|
import { DEFAULT_PAGE_SIZE, Pagination, pageWindow } from '../ui/DataTable'
|
||||||
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
|
import { Avatar, Badge, EmptyState, Icon, ScoreChip, Stars } from '../ui/primitives'
|
||||||
import { useToast } from '../ui/Toast'
|
import { useToast } from '../ui/Toast'
|
||||||
import { useAuth } from '../auth/AuthContext'
|
import { useAuth } from '../auth/AuthContext'
|
||||||
|
|
@ -760,12 +761,13 @@ function historyDayLabel(value) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function HistoryTab({ userId }) {
|
function HistoryTab({ userId }) {
|
||||||
const [limit, setLimit] = useState(200)
|
const [skip, setSkip] = useState(0)
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||||
const q = useQuery({
|
const q = useQuery({
|
||||||
queryKey: qk.candidates.history(userId, { limit }),
|
queryKey: qk.candidates.history(userId, { limit: pageSize, offset: skip }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await candidatesApi.listHistory(userId, { limit })
|
const res = await candidatesApi.listHistory(userId, { limit: pageSize, offset: skip })
|
||||||
return { rows: res?.data ?? [], total: res?.total ?? 0 }
|
return { rows: Array.isArray(res?.data) ? res.data : [], total: typeof res?.total === 'number' ? res.total : 0 }
|
||||||
},
|
},
|
||||||
enabled: Boolean(userId),
|
enabled: Boolean(userId),
|
||||||
})
|
})
|
||||||
|
|
@ -783,7 +785,12 @@ function HistoryTab({ userId }) {
|
||||||
|
|
||||||
const rows = q.data?.rows ?? []
|
const rows = q.data?.rows ?? []
|
||||||
const total = q.data?.total ?? 0
|
const total = q.data?.total ?? 0
|
||||||
if (!rows.length) {
|
const pages = Math.max(1, Math.ceil(total / pageSize))
|
||||||
|
const currentPage = Math.min(Math.floor(skip / pageSize) + 1, pages)
|
||||||
|
const from = total ? skip + 1 : 0
|
||||||
|
const to = total ? skip + rows.length : 0
|
||||||
|
|
||||||
|
if (!rows.length && total === 0) {
|
||||||
return <EmptyState icon="clock" title="No history yet">Actions on this candidate will appear here.</EmptyState>
|
return <EmptyState icon="clock" title="No history yet">Actions on this candidate will appear here.</EmptyState>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -805,10 +812,18 @@ function HistoryTab({ userId }) {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{total > rows.length && (
|
{total > 0 && (
|
||||||
<button className="btn btn-secondary btn-sm" style={{ marginTop: 8 }} onClick={() => setLimit((n) => n + 200)}>
|
<Pagination
|
||||||
Load more
|
from={from}
|
||||||
</button>
|
to={to}
|
||||||
|
total={total}
|
||||||
|
page={currentPage}
|
||||||
|
pages={pages}
|
||||||
|
setPage={(p) => setSkip((p - 1) * pageSize)}
|
||||||
|
pageButtons={pageWindow(currentPage, pages)}
|
||||||
|
pageSize={pageSize}
|
||||||
|
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -50,29 +50,48 @@ const CANDIDATE_ROLE_ID = 8
|
||||||
The consequence is that the ATS columns have no source on this screen — see
|
The consequence is that the ATS columns have no source on this screen — see
|
||||||
toCandidateUserView. Open a candidate to get their score, which the shared
|
toCandidateUserView. Open a candidate to get their score, which the shared
|
||||||
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
|
Talent Pool profile modal (CandidateProfile.jsx) reads live by userId. */
|
||||||
async function fetchCandidates({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) {
|
async function fetchCandidates({ top = DEFAULT_PAGE_SIZE, skip = 0, assignedJobPostId } = {}) {
|
||||||
const [usersRes, appsRes] = await Promise.all([
|
const [usersRes, appsRes] = await Promise.all([
|
||||||
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip }),
|
candidatesApi.listCandidateUsers({ roleId: CANDIDATE_ROLE_ID, top, skip, assignedJobPostId }),
|
||||||
candidatesApi.list({ limit: 100 }).catch(() => null),
|
candidatesApi.list({ limit: 100, assignedJobPostId }).catch(() => null),
|
||||||
])
|
])
|
||||||
const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
|
const rows = Array.isArray(usersRes?.data) ? usersRes.data : []
|
||||||
const sourceByUser = new Map()
|
const sourceByUser = new Map()
|
||||||
|
const jobsByUser = new Map()
|
||||||
for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) {
|
for (const app of Array.isArray(appsRes?.data) ? appsRes.data : []) {
|
||||||
const uid = app.user_id
|
const uid = app.user_id
|
||||||
if (!uid || sourceByUser.has(uid)) continue
|
if (!uid) continue
|
||||||
if (app.source) sourceByUser.set(String(uid), app.source)
|
const key = String(uid)
|
||||||
|
if (app.source && !sourceByUser.has(key)) sourceByUser.set(key, app.source)
|
||||||
|
const ids = jobsByUser.get(key) ?? []
|
||||||
|
for (const id of candidatesApi.jobIdsOf(app)) {
|
||||||
|
if (!ids.includes(id)) ids.push(id)
|
||||||
|
}
|
||||||
|
jobsByUser.set(key, ids)
|
||||||
}
|
}
|
||||||
return rows.map((row) => {
|
return rows.map((row) => {
|
||||||
const view = candidatesApi.toCandidateUserView(row)
|
const view = candidatesApi.toCandidateUserView(row)
|
||||||
const source = sourceByUser.get(String(view.userId))
|
const key = String(view.userId)
|
||||||
return source ? { ...view, source } : view
|
const source = sourceByUser.get(key)
|
||||||
|
const jobIds = jobsByUser.get(key) ?? []
|
||||||
|
return {
|
||||||
|
...view,
|
||||||
|
source: source || view.source,
|
||||||
|
jobIds,
|
||||||
|
jobId: jobIds[0] ?? null,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchJobs() {
|
async function fetchJobs() {
|
||||||
const res = await candidatesApi.listJobs()
|
const res = await candidatesApi.listJobs()
|
||||||
const rows = Array.isArray(res?.data) ? res.data : []
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
return rows.map((row) => ({ id: row.id, title: row.title }))
|
return rows
|
||||||
|
.filter((row) => row && row.id != null)
|
||||||
|
.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
function recommendationOf(c) {
|
function recommendationOf(c) {
|
||||||
|
|
@ -130,6 +149,7 @@ function HiringManagerCandidates() {
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
|
const [jobId, setJobId] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const id = location.state?.openCandidate
|
const id = location.state?.openCandidate
|
||||||
|
|
@ -144,14 +164,27 @@ function HiringManagerCandidates() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const rowsAll = listQuery.data ?? []
|
const rowsAll = listQuery.data ?? []
|
||||||
|
const jobs = useMemo(() => {
|
||||||
|
const seen = new Set()
|
||||||
|
const out = []
|
||||||
|
for (const r of rowsAll) {
|
||||||
|
const id = r.job_post_id
|
||||||
|
if (id == null || seen.has(String(id))) continue
|
||||||
|
seen.add(String(id))
|
||||||
|
out.push({ id: String(id), title: r.job_title || 'Untitled' })
|
||||||
|
}
|
||||||
|
out.sort((a, b) => a.title.localeCompare(b.title))
|
||||||
|
return out
|
||||||
|
}, [rowsAll])
|
||||||
const rows = useMemo(() => {
|
const rows = useMemo(() => {
|
||||||
if (!q.trim()) return rowsAll
|
|
||||||
const needle = q.trim().toLowerCase()
|
const needle = q.trim().toLowerCase()
|
||||||
return rowsAll.filter((r) => {
|
return rowsAll.filter((r) => {
|
||||||
|
if (jobId && String(r.job_post_id) !== jobId) return false
|
||||||
|
if (!needle) return true
|
||||||
const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase()
|
const hay = [r.name, r.email, r.job_title].filter(Boolean).join(' ').toLowerCase()
|
||||||
return hay.includes(needle)
|
return hay.includes(needle)
|
||||||
})
|
})
|
||||||
}, [rowsAll, q])
|
}, [rowsAll, q, jobId])
|
||||||
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
|
|
@ -214,6 +247,12 @@ function HiringManagerCandidates() {
|
||||||
onChange={(e) => setQ(e.target.value)}
|
onChange={(e) => setQ(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||||
|
<option value="">All Jobs</option>
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<option key={j.id} value={j.id}>{j.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
</div>
|
</div>
|
||||||
{listQuery.isPending && <SkeletonRows rows={4} />}
|
{listQuery.isPending && <SkeletonRows rows={4} />}
|
||||||
{listQuery.isError && (
|
{listQuery.isError && (
|
||||||
|
|
@ -225,7 +264,11 @@ function HiringManagerCandidates() {
|
||||||
<DataTable
|
<DataTable
|
||||||
columns={columns}
|
columns={columns}
|
||||||
rows={rows}
|
rows={rows}
|
||||||
empty="No candidates are allocated to jobs opened from your requisitions yet."
|
empty={
|
||||||
|
jobId || q
|
||||||
|
? 'No candidates match these filters.'
|
||||||
|
: 'No candidates are allocated to jobs opened from your requisitions yet.'
|
||||||
|
}
|
||||||
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
|
onRowClick={(r) => r.user_id && navigate(`/candidate/${r.user_id}`)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
@ -243,6 +286,7 @@ function RecruiterCandidates() {
|
||||||
const updateCandidates = useSeedMutation('candidates')
|
const updateCandidates = useSeedMutation('candidates')
|
||||||
|
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
|
const [jobId, setJobId] = useState('')
|
||||||
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
const [filters, setFilters] = useState(EMPTY_FILTERS)
|
||||||
const [showFilters, setShowFilters] = useState(false)
|
const [showFilters, setShowFilters] = useState(false)
|
||||||
const [sortMode, setSortMode] = useState('recent')
|
const [sortMode, setSortMode] = useState('recent')
|
||||||
|
|
@ -253,16 +297,18 @@ function RecruiterCandidates() {
|
||||||
const [adding, setAdding] = useState(false)
|
const [adding, setAdding] = useState(false)
|
||||||
|
|
||||||
const countQuery = useQuery({
|
const countQuery = useQuery({
|
||||||
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID }),
|
queryKey: qk.candidates.count({ roleId: CANDIDATE_ROLE_ID, assignedJobPostId: jobId || undefined }),
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
const res = await candidatesApi.countCandidateUsers({ roleId: CANDIDATE_ROLE_ID })
|
const res = await candidatesApi.countCandidateUsers({
|
||||||
|
roleId: CANDIDATE_ROLE_ID,
|
||||||
|
assignedJobPostId: jobId || undefined,
|
||||||
|
})
|
||||||
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
|
return typeof res?.total === 'number' ? res.total : (res?.data?.total ?? 0)
|
||||||
},
|
},
|
||||||
staleTime: Infinity,
|
|
||||||
})
|
})
|
||||||
const candidatesQuery = useQuery({
|
const candidatesQuery = useQuery({
|
||||||
queryKey: qk.candidates.list({ top: pageSize, skip }),
|
queryKey: qk.candidates.list({ top: pageSize, skip, assignedJobPostId: jobId || undefined }),
|
||||||
queryFn: () => fetchCandidates({ top: pageSize, skip }),
|
queryFn: () => fetchCandidates({ top: pageSize, skip, assignedJobPostId: jobId || undefined }),
|
||||||
})
|
})
|
||||||
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
const jobsQuery = useQuery({ queryKey: qk.jobPosts.list(), queryFn: fetchJobs })
|
||||||
const deptsQuery = useQuery({
|
const deptsQuery = useQuery({
|
||||||
|
|
@ -468,6 +514,19 @@ function RecruiterCandidates() {
|
||||||
<Icon name="search" />
|
<Icon name="search" />
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search name, skill, company…" />
|
||||||
</div>
|
</div>
|
||||||
|
<select
|
||||||
|
className="select"
|
||||||
|
value={jobId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setJobId(e.target.value)
|
||||||
|
setSkip(0)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">All Jobs</option>
|
||||||
|
{(jobsQuery.data ?? []).map((j) => (
|
||||||
|
<option key={j.id} value={j.id}>{j.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
|
<button className="btn btn-secondary" onClick={() => setShowFilters((s) => !s)}>
|
||||||
<Icon name="filter" /> Filters
|
<Icon name="filter" /> Filters
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -488,9 +547,10 @@ function RecruiterCandidates() {
|
||||||
className="filter-panel"
|
className="filter-panel"
|
||||||
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
|
style={{ display: 'grid', padding: '16px 0', borderTop: '1px solid var(--border)', marginTop: 12 }}
|
||||||
>
|
>
|
||||||
{/* Job / Matched Skill / Source / ATS Score / scoring Status are gone
|
{/* Matched Skill / Source / ATS Score / scoring Status stay out of
|
||||||
with the scoring columns: on a users row every one of them would
|
this panel: those fields are not on a users row. Job is a
|
||||||
match nothing and silently empty the table. */}
|
toolbar dropdown, sent as assigned_job_post_id on
|
||||||
|
GET /candidate/fetch/users. */}
|
||||||
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
|
<Facet label="Account" value={filters.account} onChange={(v) => setFilter('account', v)} any="Any Account" options={['Active', 'Unconfirmed']} />
|
||||||
<Facet label="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} />
|
<Facet label="Department" value={filters.department} onChange={(v) => setFilter('department', v)} any="All Departments" options={deptsQuery.data ?? []} />
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -519,8 +579,10 @@ function RecruiterCandidates() {
|
||||||
{t.pageRows.length === 0 ? (
|
{t.pageRows.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={columns.length}>
|
<td colSpan={columns.length}>
|
||||||
<EmptyState title="No candidates yet">
|
<EmptyState title={candidates.length ? 'No matches' : 'No candidates yet'}>
|
||||||
Score resumes in CV Import to fill this table.
|
{candidates.length
|
||||||
|
? 'Try a different search or job filter.'
|
||||||
|
: 'Score resumes in CV Import to fill this table.'}
|
||||||
</EmptyState>
|
</EmptyState>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -568,7 +630,7 @@ function RecruiterCandidates() {
|
||||||
setPage={(p) => setSkip((p - 1) * pageSize)}
|
setPage={(p) => setSkip((p - 1) * pageSize)}
|
||||||
pageButtons={pageWindow(currentPage, pages)}
|
pageButtons={pageWindow(currentPage, pages)}
|
||||||
pageSize={pageSize}
|
pageSize={pageSize}
|
||||||
onPageSizeChange={(n) => setPageSize(n)}
|
onPageSizeChange={(n) => { setPageSize(n); setSkip(0) }}
|
||||||
pageSizeMax={500}
|
pageSizeMax={500}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -10,13 +10,12 @@ import { useSearchParams } from 'react-router-dom'
|
||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
|
||||||
import PageHeader from '../ui/PageHeader'
|
import PageHeader from '../ui/PageHeader'
|
||||||
|
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
|
||||||
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
|
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
|
||||||
import { qk } from '../lib/queryKeys'
|
import { qk } from '../lib/queryKeys'
|
||||||
import { friendlyAuthError } from '../lib/errors'
|
import { friendlyAuthError } from '../lib/errors'
|
||||||
import * as jobStatsApi from '../api/jobStats'
|
import * as jobStatsApi from '../api/jobStats'
|
||||||
|
|
||||||
const PAGE_SIZE = 10
|
|
||||||
|
|
||||||
const STAGES = [
|
const STAGES = [
|
||||||
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
|
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
|
||||||
{ key: 'screened', label: 'Screened', tone: 'purple' },
|
{ key: 'screened', label: 'Screened', tone: 'purple' },
|
||||||
|
|
@ -209,7 +208,8 @@ export default function Progress() {
|
||||||
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
const [status, setStatus] = useState('all')
|
const [status, setStatus] = useState('all')
|
||||||
const [page, setPage] = useState(0)
|
const [page, setPage] = useState(1)
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||||
|
|
||||||
const statsQuery = useQuery({
|
const statsQuery = useQuery({
|
||||||
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
|
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
|
||||||
|
|
@ -240,20 +240,27 @@ export default function Progress() {
|
||||||
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
|
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
|
||||||
}, [jobs, query, status])
|
}, [jobs, query, status])
|
||||||
|
|
||||||
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
|
const pages = Math.max(1, Math.ceil(filtered.length / pageSize))
|
||||||
const safePage = Math.min(page, pageCount - 1)
|
const currentPage = Math.min(page, pages)
|
||||||
const pageRows = filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE)
|
const start = (currentPage - 1) * pageSize
|
||||||
|
const pageRows = filtered.slice(start, start + pageSize)
|
||||||
|
const from = filtered.length ? start + 1 : 0
|
||||||
|
const to = Math.min(start + pageSize, filtered.length)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPage(0)
|
setPage(1)
|
||||||
}, [query, status])
|
}, [query, status])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setPage((p) => pageAfterSizeChange(p, filtered.length, pageSize))
|
||||||
|
}, [pageSize, filtered.length])
|
||||||
|
|
||||||
// Deep-link: once jobs load, jump the sidebar page to that role.
|
// Deep-link: once jobs load, jump the sidebar page to that role.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!deepLinkJobId || !filtered.length) return
|
if (!deepLinkJobId || !filtered.length) return
|
||||||
const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId))
|
const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId))
|
||||||
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
|
if (idx >= 0) setPage(Math.floor(idx / pageSize) + 1)
|
||||||
}, [deepLinkJobId, filtered])
|
}, [deepLinkJobId, filtered, pageSize])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!jobs.length) {
|
if (!jobs.length) {
|
||||||
|
|
@ -274,7 +281,7 @@ export default function Progress() {
|
||||||
const next = String(id || '')
|
const next = String(id || '')
|
||||||
setSelectedId(next)
|
setSelectedId(next)
|
||||||
const idx = filtered.findIndex((j) => String(j.id) === next)
|
const idx = filtered.findIndex((j) => String(j.id) === next)
|
||||||
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
|
if (idx >= 0) setPage(Math.floor(idx / pageSize) + 1)
|
||||||
setSearchParams((prev) => {
|
setSearchParams((prev) => {
|
||||||
const nextParams = new URLSearchParams(prev)
|
const nextParams = new URLSearchParams(prev)
|
||||||
if (next) nextParams.set('job', next)
|
if (next) nextParams.set('job', next)
|
||||||
|
|
@ -290,8 +297,6 @@ export default function Progress() {
|
||||||
|
|
||||||
const totalApplicants = sumField(jobs, 'total')
|
const totalApplicants = sumField(jobs, 'total')
|
||||||
const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length
|
const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length
|
||||||
const rangeStart = filtered.length ? safePage * PAGE_SIZE + 1 : 0
|
|
||||||
const rangeEnd = Math.min(filtered.length, safePage * PAGE_SIZE + PAGE_SIZE)
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page progress-page">
|
<div className="page progress-page">
|
||||||
|
|
@ -377,29 +382,19 @@ export default function Progress() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="progress-sidebar-pager">
|
{filtered.length > 0 && (
|
||||||
<span>
|
<Pagination
|
||||||
{rangeStart}–{rangeEnd} of {filtered.length}
|
from={from}
|
||||||
</span>
|
to={to}
|
||||||
<div className="progress-pager-actions">
|
total={filtered.length}
|
||||||
<button
|
page={currentPage}
|
||||||
type="button"
|
pages={pages}
|
||||||
className="btn btn-secondary btn-sm"
|
setPage={setPage}
|
||||||
disabled={safePage <= 0}
|
pageButtons={pageWindow(currentPage, pages)}
|
||||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
pageSize={pageSize}
|
||||||
>
|
onPageSizeChange={setPageSize}
|
||||||
Previous
|
/>
|
||||||
</button>
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary btn-sm"
|
|
||||||
disabled={safePage >= pageCount - 1}
|
|
||||||
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
|
|
||||||
>
|
|
||||||
Next
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="progress-main">
|
<main className="progress-main">
|
||||||
|
|
|
||||||
|
|
@ -110,6 +110,7 @@ function merge(row, template) {
|
||||||
// for the seed-only profile modal, but the filter reads `departments`.
|
// for the seed-only profile modal, but the filter reads `departments`.
|
||||||
departments,
|
departments,
|
||||||
department: departments[0] || template.department,
|
department: departments[0] || template.department,
|
||||||
|
jobIds: candidatesApi.jobIdsOf(row),
|
||||||
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
|
// Prefer real Form / platform tags from manual_upload; seed only as fallback.
|
||||||
source: row.source || template.source,
|
source: row.source || template.source,
|
||||||
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
// NO seed fallback. `ai_score` is the candidate's current ats_results row,
|
||||||
|
|
@ -143,6 +144,7 @@ export default function TalentPool() {
|
||||||
|
|
||||||
const [q, setQ] = useState('')
|
const [q, setQ] = useState('')
|
||||||
const [dept, setDept] = useState('')
|
const [dept, setDept] = useState('')
|
||||||
|
const [jobId, setJobId] = useState('')
|
||||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
|
||||||
const [profileFor, setProfileFor] = useState(null)
|
const [profileFor, setProfileFor] = useState(null)
|
||||||
const [atsFor, setAtsFor] = useState(null)
|
const [atsFor, setAtsFor] = useState(null)
|
||||||
|
|
@ -156,8 +158,8 @@ export default function TalentPool() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: qk.candidates.list({ limit: pageSize }),
|
queryKey: qk.candidates.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
|
||||||
queryFn: () => candidatesApi.list({ limit: pageSize }),
|
queryFn: () => candidatesApi.list({ limit: pageSize, assignedJobPostId: jobId || undefined }),
|
||||||
})
|
})
|
||||||
const deptsQuery = useQuery({
|
const deptsQuery = useQuery({
|
||||||
queryKey: qk.jobPosts.departments(),
|
queryKey: qk.jobPosts.departments(),
|
||||||
|
|
@ -167,6 +169,20 @@ export default function TalentPool() {
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
const departments = deptsQuery.data ?? []
|
const departments = deptsQuery.data ?? []
|
||||||
|
const jobsQuery = useQuery({
|
||||||
|
queryKey: qk.jobPosts.list({ top: 100, scope: 'talent-pool' }),
|
||||||
|
queryFn: async () => {
|
||||||
|
const res = await candidatesApi.listJobs()
|
||||||
|
const rows = Array.isArray(res?.data) ? res.data : []
|
||||||
|
return rows
|
||||||
|
.filter((row) => row && row.id != null)
|
||||||
|
.map((row) => ({
|
||||||
|
id: String(row.id),
|
||||||
|
title: typeof row.title === 'string' && row.title.trim() ? row.title : 'Untitled',
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const jobs = jobsQuery.data ?? []
|
||||||
|
|
||||||
const pool = useMemo(
|
const pool = useMemo(
|
||||||
() => buildPool(candidatesApi.toRows(query.data), templates),
|
() => buildPool(candidatesApi.toRows(query.data), templates),
|
||||||
|
|
@ -230,7 +246,7 @@ export default function TalentPool() {
|
||||||
/* CSV of the FILTERED grid, built client-side — there is no /candidate export
|
/* CSV of the FILTERED grid, built client-side — there is no /candidate export
|
||||||
endpoint (jobs and reports each own theirs). Exporting `list` rather than
|
endpoint (jobs and reports each own theirs). Exporting `list` rather than
|
||||||
`pool` means the file always matches what the recruiter is looking at,
|
`pool` means the file always matches what the recruiter is looking at,
|
||||||
search and department filter included. Company/skills are seed-overlay
|
search, job, and department filter included. Company/skills are seed-overlay
|
||||||
values, same as the cards render. */
|
values, same as the cards render. */
|
||||||
function exportCsv() {
|
function exportCsv() {
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
|
|
@ -278,6 +294,12 @@ export default function TalentPool() {
|
||||||
<Icon name="search" />
|
<Icon name="search" />
|
||||||
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
|
<input value={q} onChange={(e) => setQ(e.target.value)} placeholder="Search by name, skill, company…" />
|
||||||
</div>
|
</div>
|
||||||
|
<select className="select" value={jobId} onChange={(e) => setJobId(e.target.value)}>
|
||||||
|
<option value="">All Jobs</option>
|
||||||
|
{jobs.map((j) => (
|
||||||
|
<option key={j.id} value={j.id}>{j.title}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
<select className="select" value={dept} onChange={(e) => setDept(e.target.value)}>
|
||||||
<option value="">All Departments</option>
|
<option value="">All Departments</option>
|
||||||
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
|
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||||
|
|
@ -306,7 +328,7 @@ export default function TalentPool() {
|
||||||
) : query.isPending ? (
|
) : query.isPending ? (
|
||||||
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
|
<EmptyState title="Loading talent pool…">Fetching candidates.</EmptyState>
|
||||||
) : (
|
) : (
|
||||||
<EmptyState title="No talent found">Try a different search or department.</EmptyState>
|
<EmptyState title="No talent found">Try a different search, job, or department.</EmptyState>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -1856,11 +1856,20 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.progress-status-dot.tone-success { background: var(--success); }
|
.progress-status-dot.tone-success { background: var(--success); }
|
||||||
.progress-status-dot.tone-warning { background: var(--warning); }
|
.progress-status-dot.tone-warning { background: var(--warning); }
|
||||||
|
|
||||||
.progress-sidebar-pager {
|
.progress-sidebar .pagination {
|
||||||
display: flex; align-items: center; justify-content: space-between; gap: 10px;
|
flex-direction: column;
|
||||||
padding: 12px; border-top: 1px solid var(--border); font-size: var(--fs-sm); color: var(--text-2);
|
align-items: stretch;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
.progress-sidebar .page-info { width: 100%; }
|
||||||
|
.progress-sidebar .page-controls { flex-wrap: wrap; }
|
||||||
|
.progress-sidebar .page-nav {
|
||||||
|
justify-content: flex-start;
|
||||||
|
flex: 1 1 100%;
|
||||||
|
width: 100%;
|
||||||
}
|
}
|
||||||
.progress-pager-actions { display: flex; gap: 8px; }
|
|
||||||
|
|
||||||
.progress-main { padding: 20px; min-width: 0; color: var(--text); }
|
.progress-main { padding: 20px; min-width: 0; color: var(--text); }
|
||||||
.progress-detail { display: flex; flex-direction: column; gap: 20px; }
|
.progress-detail { display: flex; flex-direction: column; gap: 20px; }
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue