312 lines
12 KiB
JavaScript
312 lines
12 KiB
JavaScript
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="Couldn’t 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>
|
||
)
|
||
}
|