Merge origin/main into Talha (history logs, page-size dropdown)
Integrates the Per-page dropdown (10/50/100) with the inbox All option: All is now a fourth dropdown entry (opt-in via allowAll) instead of a separate button. Restores what the stale-branch merges on main clobbered from PR #46: mobile.test.mjs, the test:mobile script and the puppeteer-core devDependency.pull/50/head
commit
73ec556f8c
|
|
@ -32,6 +32,27 @@ def _auth_headers(token=None):
|
|||
return {"Authorization":f"Bearer {auth_token}"}
|
||||
|
||||
|
||||
async def get_event(event_id, token=None):
|
||||
"""GET {base}/calendar/events/{id} -> event dict, or None on 404."""
|
||||
encoded_id=quote(str(event_id),safe="")
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response=await client.get(
|
||||
f"{_base_url()}/calendar/events/{encoded_id}",
|
||||
headers={**_auth_headers(token),"accept":"application/json"},
|
||||
)
|
||||
if response.status_code==404:
|
||||
return None
|
||||
if response.status_code>=400:
|
||||
raise httpx.HTTPStatusError(
|
||||
response.text,
|
||||
request=response.request,
|
||||
response=response,
|
||||
)
|
||||
if not response.content:
|
||||
return {}
|
||||
return response.json()
|
||||
|
||||
|
||||
async def create_event(payload, token=None):
|
||||
"""POST {base}/calendar/events -> created event dict."""
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
|
|
|
|||
|
|
@ -1,3 +1,34 @@
|
|||
def _email_address(block):
|
||||
if not isinstance(block,dict):
|
||||
return None,None
|
||||
inner=block.get("emailAddress") or block.get("email_address") or block
|
||||
if not isinstance(inner,dict):
|
||||
return None,None
|
||||
email=(inner.get("address") or inner.get("email") or "").strip() or None
|
||||
name=(inner.get("name") or "").strip() or None
|
||||
return email,name
|
||||
|
||||
|
||||
def participants_from_event(payload):
|
||||
"""Graph event dict -> (organizer, attendees) as {name, email} snapshots."""
|
||||
if not isinstance(payload,dict):
|
||||
payload={}
|
||||
org_email,org_name=_email_address(payload.get("organizer") or {})
|
||||
organizer=None
|
||||
if org_email or org_name:
|
||||
organizer={"name":org_name,"email":org_email}
|
||||
attendees=[]
|
||||
seen=set()
|
||||
for item in payload.get("attendees") or []:
|
||||
email,name=_email_address(item)
|
||||
key=(email or "").lower()
|
||||
if not email or key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
attendees.append({"name":name,"email":email})
|
||||
return organizer,attendees
|
||||
|
||||
|
||||
def serialize_event(payload) -> dict:
|
||||
"""Upstream calendar event -> the fields the interview row stores."""
|
||||
if not isinstance(payload,dict):
|
||||
|
|
|
|||
|
|
@ -913,6 +913,18 @@ class Interviews(SQLModel, table=True):
|
|||
)
|
||||
return result.scalars().first()
|
||||
|
||||
@classmethod
|
||||
async def get_interviews_by_ids(cls, session: AsyncSession, ids):
|
||||
keys=[]
|
||||
for raw in ids or []:
|
||||
uid=cls._as_uuid(raw)
|
||||
if uid is not None:
|
||||
keys.append(uid)
|
||||
if not keys:
|
||||
return []
|
||||
result=await session.execute(select(cls).where(cls.id.in_(keys)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
||||
result = await session.execute(
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
def serialize_history(row, actor_name=None) -> dict:
|
||||
def serialize_history(row, actor_name=None, organizer_email=None, attendee_emails=None) -> dict:
|
||||
return {
|
||||
"id": str(row.id),
|
||||
"user_id": str(row.user_id) if row.user_id else None,
|
||||
|
|
@ -16,5 +16,7 @@ def serialize_history(row, actor_name=None) -> dict:
|
|||
"actor_name": actor_name,
|
||||
"actor_kind": row.actor_kind,
|
||||
"meta": row.meta,
|
||||
"organizer_email": organizer_email,
|
||||
"attendee_emails": attendee_emails or None,
|
||||
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,25 @@
|
|||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from inbox.models import Inbox
|
||||
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
|
||||
from job.candidate.models import CandidateHistory, Interviews, Manual_UPLOAD_CANDIDATE
|
||||
from job.history.enums import HistoryEvent
|
||||
from job.history.serializers import serialize_history
|
||||
from users.models import Users
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
INTERVIEW_HISTORY_EVENTS = {
|
||||
HistoryEvent.INTERVIEW_CREATED.value,
|
||||
HistoryEvent.INTERVIEW_UPDATED.value,
|
||||
HistoryEvent.CALENDAR_CREATED.value,
|
||||
HistoryEvent.CALENDAR_RESCHEDULED.value,
|
||||
HistoryEvent.CALENDAR_CANCELLED.value,
|
||||
}
|
||||
|
||||
|
||||
def _text(value):
|
||||
if value is None:
|
||||
|
|
@ -111,10 +121,70 @@ class HistoryRecorder:
|
|||
logger.exception("candidate history rollback failed")
|
||||
return None
|
||||
|
||||
async def _event_emails(self, event_id):
|
||||
from interview.plugins import get_event
|
||||
from interview.serializers import participants_from_event
|
||||
|
||||
try:
|
||||
raw = await get_event(event_id)
|
||||
except Exception:
|
||||
logger.exception("calendar event fetch failed for history")
|
||||
return None, []
|
||||
if not raw:
|
||||
return None, []
|
||||
organizer, attendees = participants_from_event(raw)
|
||||
org_email = (organizer or {}).get("email")
|
||||
attendee_emails = [a.get("email") for a in (attendees or []) if a.get("email")]
|
||||
return org_email, attendee_emails
|
||||
|
||||
async def _attach_outlook_emails(self, rows, items):
|
||||
pairs = [
|
||||
(row, item)
|
||||
for row, item in zip(rows, items)
|
||||
if row.event_type in INTERVIEW_HISTORY_EVENTS and row.entity_id
|
||||
]
|
||||
if not pairs:
|
||||
return
|
||||
interviews = await Interviews.get_interviews_by_ids(
|
||||
self.session, {row.entity_id for row, _item in pairs}
|
||||
)
|
||||
event_by_interview = {
|
||||
str(r.id): r.graph_event_id for r in interviews if r.graph_event_id
|
||||
}
|
||||
pending = []
|
||||
event_ids = []
|
||||
for row, item in pairs:
|
||||
eid = event_by_interview.get(str(row.entity_id))
|
||||
if not eid:
|
||||
continue
|
||||
event_ids.append(eid)
|
||||
pending.append((item, eid))
|
||||
ids = list(dict.fromkeys(event_ids))
|
||||
if not ids:
|
||||
return
|
||||
sem = asyncio.Semaphore(5)
|
||||
|
||||
async def one(eid):
|
||||
async with sem:
|
||||
return eid, *(await self._event_emails(eid))
|
||||
|
||||
fetched = {eid: (org, atts) for eid, org, atts in await asyncio.gather(*[one(eid) for eid in ids])}
|
||||
for item, eid in pending:
|
||||
org_email, attendee_emails = fetched.get(eid, (None, []))
|
||||
if org_email:
|
||||
item["organizer_email"] = org_email
|
||||
if attendee_emails:
|
||||
item["attendee_emails"] = attendee_emails
|
||||
|
||||
async def list_for_user(self, user_id, *, limit=200, offset=0):
|
||||
rows, total = await CandidateHistory.fetch_by_user(
|
||||
self.session, user_id, limit=limit, offset=offset
|
||||
)
|
||||
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
||||
names = await Users.names_by_ids(self.session, actor_ids)
|
||||
return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total
|
||||
items = [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows]
|
||||
try:
|
||||
await self._attach_outlook_emails(rows, items)
|
||||
except Exception:
|
||||
logger.exception("calendar participant hydrate failed for history")
|
||||
return items, total
|
||||
|
|
|
|||
|
|
@ -345,6 +345,7 @@ class JobPosts(SQLModel, table=True):
|
|||
cls.location,
|
||||
cls.requisition_status,
|
||||
cls.current_recruiter_id,
|
||||
cls.created_at,
|
||||
Recruiter.name.label("recruiter_name"),
|
||||
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
||||
|
|
|
|||
|
|
@ -86,6 +86,7 @@ def serialize_job_row(row, *, recruiter_name=None, hiring_manager_name=None, app
|
|||
def serialize_job_stats(row) -> dict:
|
||||
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
|
||||
recruiter_id=row.get("current_recruiter_id")
|
||||
created_at = row.get("created_at")
|
||||
return {
|
||||
"job_post_id": str(row["job_post_id"]),
|
||||
"title": row["title"],
|
||||
|
|
@ -94,6 +95,8 @@ def serialize_job_stats(row) -> dict:
|
|||
"requisition_status": row["requisition_status"],
|
||||
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
|
||||
"recruiter_name": row.get("recruiter_name") or None,
|
||||
# Frontend computes days-open vs client clock; no server days_open field.
|
||||
"created_at": created_at.isoformat() if created_at else None,
|
||||
"total_applicants": int(row["total_applicants"] or 0),
|
||||
"shortlisting": int(row["shortlisting"] or 0),
|
||||
"screened": int(row["screened"] or 0),
|
||||
|
|
|
|||
|
|
@ -24,8 +24,8 @@
|
|||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
|
||||
<script type="module" crossorigin src="/assets/index-CsFrnWUK.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-C6biZ8qR.css">
|
||||
<script type="module" crossorigin src="/assets/index-CLFuERsT.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-CuQsXTK_.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -1767,9 +1767,9 @@
|
|||
}
|
||||
},
|
||||
"node_modules/bare-url": {
|
||||
"version": "2.5.3",
|
||||
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.3.tgz",
|
||||
"integrity": "sha512-3absfEzoyFosWT8v83ZcJgbTJxv+S/sI7jBfeCMlUVatSaefRmwweqBhFpMllQv+HTxs/FbbToVOw1c05NORkQ==",
|
||||
"version": "2.5.4",
|
||||
"resolved": "https://registry.npmjs.org/bare-url/-/bare-url-2.5.4.tgz",
|
||||
"integrity": "sha512-Gxa7UVWBr0/edU1b+TJhn/AZvMQUj9OGspvYsaTYQrAbZA4BOTZGL3LiZxvD+CeMlDH4juwD84+eTAp/bLYW5g==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
|
|
|
|||
|
|
@ -50,7 +50,8 @@ dom.window.HTMLCanvasElement.prototype.getContext = () =>
|
|||
|
||||
// A signed-in session holding every permission tag, so no route is gated away.
|
||||
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
||||
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent']
|
||||
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent',
|
||||
'requisitions']
|
||||
const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
|
||||
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
|
||||
|
||||
|
|
|
|||
|
|
@ -27,8 +27,17 @@ export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Whole days from created_at to the browser clock. Null if timestamp missing. */
|
||||
export function daysOpen(createdAt, now = Date.now()) {
|
||||
if (!createdAt) return null
|
||||
const start = new Date(createdAt).getTime()
|
||||
if (!Number.isFinite(start)) return null
|
||||
return Math.max(0, Math.floor((now - start) / 86_400_000))
|
||||
}
|
||||
|
||||
/** API row -> what Progress cards and the table render. */
|
||||
export function toJobStatsView(row) {
|
||||
const createdAt = row.created_at || null
|
||||
return {
|
||||
id: row.job_post_id,
|
||||
title: row.title || 'Untitled role',
|
||||
|
|
@ -38,6 +47,8 @@ export function toJobStatsView(row) {
|
|||
requisitionStatus: row.requisition_status,
|
||||
recruiterId: row.current_recruiter_id || null,
|
||||
recruiterName: row.recruiter_name || null,
|
||||
createdAt,
|
||||
daysOpen: daysOpen(createdAt),
|
||||
total: Number(row.total_applicants) || 0,
|
||||
shortlist: Number(row.shortlisting) || 0,
|
||||
screened: Number(row.screened) || 0,
|
||||
|
|
|
|||
|
|
@ -1,26 +1,4 @@
|
|||
/* The candidate profile modal, split out of Candidates.jsx — it was the
|
||||
single largest block in js/candidates.js and deserves its own file.
|
||||
|
||||
TWO DATA MODES, selected by whether the caller passes a `userId`:
|
||||
|
||||
SEED (Candidates.jsx) — every tab renders from the seed record, exactly as
|
||||
the prototype did.
|
||||
LIVE (TalentPool.jsx) — GET /candidate/fetch?user_id= switches the endpoint
|
||||
into detail mode and returns the real record: résumé text, the agent's
|
||||
match verdict, documents, and the child collections (interviews,
|
||||
notes, activity). The write tabs POST to their own endpoints
|
||||
and invalidate this one query, so the whole modal repaints from a single
|
||||
refetch. History is fetched separately (GET /candidate/history/fetch)
|
||||
when that tab opens — it is append-only and not part of the detail payload.
|
||||
|
||||
Live collections are NEVER padded with the seed's demo rows. An empty tab gets
|
||||
an empty state, because inventing three scorecards for a real applicant is
|
||||
worse than showing none.
|
||||
|
||||
Scoping differs between the child tables and is not interchangeable: notes
|
||||
hang off the candidate (users.id), while interviews and activity
|
||||
hang off one application (inbox.id). */
|
||||
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
|
|
@ -836,14 +814,22 @@ function HistoryTab({ userId }) {
|
|||
)
|
||||
}
|
||||
|
||||
const INTERVIEW_HISTORY_TYPES = new Set([
|
||||
'interview.created', 'interview.updated',
|
||||
'calendar.created', 'calendar.rescheduled', 'calendar.cancelled',
|
||||
])
|
||||
|
||||
function HistoryRow({ row: r }) {
|
||||
const look = HISTORY_ICON[r.event_type] || { icon: 'clock', tone: 'i-blue' }
|
||||
const title = HISTORY_TITLE[r.event_type] || r.event_type
|
||||
const change = [r.from_value, r.to_value].some((v) => v != null && v !== '')
|
||||
? `${r.from_value ?? '—'} → ${r.to_value ?? '—'}`
|
||||
: null
|
||||
const isInterview = INTERVIEW_HISTORY_TYPES.has(r.event_type)
|
||||
const actor = r.actor_name || (r.actor_id ? 'Unknown' : 'System')
|
||||
const when = [fmtWhen(r.created_at), fmtClock(r.created_at)].filter(Boolean).join(' · ')
|
||||
const organizerEmail = r.organizer_email || null
|
||||
const attendeeEmails = Array.isArray(r.attendee_emails) ? r.attendee_emails.filter(Boolean) : []
|
||||
return (
|
||||
<div className="list-row">
|
||||
<span className={`kpi-icn ${look.tone}`} style={{ width: 38, height: 38, borderRadius: 10 }}>
|
||||
|
|
@ -853,12 +839,20 @@ function HistoryRow({ row: r }) {
|
|||
<div className="lr-title">{title}</div>
|
||||
{change && <div className="lr-sub">{change}</div>}
|
||||
{r.description && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{r.description}</div>}
|
||||
<div className="lr-sub">{actor} · {when}</div>
|
||||
{organizerEmail && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>Organizer: {organizerEmail}</div>}
|
||||
{attendeeEmails.length > 0 && (
|
||||
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
|
||||
Attendees: {attendeeEmails.join(', ')}
|
||||
</div>
|
||||
)}
|
||||
<div className="lr-sub">{isInterview ? when : `${actor} · ${when}`}</div>
|
||||
</div>
|
||||
<div className="lr-right">
|
||||
{r.actor_name || r.actor_id
|
||||
? <Avatar name={actor} />
|
||||
: <Badge className="b-gray">System</Badge>}
|
||||
{isInterview
|
||||
? null
|
||||
: (r.actor_name || r.actor_id
|
||||
? <Avatar name={actor} />
|
||||
: <Badge className="b-gray">System</Badge>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/* ============================================================
|
||||
Recruitment Inbox — application tabs over GET /inbox/all-applications.
|
||||
Page size defaults to 10. skip/offset is the window start and is not
|
||||
recomputed when Per page changes: growing 10 to 30 on a window that
|
||||
started at row 11 requests skip=10, top=30 (rows 11–40). Clicking a
|
||||
Page size defaults to 10 (dropdown: 10 / 50 / 100). skip/offset is the
|
||||
window start and is not recomputed when Per page changes: growing 10 to
|
||||
50 on a window that started at row 11 requests skip=10, top=50
|
||||
(rows 11–60). Clicking a
|
||||
page number realigns skip = (page-1)*limit. Total comes from a count
|
||||
endpoint called once when the page opens.
|
||||
============================================================ */
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
/* ============================================================
|
||||
Progress — per-job pipeline stage overview from GET /job/stats/fetch.
|
||||
Progress — single master/detail view of GET /job/stats/fetch.
|
||||
|
||||
Two tabs: Overview (job picker + stage tiles) and All job posts (table).
|
||||
Counts are unique applicants by email; recruiter comes from
|
||||
job_posts.current_recruiter_id.
|
||||
Left: searchable/filterable job index (paged). Right: selected job
|
||||
pipeline breakdown. Days-open is computed client-side from created_at.
|
||||
============================================================ */
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
|
@ -11,65 +10,193 @@ import { useSearchParams } from 'react-router-dom'
|
|||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import DataTable from '../ui/DataTable'
|
||||
import { Badge, EmptyState, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
|
||||
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', icon: 'star', tone: 'blue' },
|
||||
{ key: 'screened', label: 'Screened', icon: 'eye', tone: 'purple' },
|
||||
{ key: 'assessment', label: 'Assessment', icon: 'check-square', tone: 'amber' },
|
||||
{ key: 'interviewed', label: 'Interviewed', icon: 'calendar', tone: 'indigo' },
|
||||
{ key: 'offered', label: 'Offered', icon: 'send', tone: 'teal' },
|
||||
{ key: 'onHold', label: 'On Hold', icon: 'clock', tone: 'amber' },
|
||||
{ key: 'rejected', label: 'Rejected', icon: 'x-circle', tone: 'red' },
|
||||
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
|
||||
{ key: 'screened', label: 'Screened', tone: 'purple' },
|
||||
{ key: 'assessment', label: 'Assessment', tone: 'amber' },
|
||||
{ key: 'interviewed', label: 'Interviewed', tone: 'indigo' },
|
||||
{ key: 'offered', label: 'Offered', tone: 'teal' },
|
||||
{ key: 'onHold', label: 'On hold', tone: 'amber' },
|
||||
{ key: 'rejected', label: 'Rejected', tone: 'red' },
|
||||
{ key: 'hired', label: 'Hired', tone: 'teal' },
|
||||
]
|
||||
|
||||
function sumField(jobs, key) {
|
||||
return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0)
|
||||
}
|
||||
|
||||
function StageTile({ stage, value, total }) {
|
||||
const pct = total ? Math.round((value / total) * 100) : 0
|
||||
function statusTone(status) {
|
||||
const value = String(status || '').toLowerCase()
|
||||
if (value === 'open') return 'success'
|
||||
if (value === 'on_hold' || value === 'on hold') return 'warning'
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
function Metric({ value, label, accent = false }) {
|
||||
return (
|
||||
<div className={`progress-stage stage-${stage.tone}`}>
|
||||
<div className="progress-stage-head">
|
||||
<span className="progress-stage-icon"><Icon name={stage.icon} /></span>
|
||||
<span className="progress-stage-label">{stage.label}</span>
|
||||
<span className="progress-stage-pct">{pct}%</span>
|
||||
</div>
|
||||
<strong className="progress-stage-value">{value}</strong>
|
||||
<div className="progress-stage-meter" aria-hidden="true">
|
||||
<i style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<div className="progress-metric">
|
||||
<strong className={accent ? 'accent' : undefined}>{value}</strong>
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StageBar({ job }) {
|
||||
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
|
||||
const base = Math.max(used, job.total, 1)
|
||||
function JobRow({ job, active, onSelect }) {
|
||||
return (
|
||||
<div className="progress-bar-wrap">
|
||||
<div className="progress-bar-labels">
|
||||
<span>Current stage distribution</span>
|
||||
<span>{job.total} unique applicants</span>
|
||||
<button
|
||||
type="button"
|
||||
className={`progress-job-row${active ? ' active' : ''}`}
|
||||
onClick={onSelect}
|
||||
aria-current={active ? 'true' : undefined}
|
||||
>
|
||||
<div className="progress-job-row-top">
|
||||
<span className="progress-job-title">{job.title}</span>
|
||||
<span className="progress-job-count">{job.total.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
|
||||
{STAGES.map((stage) => {
|
||||
const n = job[stage.key] || 0
|
||||
if (!n) return null
|
||||
return (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={`progress-bar-seg stage-${stage.tone}`}
|
||||
title={`${stage.label}: ${n}`}
|
||||
style={{ width: `${(n / base) * 100}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
<div className="progress-job-row-sub">
|
||||
<span>
|
||||
{[job.department, job.recruiterName || 'Unassigned'].filter(Boolean).join(' · ')}
|
||||
</span>
|
||||
<i className={`progress-status-dot tone-${statusTone(job.requisitionStatus)}`} aria-hidden="true" />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function StageRow({ job, stage }) {
|
||||
const count = job[stage.key] || 0
|
||||
const pct = job.total ? Math.round((count / job.total) * 100) : 0
|
||||
return (
|
||||
<div className="progress-stage-row">
|
||||
<div className="progress-stage-row-label">
|
||||
<i className={`progress-stage-dot stage-${stage.tone}`} aria-hidden="true" />
|
||||
<span>{stage.label}</span>
|
||||
</div>
|
||||
<div className="progress-stage-row-meter" aria-hidden="true">
|
||||
<i className={`stage-${stage.tone}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
<strong>{count.toLocaleString()}</strong>
|
||||
<span className="progress-stage-row-pct">{pct}%</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function JobDetail({ job }) {
|
||||
const interviewRate = job.total ? Math.round((job.interviewed / job.total) * 100) : null
|
||||
const offerRate = job.offered ? Math.round((job.hired / job.offered) * 100) : null
|
||||
const active = job.shortlist + job.screened + job.assessment + job.interviewed
|
||||
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
|
||||
const barBase = Math.max(used, job.total, 1)
|
||||
|
||||
return (
|
||||
<div className="progress-detail">
|
||||
<div className="progress-detail-head">
|
||||
<div>
|
||||
<div className="progress-detail-tags">
|
||||
{job.department && <span className="progress-eyebrow">{job.department}</span>}
|
||||
<Badge className={`b-${statusTone(job.requisitionStatus) === 'success' ? 'green' : statusTone(job.requisitionStatus) === 'warning' ? 'amber' : 'gray'}`}>
|
||||
{job.status}
|
||||
</Badge>
|
||||
</div>
|
||||
<h2>{job.title}</h2>
|
||||
<p className="progress-meta">
|
||||
{job.location && <span><Icon name="map" /> {job.location}</span>}
|
||||
<span>
|
||||
Recruiter ·{' '}
|
||||
{job.recruiterName
|
||||
? <b>{job.recruiterName}</b>
|
||||
: <span className="text-muted">Unassigned</span>}
|
||||
</span>
|
||||
{job.daysOpen != null && (
|
||||
<span>Open {job.daysOpen} day{job.daysOpen === 1 ? '' : 's'}</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="progress-selected-total">
|
||||
<strong>{job.total.toLocaleString()}</strong>
|
||||
<span>unique applicants</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-bar-wrap">
|
||||
<div className="progress-bar-labels">
|
||||
<span>Candidate distribution by current stage</span>
|
||||
<span>{job.total.toLocaleString()} applicants</span>
|
||||
</div>
|
||||
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
|
||||
{STAGES.map((stage) => {
|
||||
const n = job[stage.key] || 0
|
||||
if (!n) return null
|
||||
return (
|
||||
<div
|
||||
key={stage.key}
|
||||
className={`progress-bar-seg stage-${stage.tone}`}
|
||||
title={`${stage.label}: ${n}`}
|
||||
style={{ width: `${(n / barBase) * 100}%` }}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-breakdown">
|
||||
<div className="progress-section-head">
|
||||
<h3>Pipeline breakdown</h3>
|
||||
<span>Count · share of applicants</span>
|
||||
</div>
|
||||
<div className="progress-stage-rows">
|
||||
{STAGES.map((stage) => (
|
||||
<StageRow key={stage.key} job={job} stage={stage} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="progress-metric-grid">
|
||||
<Metric value={active.toLocaleString()} label="Active pipeline" />
|
||||
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
|
||||
<Metric value={job.offered.toLocaleString()} label="Offers made" accent />
|
||||
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
|
||||
</div>
|
||||
|
||||
<div className="progress-bottom-grid">
|
||||
<div>
|
||||
<h3>Attention needed</h3>
|
||||
<div className="progress-attention-list">
|
||||
<div className="progress-attention-item">
|
||||
<span>Candidates on hold</span>
|
||||
<strong className="text-warning">{job.onHold}</strong>
|
||||
</div>
|
||||
<div className="progress-attention-item">
|
||||
<span>Job aging</span>
|
||||
<strong>
|
||||
{job.daysOpen == null ? '—' : `${job.daysOpen} day${job.daysOpen === 1 ? '' : 's'}`}
|
||||
</strong>
|
||||
</div>
|
||||
<div className="progress-attention-item">
|
||||
<span>Rejected</span>
|
||||
<strong className="text-danger">{job.rejected}</strong>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card progress-outcome-card">
|
||||
<div className="card-body">
|
||||
<h3>Hiring outcome</h3>
|
||||
<div className="progress-outcome-grid">
|
||||
<Metric value={job.offered} label="Offers" />
|
||||
<Metric value={job.hired} label="Hired" accent />
|
||||
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
|
||||
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
@ -79,9 +206,10 @@ export default function Progress() {
|
|||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
const deepLinkJobId = searchParams.get('job') || ''
|
||||
|
||||
const [tab, setTab] = useState('overview')
|
||||
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
||||
const [query, setQuery] = useState('')
|
||||
const [status, setStatus] = useState('all')
|
||||
const [page, setPage] = useState(0)
|
||||
|
||||
const statsQuery = useQuery({
|
||||
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
|
||||
|
|
@ -94,6 +222,39 @@ export default function Progress() {
|
|||
|
||||
const jobs = statsQuery.data ?? []
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = query.trim().toLowerCase()
|
||||
return jobs
|
||||
.filter((job) => {
|
||||
if (status !== 'all' && String(job.requisitionStatus || '').toLowerCase() !== status) {
|
||||
return false
|
||||
}
|
||||
if (!term) return true
|
||||
return (
|
||||
job.title.toLowerCase().includes(term)
|
||||
|| (job.department || '').toLowerCase().includes(term)
|
||||
|| (job.location || '').toLowerCase().includes(term)
|
||||
|| (job.recruiterName || '').toLowerCase().includes(term)
|
||||
)
|
||||
})
|
||||
.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)
|
||||
|
||||
useEffect(() => {
|
||||
setPage(0)
|
||||
}, [query, status])
|
||||
|
||||
// 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])
|
||||
|
||||
useEffect(() => {
|
||||
if (!jobs.length) {
|
||||
setSelectedId('')
|
||||
|
|
@ -102,7 +263,6 @@ export default function Progress() {
|
|||
const matchId = (id) => jobs.some((j) => String(j.id) === String(id))
|
||||
if (deepLinkJobId && matchId(deepLinkJobId)) {
|
||||
setSelectedId(String(deepLinkJobId))
|
||||
setTab('overview')
|
||||
return
|
||||
}
|
||||
if (!selectedId || !matchId(selectedId)) {
|
||||
|
|
@ -113,7 +273,8 @@ export default function Progress() {
|
|||
const selectJob = (id) => {
|
||||
const next = String(id || '')
|
||||
setSelectedId(next)
|
||||
setTab('overview')
|
||||
const idx = filtered.findIndex((j) => String(j.id) === next)
|
||||
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
|
||||
setSearchParams((prev) => {
|
||||
const nextParams = new URLSearchParams(prev)
|
||||
if (next) nextParams.set('job', next)
|
||||
|
|
@ -122,102 +283,32 @@ export default function Progress() {
|
|||
}, { replace: true })
|
||||
}
|
||||
|
||||
const selected = jobs.find((j) => String(j.id) === String(selectedId)) || jobs[0] || null
|
||||
|
||||
const visibleJobs = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q) return jobs
|
||||
return jobs.filter((job) => (
|
||||
job.title.toLowerCase().includes(q)
|
||||
|| (job.department || '').toLowerCase().includes(q)
|
||||
|| (job.location || '').toLowerCase().includes(q)
|
||||
|| (job.recruiterName || '').toLowerCase().includes(q)
|
||||
))
|
||||
}, [jobs, query])
|
||||
const selected = jobs.find((j) => String(j.id) === String(selectedId))
|
||||
|| filtered[0]
|
||||
|| jobs[0]
|
||||
|| null
|
||||
|
||||
const totalApplicants = sumField(jobs, 'total')
|
||||
const activePipeline = sumField(jobs, 'shortlist')
|
||||
+ sumField(jobs, 'screened')
|
||||
+ sumField(jobs, 'assessment')
|
||||
+ sumField(jobs, 'interviewed')
|
||||
|
||||
const tableColumns = [
|
||||
{
|
||||
key: 'title',
|
||||
label: 'Job post',
|
||||
sortable: true,
|
||||
render: (j) => (
|
||||
<div>
|
||||
<div className="cell-primary">{j.title}</div>
|
||||
<div className="cell-sub">
|
||||
{[j.department, j.location].filter(Boolean).join(' · ') || '—'}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'recruiterName',
|
||||
label: 'Recruiter',
|
||||
sortable: true,
|
||||
render: (j) => (
|
||||
j.recruiterName
|
||||
? <b>{j.recruiterName}</b>
|
||||
: <span className="text-muted">Unassigned</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
sortable: true,
|
||||
render: (j) => <Badge>{j.status}</Badge>,
|
||||
},
|
||||
{ key: 'total', label: 'Applicants', sortable: true, align: 'right', render: (j) => <b>{j.total}</b> },
|
||||
{ key: 'shortlist', label: 'Shortlisted', sortable: true, align: 'right' },
|
||||
{ key: 'screened', label: 'Screened', sortable: true, align: 'right' },
|
||||
{ key: 'interviewed', label: 'Interviewed', sortable: true, align: 'right' },
|
||||
{ key: 'offered', label: 'Offered', sortable: true, align: 'right' },
|
||||
{
|
||||
key: 'onHold',
|
||||
label: 'On hold',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (j) => <span className="text-warning">{j.onHold}</span>,
|
||||
},
|
||||
{
|
||||
key: 'rejected',
|
||||
label: 'Rejected',
|
||||
sortable: true,
|
||||
align: 'right',
|
||||
render: (j) => <span className="text-danger">{j.rejected}</span>,
|
||||
},
|
||||
]
|
||||
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">
|
||||
<PageHeader
|
||||
title="Progress"
|
||||
sub="Candidate progress across every job post, at a glance"
|
||||
sub="One operational view for high-volume recruiting"
|
||||
actions={(
|
||||
<div className="progress-header-stats">
|
||||
<Metric value={jobs.length.toLocaleString()} label="job posts" />
|
||||
<Metric value={totalApplicants.toLocaleString()} label="unique applicants" />
|
||||
<Metric value={activeRoles.toLocaleString()} label="active roles" accent />
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="tabs" role="tablist" aria-label="Progress views">
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${tab === 'overview' ? 'active' : ''}`}
|
||||
onClick={() => setTab('overview')}
|
||||
>
|
||||
<Icon name="dashboard" /> Overview
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`tab ${tab === 'jobs' ? 'active' : ''}`}
|
||||
onClick={() => setTab('jobs')}
|
||||
>
|
||||
<Icon name="briefcase" /> All job posts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{statsQuery.isPending && (
|
||||
<div className="card"><div className="card-body"><SkeletonRows rows={6} /></div></div>
|
||||
<div className="card"><div className="card-body"><SkeletonRows rows={8} /></div></div>
|
||||
)}
|
||||
|
||||
{statsQuery.isError && (
|
||||
|
|
@ -237,194 +328,89 @@ export default function Progress() {
|
|||
</div></div>
|
||||
)}
|
||||
|
||||
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'overview' && selected && (
|
||||
<>
|
||||
<div className="grid g-kpi mb-18">
|
||||
<KpiCard
|
||||
icon="users"
|
||||
tone="i-indigo"
|
||||
label="Total applicants"
|
||||
value={totalApplicants}
|
||||
foot={`Across ${jobs.length} job post${jobs.length === 1 ? '' : 's'}`}
|
||||
/>
|
||||
<KpiCard
|
||||
icon="pipeline"
|
||||
tone="i-purple"
|
||||
label="Active pipeline"
|
||||
value={activePipeline}
|
||||
foot="Currently progressing"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="calendar"
|
||||
tone="i-blue"
|
||||
label="Interviewed"
|
||||
value={sumField(jobs, 'interviewed')}
|
||||
foot="Candidate interviews"
|
||||
/>
|
||||
<KpiCard
|
||||
icon="send"
|
||||
tone="i-green"
|
||||
label="Offers made"
|
||||
value={sumField(jobs, 'offered')}
|
||||
foot={`${sumField(jobs, 'hired')} candidates hired`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="card mb-18">
|
||||
<div className="card-head progress-filter-head">
|
||||
<div>
|
||||
<h3>Pipeline at a glance</h3>
|
||||
<span className="ch-sub">Select a job post to inspect its current candidate distribution</span>
|
||||
</div>
|
||||
<select
|
||||
className="select"
|
||||
value={selectedId}
|
||||
onChange={(e) => selectJob(e.target.value)}
|
||||
aria-label="Select job post"
|
||||
>
|
||||
{jobs.map((job) => (
|
||||
<option key={job.id} value={String(job.id)}>{job.title}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="progress-selected">
|
||||
<div>
|
||||
{selected.department && (
|
||||
<span className="progress-eyebrow">{selected.department}</span>
|
||||
)}
|
||||
<h2>{selected.title}</h2>
|
||||
<p className="progress-meta">
|
||||
{selected.location && (
|
||||
<span><Icon name="map" /> {selected.location}</span>
|
||||
)}
|
||||
<span>
|
||||
Recruiter ·{' '}
|
||||
{selected.recruiterName
|
||||
? <b>{selected.recruiterName}</b>
|
||||
: <span className="text-muted">Unassigned</span>}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="progress-selected-total">
|
||||
<strong>{selected.total}</strong>
|
||||
<span>unique applicants</span>
|
||||
</div>
|
||||
<Badge>{selected.status}</Badge>
|
||||
</div>
|
||||
|
||||
<div className="progress-stage-grid">
|
||||
{STAGES.map((stage) => (
|
||||
<StageTile
|
||||
key={stage.key}
|
||||
stage={stage}
|
||||
value={selected[stage.key] || 0}
|
||||
total={selected.total}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<StageBar job={selected} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid g-2">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Attention needed</h3>
|
||||
<span className="ch-sub">Where attention is needed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body progress-health">
|
||||
<div>
|
||||
<span className="progress-health-icon i-amber"><Icon name="clock" /></span>
|
||||
<div>
|
||||
<strong>{selected.onHold} candidates on hold</strong>
|
||||
<span>Review before the next hiring round</span>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<span className="progress-health-icon i-red"><Icon name="x-circle" /></span>
|
||||
<div>
|
||||
<strong>{selected.rejected} rejected</strong>
|
||||
<span>
|
||||
{selected.total
|
||||
? `${Math.round((selected.rejected / selected.total) * 100)}% of total applicants`
|
||||
: 'No applicants yet'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Hiring outcome</h3>
|
||||
<span className="ch-sub">Selected job post</span>
|
||||
</div>
|
||||
<Badge>{selected.status}</Badge>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="info-grid">
|
||||
<div className="info-item">
|
||||
<div className="il">Offers</div>
|
||||
<div className="iv">{selected.offered}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Hired</div>
|
||||
<div className="iv">{selected.hired}</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Interview rate</div>
|
||||
<div className="iv">
|
||||
{selected.total
|
||||
? `${Math.round((selected.interviewed / selected.total) * 100)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="info-item">
|
||||
<div className="il">Offer-to-hire</div>
|
||||
<div className="iv">
|
||||
{selected.offered
|
||||
? `${Math.round((selected.hired / selected.offered) * 100)}%`
|
||||
: '—'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'jobs' && (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>All job posts</h3>
|
||||
<span className="ch-sub">{visibleJobs.length} role{visibleJobs.length === 1 ? '' : 's'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body" style={{ paddingBottom: 0 }}>
|
||||
<div className="toolbar">
|
||||
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && (
|
||||
<div className="progress-shell">
|
||||
<aside className="progress-sidebar">
|
||||
<div className="progress-sidebar-filters">
|
||||
<div className="toolbar-search">
|
||||
<Icon name="search" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="Search jobs, teams, recruiters…"
|
||||
aria-label="Search job posts"
|
||||
/>
|
||||
</div>
|
||||
<div className="progress-sidebar-controls">
|
||||
<select
|
||||
className="select"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
aria-label="Filter by status"
|
||||
>
|
||||
<option value="all">All statuses</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="on_hold">On hold</option>
|
||||
<option value="closed">Closed</option>
|
||||
</select>
|
||||
<span className="progress-sort-chip">Sort: applicants</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={tableColumns}
|
||||
rows={visibleJobs}
|
||||
pageSize={8}
|
||||
empty="No job posts match this search."
|
||||
/>
|
||||
|
||||
<div className="progress-sidebar-meta">
|
||||
<span>{filtered.length} shown · {jobs.length} total roles</span>
|
||||
<span>Applicants</span>
|
||||
</div>
|
||||
|
||||
<div className="progress-job-list">
|
||||
{pageRows.length === 0 ? (
|
||||
<div className="progress-sidebar-empty">No jobs match this filter.</div>
|
||||
) : (
|
||||
pageRows.map((job) => (
|
||||
<JobRow
|
||||
key={job.id}
|
||||
job={job}
|
||||
active={String(job.id) === String(selected?.id)}
|
||||
onSelect={() => selectJob(job.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</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>
|
||||
</aside>
|
||||
|
||||
<main className="progress-main">
|
||||
{selected
|
||||
? <JobDetail job={selected} />
|
||||
: (
|
||||
<EmptyState icon="briefcase" title="Select a job post">
|
||||
Choose a role from the list to inspect its pipeline.
|
||||
</EmptyState>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -690,13 +690,9 @@ table.data tbody tr:last-child td { border-bottom: none; }
|
|||
.page-nums { display: flex; gap: 4px; align-items: center; min-width: 0; }
|
||||
.page-size { display: flex; align-items: center; gap: 8px; margin-right: 8px; flex-shrink: 0; }
|
||||
.page-size-label { font-size: 13px; color: var(--text-2); white-space: nowrap; }
|
||||
.page-size-select { height: 34px; padding: 0 28px 0 10px; font-size: 13px; }
|
||||
.page-size-input {
|
||||
width: 72px; height: 34px; padding: 0 8px; font-size: 13px; font-weight: 600;
|
||||
text-align: center; border-radius: 8px; border: 1px solid var(--border-strong);
|
||||
background: var(--bg-elev); color: inherit; outline: none;
|
||||
.page-size-select {
|
||||
height: 34px; min-width: 72px; padding: 0 28px 0 10px; font-size: 13px; font-weight: 600;
|
||||
}
|
||||
.page-size-input:focus { border-color: var(--primary); box-shadow: var(--ring); }
|
||||
.page-size-total { font-size: 13px; color: var(--text-2); margin-right: 8px; white-space: nowrap; }
|
||||
.page-btn { min-width: 34px; height: 34px; padding: 0 8px; border-radius: 8px; display: grid; place-items: center; font-size: 13px; font-weight: 600; color: var(--text-2); border: 1px solid transparent; }
|
||||
.page-btn:hover:not(:disabled) { background: var(--bg-sunken); }
|
||||
|
|
@ -1789,19 +1785,96 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
|
||||
|
||||
/* ================= PROGRESS (job-post stage overview) ================= */
|
||||
.progress-filter-head { flex-wrap: wrap; }
|
||||
.progress-filter-head .select { min-width: 240px; }
|
||||
.progress-selected {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
||||
flex-wrap: wrap; margin-bottom: 18px;
|
||||
/* ---------- Progress (master / detail) ---------- */
|
||||
.progress-header-stats {
|
||||
display: flex; gap: 24px; align-items: flex-end; flex-wrap: wrap;
|
||||
}
|
||||
.progress-metric strong {
|
||||
display: block; font-family: var(--font-display); font-size: 20px; font-weight: 600;
|
||||
letter-spacing: -.02em; line-height: 1.1; color: var(--text);
|
||||
}
|
||||
.progress-metric strong.accent { color: var(--primary); }
|
||||
.progress-metric span {
|
||||
display: block; margin-top: 5px; font-size: var(--fs-sm); color: var(--text-2);
|
||||
}
|
||||
|
||||
.progress-shell {
|
||||
display: grid; grid-template-columns: minmax(280px, 32%) minmax(0, 1fr);
|
||||
min-height: 690px; border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||||
overflow: hidden; background: var(--bg-elev);
|
||||
}
|
||||
.progress-sidebar {
|
||||
display: flex; flex-direction: column; min-width: 0;
|
||||
border-right: 1px solid var(--border); background: var(--bg);
|
||||
}
|
||||
.progress-sidebar-filters {
|
||||
padding: 14px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 9px;
|
||||
}
|
||||
.progress-sidebar-filters .toolbar-search { max-width: none; min-width: 0; }
|
||||
.progress-sidebar-controls { display: flex; gap: 8px; align-items: center; }
|
||||
.progress-sidebar-controls .select { flex: 1; min-width: 0; }
|
||||
.progress-sort-chip {
|
||||
flex: 0 0 auto; padding: 8px 12px; border-radius: 9px; border: 1px solid var(--border-strong);
|
||||
background: var(--bg-elev); color: var(--text); font-size: var(--fs-sm); font-weight: 500;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.progress-sidebar-meta {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
padding: 9px 14px; border-bottom: 1px solid var(--border);
|
||||
font-size: var(--fs-sm); color: var(--text-2);
|
||||
}
|
||||
.progress-job-list { flex: 1; overflow-y: auto; min-height: 0; max-height: 528px; }
|
||||
.progress-sidebar-empty {
|
||||
padding: 28px 16px; text-align: center; color: var(--text-3); font-size: var(--fs-sm);
|
||||
}
|
||||
.progress-job-row {
|
||||
width: 100%; border: 0; border-bottom: 1px solid var(--border);
|
||||
border-left: 3px solid transparent; background: transparent; color: var(--text);
|
||||
padding: 12px 12px 12px 13px; text-align: left; cursor: pointer; font: inherit;
|
||||
}
|
||||
.progress-job-row:hover { background: var(--bg-sunken); }
|
||||
.progress-job-row.active {
|
||||
background: var(--primary-soft); border-left-color: var(--primary);
|
||||
}
|
||||
.progress-job-row-top,
|
||||
.progress-job-row-sub {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
}
|
||||
.progress-job-row-sub { margin-top: 5px; font-size: var(--fs-sm); color: var(--text-2); }
|
||||
.progress-job-row-sub > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.progress-job-title {
|
||||
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
font-weight: 600; font-size: var(--fs-base); color: var(--text);
|
||||
}
|
||||
.progress-job-count {
|
||||
flex: 0 0 auto; font-family: var(--font-display); font-weight: 600; color: var(--text);
|
||||
}
|
||||
.progress-status-dot {
|
||||
width: 7px; height: 7px; border-radius: 99px; flex: 0 0 auto; margin-left: auto;
|
||||
background: var(--text-3);
|
||||
}
|
||||
.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-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; }
|
||||
.progress-detail-head {
|
||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap;
|
||||
}
|
||||
.progress-detail-tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
|
||||
.progress-eyebrow {
|
||||
display: inline-block; font-size: var(--fs-xs); font-weight: 600; letter-spacing: .04em;
|
||||
text-transform: uppercase; color: var(--primary); margin-bottom: 4px;
|
||||
text-transform: uppercase; color: var(--primary);
|
||||
}
|
||||
.progress-selected h2 {
|
||||
.progress-detail h2 {
|
||||
font-family: var(--font-display); font-size: var(--fs-xl); font-weight: 600;
|
||||
letter-spacing: -.02em; margin: 0 0 6px;
|
||||
letter-spacing: -.02em; margin: 0 0 6px; color: var(--text);
|
||||
}
|
||||
.progress-meta {
|
||||
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
|
||||
|
|
@ -1809,15 +1882,14 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
}
|
||||
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
|
||||
.progress-selected-total {
|
||||
display: flex; flex-direction: column; align-items: center; text-align: center;
|
||||
min-width: 110px;
|
||||
display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 110px;
|
||||
}
|
||||
.progress-selected-total strong {
|
||||
display: block; font-family: var(--font-display); font-size: 32px; font-weight: 600;
|
||||
letter-spacing: -.02em; line-height: 1;
|
||||
display: block; font-family: var(--font-display); font-size: 28px; font-weight: 600;
|
||||
letter-spacing: -.02em; line-height: 1; color: var(--text);
|
||||
}
|
||||
.progress-selected-total span {
|
||||
display: block; font-size: var(--fs-xs); color: var(--text-3); margin-top: 4px;
|
||||
display: block; font-size: var(--fs-xs); color: var(--text-2); margin-top: 4px;
|
||||
}
|
||||
|
||||
.progress-stage-grid {
|
||||
|
|
@ -1863,6 +1935,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.progress-stage.stage-red .progress-stage-meter > i { background: var(--danger); }
|
||||
|
||||
.progress-bar-wrap { margin-top: 4px; }
|
||||
.progress-bar-wrap { margin-top: 0; }
|
||||
.progress-bar-labels {
|
||||
display: flex; justify-content: space-between; gap: 12px;
|
||||
font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px;
|
||||
|
|
@ -1880,23 +1953,90 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.progress-bar-seg.stage-teal { background: var(--teal); }
|
||||
.progress-bar-seg.stage-red { background: var(--danger); }
|
||||
|
||||
.progress-health { display: flex; flex-direction: column; gap: 16px; }
|
||||
.progress-health > div { display: flex; align-items: flex-start; gap: 12px; }
|
||||
.progress-health-icon {
|
||||
width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: 0 0 auto;
|
||||
.progress-section-head,
|
||||
.progress-detail h3 {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
margin: 0; font-size: var(--fs-md); font-weight: 600; color: var(--text);
|
||||
}
|
||||
.progress-section-head span { font-size: var(--fs-sm); font-weight: 400; color: var(--text-2); }
|
||||
|
||||
.progress-stage-rows { margin-top: 8px; }
|
||||
.progress-stage-row {
|
||||
display: grid; grid-template-columns: 118px minmax(120px, 1fr) 56px 42px;
|
||||
gap: 12px; align-items: center; padding: 9px 0;
|
||||
}
|
||||
.progress-stage-row-label {
|
||||
display: flex; align-items: center; gap: 8px; min-width: 0;
|
||||
font-size: var(--fs-sm); color: var(--text);
|
||||
}
|
||||
.progress-stage-dot {
|
||||
width: 8px; height: 8px; border-radius: 99px; flex: 0 0 auto;
|
||||
}
|
||||
.progress-stage-dot.stage-blue { background: var(--info); }
|
||||
.progress-stage-dot.stage-purple { background: var(--purple); }
|
||||
.progress-stage-dot.stage-amber { background: var(--warning); }
|
||||
.progress-stage-dot.stage-indigo { background: var(--primary); }
|
||||
.progress-stage-dot.stage-teal { background: var(--teal); }
|
||||
.progress-stage-dot.stage-red { background: var(--danger); }
|
||||
.progress-stage-row-meter {
|
||||
height: 8px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden;
|
||||
}
|
||||
.progress-stage-row-meter > i {
|
||||
display: block; height: 100%; min-width: 0; border-radius: 99px;
|
||||
}
|
||||
.progress-stage-row-meter > i.stage-blue { background: var(--info); }
|
||||
.progress-stage-row-meter > i.stage-purple { background: var(--purple); }
|
||||
.progress-stage-row-meter > i.stage-amber { background: var(--warning); }
|
||||
.progress-stage-row-meter > i.stage-indigo { background: var(--primary); }
|
||||
.progress-stage-row-meter > i.stage-teal { background: var(--teal); }
|
||||
.progress-stage-row-meter > i.stage-red { background: var(--danger); }
|
||||
.progress-stage-row strong {
|
||||
text-align: right; font-family: var(--font-display); font-weight: 600; color: var(--text);
|
||||
}
|
||||
.progress-stage-row-pct { text-align: right; font-size: var(--fs-sm); color: var(--text-2); }
|
||||
|
||||
.progress-metric-grid {
|
||||
display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr));
|
||||
gap: 1px; background: var(--border); border: 1px solid var(--border);
|
||||
border-radius: var(--radius); overflow: hidden;
|
||||
}
|
||||
.progress-metric-grid > .progress-metric {
|
||||
padding: 14px; background: var(--bg-elev);
|
||||
}
|
||||
|
||||
.progress-bottom-grid {
|
||||
display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr); gap: 16px;
|
||||
}
|
||||
.progress-attention-list { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
|
||||
.progress-attention-item {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||
padding: 12px; border-radius: 7px; background: var(--bg-sunken);
|
||||
font-size: var(--fs-sm); color: var(--text-2);
|
||||
}
|
||||
.progress-attention-item strong {
|
||||
font-family: var(--font-display); font-size: var(--fs-base); color: var(--text);
|
||||
}
|
||||
.progress-outcome-card { margin: 0; }
|
||||
.progress-outcome-card h3 { margin-bottom: 14px; }
|
||||
.progress-outcome-grid {
|
||||
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
|
||||
}
|
||||
.progress-health-icon svg { width: 18px; height: 18px; }
|
||||
.progress-health strong { display: block; font-size: var(--fs-base); margin-bottom: 2px; }
|
||||
.progress-health span { font-size: var(--fs-sm); color: var(--text-3); }
|
||||
.text-warning { color: var(--warning); }
|
||||
.text-danger { color: var(--danger); }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.progress-selected-total { align-items: flex-start; text-align: left; }
|
||||
@media (max-width: 1100px) {
|
||||
.progress-shell { grid-template-columns: 1fr; min-height: 0; }
|
||||
.progress-sidebar { border-right: 0; border-bottom: 1px solid var(--border); }
|
||||
.progress-job-list { max-height: 320px; }
|
||||
.progress-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
.progress-bottom-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.progress-stage-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
.progress-filter-head .select { width: 100%; min-width: 0; }
|
||||
.progress-header-stats { width: 100%; justify-content: space-between; }
|
||||
.progress-stage-row { grid-template-columns: 96px minmax(80px, 1fr) 44px 36px; gap: 8px; }
|
||||
.progress-selected-total { align-items: flex-start; text-align: left; }
|
||||
.progress-sidebar-controls { flex-wrap: wrap; }
|
||||
.progress-sort-chip { width: 100%; text-align: center; }
|
||||
}
|
||||
/* Small phones: two columns leave ~56px for a stage label — stack instead. */
|
||||
@media (max-width: 400px) {
|
||||
|
|
|
|||
|
|
@ -18,10 +18,17 @@ import { EmptyState } from './primitives'
|
|||
/** Matches the backend Query(10) default on list GET endpoints. */
|
||||
export const DEFAULT_PAGE_SIZE = 10
|
||||
|
||||
/** Fixed Per page choices — a dropdown, not a free-text box. */
|
||||
export const PAGE_SIZE_OPTIONS = [10, 50, 100]
|
||||
|
||||
export function clampPageSize(value, max = 100) {
|
||||
const allowed = PAGE_SIZE_OPTIONS.filter((n) => n <= max)
|
||||
const fallback = allowed.includes(DEFAULT_PAGE_SIZE)
|
||||
? DEFAULT_PAGE_SIZE
|
||||
: (allowed[0] ?? DEFAULT_PAGE_SIZE)
|
||||
const n = Number.parseInt(value, 10)
|
||||
if (!Number.isFinite(n) || n < 1) return DEFAULT_PAGE_SIZE
|
||||
return Math.min(n, max)
|
||||
if (!Number.isFinite(n) || !allowed.includes(n)) return fallback
|
||||
return n
|
||||
}
|
||||
|
||||
export function useDataTable({ columns, rows, pageSize = DEFAULT_PAGE_SIZE }) {
|
||||
|
|
@ -103,57 +110,30 @@ export function pageAfterSizeChange(currentPage, total, nextSize) {
|
|||
return Math.min(Math.max(1, currentPage || 1), pages)
|
||||
}
|
||||
|
||||
/** Local draft so typing "50" does not fire a GET for 5, then 50.
|
||||
With `allowAll`, an "All" toggle sits beside the input: active it reports
|
||||
the sentinel 'all' (the caller drops its top/limit param — the endpoints
|
||||
treat a missing page size as unpaged); committing a number exits it. */
|
||||
/** Per page dropdown: 10, 50, 100 (values above `max` are omitted).
|
||||
With `allowAll`, an extra "All" entry reports the sentinel 'all' — the
|
||||
caller drops its top/limit param, which the inbox endpoints read as
|
||||
unpaged (Query(None) -> no LIMIT). Opt-in per screen. */
|
||||
export function PageSizeField({ value, onChange, max = 100, label = 'Per page', id, allowAll = false }) {
|
||||
const options = PAGE_SIZE_OPTIONS.filter((n) => n <= max)
|
||||
const isAll = allowAll && value === 'all'
|
||||
const numeric = isAll ? DEFAULT_PAGE_SIZE : (value ?? DEFAULT_PAGE_SIZE)
|
||||
const [draft, setDraft] = useState(String(numeric))
|
||||
useEffect(() => setDraft(String(numeric)), [numeric])
|
||||
|
||||
function commit() {
|
||||
const next = clampPageSize(draft, max)
|
||||
setDraft(String(next))
|
||||
if (isAll || next !== value) onChange(next)
|
||||
}
|
||||
const selected = isAll ? 'all' : clampPageSize(value, max)
|
||||
|
||||
return (
|
||||
<label className="page-size">
|
||||
<span className="page-size-label">{label}</span>
|
||||
<input
|
||||
<select
|
||||
id={id}
|
||||
className="page-size-input"
|
||||
type="number"
|
||||
min={1}
|
||||
max={max}
|
||||
value={draft}
|
||||
disabled={isAll}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={commit}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault()
|
||||
e.currentTarget.blur()
|
||||
}
|
||||
}}
|
||||
className="select page-size-select"
|
||||
value={selected}
|
||||
onChange={(e) => onChange(e.target.value === 'all' ? 'all' : clampPageSize(e.target.value, max))}
|
||||
aria-label={label}
|
||||
/>
|
||||
{allowAll && (
|
||||
<button
|
||||
type="button"
|
||||
className={`page-btn${isAll ? ' active' : ''}`}
|
||||
aria-pressed={isAll}
|
||||
title={isAll ? 'Back to paged view' : 'Show all rows'}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
onChange(isAll ? clampPageSize(draft, max) : 'all')
|
||||
}}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
{options.map((n) => (
|
||||
<option key={n} value={n}>{n}</option>
|
||||
))}
|
||||
{allowAll && <option value="all">All</option>}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue