HR-ATS-Portal/frontend/src/screens/Managers.jsx

312 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useEffect, useMemo, useState } from 'react'
import { useLocation, useNavigate } from 'react-router-dom'
import { useMutation, useQuery } from '@tanstack/react-query'
import Modal from '../ui/Modal'
import { DEFAULT_PAGE_SIZE, Pagination, pageAfterSizeChange, pageWindow } from '../ui/DataTable'
import PageHeader from '../ui/PageHeader'
import { Avatar, Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as usersApi from '../api/users'
import * as jobsApi from '../api/jobs'
import * as inboxApi from '../api/inbox'
const PAGE_SIZE_MAX = 500
/** Seeded `hiring_manager` role (backend/role/models.py::EnumRoles). */
const HIRING_MANAGER_ROLE_ID = 4
async function fetchManagers({ top = DEFAULT_PAGE_SIZE, skip = 0 } = {}) {
const res = await usersApi.list({ roleId: HIRING_MANAGER_ROLE_ID, top, skip })
const rows = Array.isArray(res?.data) ? res.data : []
return {
rows: rows.map(usersApi.toManagerView),
total: typeof res?.total === 'number' ? res.total : 0,
}
}
async function fetchJobs() {
// Keep within GET /jobs/fetch `top` ceiling (and match Jobs.jsx) so a shared
// qk.jobs.list() cache entry is never poisoned by a 422 from top=200.
const res = await jobsApi.list({ top: 100 })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(jobsApi.toJobView)
}
export default function Managers() {
const { toast } = useToast()
const { can } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE)
const [detail, setDetail] = useState(null)
const skip = (page - 1) * pageSize
const managersQuery = useQuery({
queryKey: qk.managers.list({ roleId: HIRING_MANAGER_ROLE_ID, top: pageSize, skip }),
queryFn: () => fetchManagers({ top: pageSize, skip }),
})
const jobsQuery = useQuery({ queryKey: qk.jobs.list(), queryFn: fetchJobs })
const managers = managersQuery.data?.rows ?? []
const total = managersQuery.data?.total ?? 0
const jobs = jobsQuery.data ?? []
const openByManager = useMemo(() => {
const map = {}
for (const j of jobs) {
if (j.hiringManagerId && j.status === 'Open') {
const key = String(j.hiringManagerId)
map[key] = (map[key] || 0) + 1
}
}
return map
}, [jobs])
const totalReqs = jobs.filter((j) => j.status === 'Open').length
const pages = Math.max(1, Math.ceil(total / pageSize))
const currentPage = Math.min(page, pages)
useEffect(() => {
if (page > pages) setPage(pages)
}, [page, pages])
useEffect(() => {
const id = location.state?.openManager
if (id) setDetail(managers.find((m) => m.id === id) ?? null)
}, [location.state, managers])
return (
<div className="page">
<PageHeader
title="Hiring Managers"
sub={`${managers.length} managers · ${totalReqs} active requisitions`}
/>
{managersQuery.isPending && (
<div className="card"><div className="card-body"><SkeletonRows rows={4} /></div></div>
)}
{managersQuery.isError && (
<EmptyState icon="managers" title="Couldnt load hiring managers">
{friendlyAuthError(managersQuery.error, 'This directory needs rbac_users.view.')}
</EmptyState>
)}
{managersQuery.isSuccess && total === 0 && managers.length === 0 && (
<EmptyState icon="managers" title="No hiring managers">
No accounts currently hold the hiring-manager role.
</EmptyState>
)}
{managersQuery.isSuccess && (managers.length > 0 || total > 0) && (
<>
<div className="grid g-3">
{managers.map((m) => (
<div className="card" key={m.id}>
<div className="card-body">
<div className="flex items-center gap-12 mb-12">
<Avatar name={m.name} className="avatar-lg" />
<div className="flex-1">
<div className="lr-title">{m.name}</div>
<div className="lr-sub">{m.title || m.roleName || 'Hiring manager'}</div>
</div>
</div>
<div className="grid g-2" style={{ gap: 10, marginBottom: 14 }}>
<div className="stat-mini"><span className="stat-mini-val">{openByManager[String(m.id)] ?? m.openReqs ?? 0}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{m.teamSize ?? '—'}</span><span className="stat-mini-lbl">Team Size</span></div>
</div>
<div className="divider" style={{ margin: '12px 0' }} />
<div className="flex items-center justify-between gap-8">
<span className="cell-sub truncate min-w-0" title={m.email || undefined}>
<Icon name="mail" /> {m.email || '—'}
</span>
<button className="btn btn-ghost btn-sm" onClick={() => setDetail(m)}>View</button>
</div>
</div>
</div>
))}
</div>
{total > 0 && (
<div className="card" style={{ marginTop: 16 }}>
<Pagination
from={total ? (currentPage - 1) * pageSize + 1 : 0}
to={total ? (currentPage - 1) * pageSize + managers.length : 0}
total={total}
page={currentPage}
pages={pages}
setPage={setPage}
pageButtons={pageWindow(currentPage, pages)}
pageSize={pageSize}
onPageSizeChange={(n) => { setPageSize(n); setPage((p) => pageAfterSizeChange(p, total, n)) }}
pageSizeMax={PAGE_SIZE_MAX}
/>
</div>
)}
</>
)}
{detail && (
<ManagerDetail
manager={detail}
jobs={jobs}
canSend={can('inbox.edit')}
onClose={() => setDetail(null)}
navigate={navigate}
toast={toast}
/>
)}
</div>
)
}
function ManagerDetail({ manager: m, jobs, canSend, onClose, navigate, toast }) {
const [messaging, setMessaging] = useState(false)
const mineQuery = useQuery({
queryKey: qk.jobs.list({ hiringManagerId: m.id }),
queryFn: async () => {
const res = await jobsApi.list({ hiringManagerId: m.id, top: 100 })
const rows = Array.isArray(res?.data) ? res.data : []
return rows.map(jobsApi.toJobView)
},
})
const mine = mineQuery.data ?? jobs.filter((j) => String(j.hiringManagerId) === String(m.id))
const openMine = mine.filter((j) => j.status === 'Open')
const send = useMutation({
mutationFn: (body) => inboxApi.sendEmail({ to: m.email, subject: body.subject, body: body.body, contentType: 'text' }),
onError: (err) => toast(friendlyAuthError(err, 'Could not send the message.'), 'error'),
onSuccess: () => {
setMessaging(false)
toast('Message sent', 'success')
},
})
const go = (path, state) => {
onClose()
navigate(path, { state })
}
return (
<Modal
title="Hiring Manager"
subtitle={m.email || undefined}
size="modal-lg"
onClose={onClose}
footer={
messaging ? (
<>
<button className="btn btn-secondary" onClick={() => setMessaging(false)} disabled={send.isPending}>Cancel</button>
<button
className="btn btn-primary"
disabled={send.isPending || !m.email}
onClick={() => {
const subject = document.getElementById('mgr-msg-subject')?.value?.trim()
const body = document.getElementById('mgr-msg-body')?.value?.trim()
if (!subject || !body) {
toast('Subject and body are required', 'warning')
return
}
send.mutate({ subject, body })
}}
>
<Icon name="send" /> {send.isPending ? 'Sending…' : 'Send'}
</button>
</>
) : (
<>
<button className="btn btn-secondary" onClick={onClose}>Close</button>
<button
className="btn btn-primary"
disabled={!canSend || !m.email}
title={!canSend ? 'Requires inbox.edit' : !m.email ? 'No email on file' : undefined}
onClick={() => setMessaging(true)}
>
<Icon name="mail" /> Message
</button>
</>
)
}
>
<div className="profile-hero" style={{ marginBottom: 18 }}>
<Avatar name={m.name} className="avatar-lg" />
<div>
<div className="ph-name">{m.name}</div>
<div className="ph-role">{m.title || m.roleName || 'Hiring manager'}</div>
<div className="ph-tags">
{m.department && <Badge className="b-indigo">{m.department}</Badge>}
{m.teamSize != null && <span className="badge b-gray badge-plain">{m.teamSize} reports</span>}
</div>
</div>
</div>
{messaging ? (
<div className="form-grid">
<div className="form-field col-span-2">
<label>To</label>
<input value={m.email} readOnly />
</div>
<div className="form-field col-span-2">
<label>Subject</label>
<input id="mgr-msg-subject" defaultValue={`Hiring update`} />
</div>
<div className="form-field col-span-2">
<label>Message</label>
<textarea id="mgr-msg-body" rows={5} placeholder="Write a message…" />
</div>
</div>
) : (
<>
<div className="grid g-3" style={{ marginBottom: 18 }}>
<div className="stat-mini"><span className="stat-mini-val">{openMine.length}</span><span className="stat-mini-lbl">Open Reqs</span></div>
<div className="stat-mini"><span className="stat-mini-val">{mine.length}</span><span className="stat-mini-lbl">Jobs</span></div>
<div className="stat-mini">
<span className="stat-mini-val">{m.email ? 'Yes' : '—'}</span>
<span className="stat-mini-lbl">Email on file</span>
</div>
</div>
<h3 className="form-section-title" style={{ marginTop: 0 }}>Hiring Manager Portal</h3>
<div className="grid g-2" style={{ gap: 10, marginBottom: 16 }}>
<button className="btn btn-secondary" onClick={() => go('/jobs', { openCreate: true })}>
<Icon name="plus" /> Raise Requisition
</button>
<button className="btn btn-secondary" onClick={() => go('/candidates')}>
<Icon name="users" /> Review Candidates
</button>
<button className="btn btn-secondary" onClick={() => go('/interviews', { openSchedule: true })}>
<Icon name="calendar" /> Schedule Interview
</button>
<button className="btn btn-secondary" onClick={() => go('/offers')}>
<Icon name="check-circle" /> Approve Offers
</button>
</div>
<h3 className="form-section-title">Open requisitions</h3>
<div className="list-tight">
{mineQuery.isPending ? (
<p className="text-muted">Loading requisitions</p>
) : openMine.length === 0 ? (
<p className="text-muted">No open requisitions for this manager</p>
) : (
openMine.slice(0, 8).map((j) => (
<div
key={j.id}
className="list-row"
style={{ cursor: 'pointer' }}
onClick={() => go('/jobs', { openJob: j.id })}
>
<span className="kpi-icn i-indigo" style={{ width: 36, height: 36, borderRadius: 9 }}>
<Icon name="briefcase" />
</span>
<div className="lr-main">
<div className="lr-title">{j.title}</div>
<div className="lr-sub">{[j.department, j.location].filter(Boolean).join(' · ') || '—'}</div>
</div>
<Badge>{j.status}</Badge>
</div>
))
)}
</div>
</>
)}
</Modal>
)
}