add history
parent
3ab1dc9117
commit
c7d630875c
|
|
@ -32,6 +32,27 @@ def _auth_headers(token=None):
|
||||||
return {"Authorization":f"Bearer {auth_token}"}
|
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):
|
async def create_event(payload, token=None):
|
||||||
"""POST {base}/calendar/events -> created event dict."""
|
"""POST {base}/calendar/events -> created event dict."""
|
||||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
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:
|
def serialize_event(payload) -> dict:
|
||||||
"""Upstream calendar event -> the fields the interview row stores."""
|
"""Upstream calendar event -> the fields the interview row stores."""
|
||||||
if not isinstance(payload,dict):
|
if not isinstance(payload,dict):
|
||||||
|
|
|
||||||
|
|
@ -913,6 +913,18 @@ class Interviews(SQLModel, table=True):
|
||||||
)
|
)
|
||||||
return result.scalars().first()
|
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
|
@classmethod
|
||||||
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
|
||||||
result = await session.execute(
|
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 {
|
return {
|
||||||
"id": str(row.id),
|
"id": str(row.id),
|
||||||
"user_id": str(row.user_id) if row.user_id else None,
|
"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_name": actor_name,
|
||||||
"actor_kind": row.actor_kind,
|
"actor_kind": row.actor_kind,
|
||||||
"meta": row.meta,
|
"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,
|
"created_at": row.created_at.isoformat() if row.created_at else None,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,25 @@
|
||||||
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from inbox.models import Inbox
|
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 job.history.serializers import serialize_history
|
||||||
from users.models import Users
|
from users.models import Users
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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):
|
def _text(value):
|
||||||
if value is None:
|
if value is None:
|
||||||
|
|
@ -111,10 +121,70 @@ class HistoryRecorder:
|
||||||
logger.exception("candidate history rollback failed")
|
logger.exception("candidate history rollback failed")
|
||||||
return None
|
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):
|
async def list_for_user(self, user_id, *, limit=200, offset=0):
|
||||||
rows, total = await CandidateHistory.fetch_by_user(
|
rows, total = await CandidateHistory.fetch_by_user(
|
||||||
self.session, user_id, limit=limit, offset=offset
|
self.session, user_id, limit=limit, offset=offset
|
||||||
)
|
)
|
||||||
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
actor_ids = {r.actor_id for r in rows if r.actor_id}
|
||||||
names = await Users.names_by_ids(self.session, actor_ids)
|
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.location,
|
||||||
cls.requisition_status,
|
cls.requisition_status,
|
||||||
cls.current_recruiter_id,
|
cls.current_recruiter_id,
|
||||||
|
cls.created_at,
|
||||||
Recruiter.name.label("recruiter_name"),
|
Recruiter.name.label("recruiter_name"),
|
||||||
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
|
||||||
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),
|
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:
|
def serialize_job_stats(row) -> dict:
|
||||||
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
|
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
|
||||||
recruiter_id=row.get("current_recruiter_id")
|
recruiter_id=row.get("current_recruiter_id")
|
||||||
|
created_at = row.get("created_at")
|
||||||
return {
|
return {
|
||||||
"job_post_id": str(row["job_post_id"]),
|
"job_post_id": str(row["job_post_id"]),
|
||||||
"title": row["title"],
|
"title": row["title"],
|
||||||
|
|
@ -94,6 +95,8 @@ def serialize_job_stats(row) -> dict:
|
||||||
"requisition_status": row["requisition_status"],
|
"requisition_status": row["requisition_status"],
|
||||||
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
|
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
|
||||||
"recruiter_name": row.get("recruiter_name") or 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),
|
"total_applicants": int(row["total_applicants"] or 0),
|
||||||
"shortlisting": int(row["shortlisting"] or 0),
|
"shortlisting": int(row["shortlisting"] or 0),
|
||||||
"screened": int(row["screened"] or 0),
|
"screened": int(row["screened"] or 0),
|
||||||
|
|
|
||||||
|
|
@ -24,8 +24,8 @@
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<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="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>" />
|
<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>
|
<script type="module" crossorigin src="/assets/index-CLFuERsT.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-C6biZ8qR.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-CuQsXTK_.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,8 @@ dom.window.HTMLCanvasElement.prototype.getContext = () =>
|
||||||
|
|
||||||
// A signed-in session holding every permission tag, so no route is gated away.
|
// A signed-in session holding every permission tag, so no route is gated away.
|
||||||
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
|
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 ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
|
||||||
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))
|
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. */
|
/** API row -> what Progress cards and the table render. */
|
||||||
export function toJobStatsView(row) {
|
export function toJobStatsView(row) {
|
||||||
|
const createdAt = row.created_at || null
|
||||||
return {
|
return {
|
||||||
id: row.job_post_id,
|
id: row.job_post_id,
|
||||||
title: row.title || 'Untitled role',
|
title: row.title || 'Untitled role',
|
||||||
|
|
@ -38,6 +47,8 @@ export function toJobStatsView(row) {
|
||||||
requisitionStatus: row.requisition_status,
|
requisitionStatus: row.requisition_status,
|
||||||
recruiterId: row.current_recruiter_id || null,
|
recruiterId: row.current_recruiter_id || null,
|
||||||
recruiterName: row.recruiter_name || null,
|
recruiterName: row.recruiter_name || null,
|
||||||
|
createdAt,
|
||||||
|
daysOpen: daysOpen(createdAt),
|
||||||
total: Number(row.total_applicants) || 0,
|
total: Number(row.total_applicants) || 0,
|
||||||
shortlist: Number(row.shortlisting) || 0,
|
shortlist: Number(row.shortlisting) || 0,
|
||||||
screened: Number(row.screened) || 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 { useMemo, useState } from 'react'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
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 }) {
|
function HistoryRow({ row: r }) {
|
||||||
const look = HISTORY_ICON[r.event_type] || { icon: 'clock', tone: 'i-blue' }
|
const look = HISTORY_ICON[r.event_type] || { icon: 'clock', tone: 'i-blue' }
|
||||||
const title = HISTORY_TITLE[r.event_type] || r.event_type
|
const title = HISTORY_TITLE[r.event_type] || r.event_type
|
||||||
const change = [r.from_value, r.to_value].some((v) => v != null && v !== '')
|
const change = [r.from_value, r.to_value].some((v) => v != null && v !== '')
|
||||||
? `${r.from_value ?? '—'} → ${r.to_value ?? '—'}`
|
? `${r.from_value ?? '—'} → ${r.to_value ?? '—'}`
|
||||||
: null
|
: null
|
||||||
|
const isInterview = INTERVIEW_HISTORY_TYPES.has(r.event_type)
|
||||||
const actor = r.actor_name || (r.actor_id ? 'Unknown' : 'System')
|
const actor = r.actor_name || (r.actor_id ? 'Unknown' : 'System')
|
||||||
const when = [fmtWhen(r.created_at), fmtClock(r.created_at)].filter(Boolean).join(' · ')
|
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 (
|
return (
|
||||||
<div className="list-row">
|
<div className="list-row">
|
||||||
<span className={`kpi-icn ${look.tone}`} style={{ width: 38, height: 38, borderRadius: 10 }}>
|
<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>
|
<div className="lr-title">{title}</div>
|
||||||
{change && <div className="lr-sub">{change}</div>}
|
{change && <div className="lr-sub">{change}</div>}
|
||||||
{r.description && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{r.description}</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>
|
||||||
<div className="lr-right">
|
<div className="lr-right">
|
||||||
{r.actor_name || r.actor_id
|
{isInterview
|
||||||
|
? null
|
||||||
|
: (r.actor_name || r.actor_id
|
||||||
? <Avatar name={actor} />
|
? <Avatar name={actor} />
|
||||||
: <Badge className="b-gray">System</Badge>}
|
: <Badge className="b-gray">System</Badge>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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).
|
Left: searchable/filterable job index (paged). Right: selected job
|
||||||
Counts are unique applicants by email; recruiter comes from
|
pipeline breakdown. Days-open is computed client-side from created_at.
|
||||||
job_posts.current_recruiter_id.
|
|
||||||
============================================================ */
|
============================================================ */
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
|
|
@ -11,51 +10,125 @@ 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 DataTable from '../ui/DataTable'
|
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
|
||||||
import { Badge, EmptyState, Icon, KpiCard, 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', icon: 'star', tone: 'blue' },
|
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
|
||||||
{ key: 'screened', label: 'Screened', icon: 'eye', tone: 'purple' },
|
{ key: 'screened', label: 'Screened', tone: 'purple' },
|
||||||
{ key: 'assessment', label: 'Assessment', icon: 'check-square', tone: 'amber' },
|
{ key: 'assessment', label: 'Assessment', tone: 'amber' },
|
||||||
{ key: 'interviewed', label: 'Interviewed', icon: 'calendar', tone: 'indigo' },
|
{ key: 'interviewed', label: 'Interviewed', tone: 'indigo' },
|
||||||
{ key: 'offered', label: 'Offered', icon: 'send', tone: 'teal' },
|
{ key: 'offered', label: 'Offered', tone: 'teal' },
|
||||||
{ key: 'onHold', label: 'On Hold', icon: 'clock', tone: 'amber' },
|
{ key: 'onHold', label: 'On hold', tone: 'amber' },
|
||||||
{ key: 'rejected', label: 'Rejected', icon: 'x-circle', tone: 'red' },
|
{ key: 'rejected', label: 'Rejected', tone: 'red' },
|
||||||
|
{ key: 'hired', label: 'Hired', tone: 'teal' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function sumField(jobs, key) {
|
function sumField(jobs, key) {
|
||||||
return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0)
|
return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StageTile({ stage, value, total }) {
|
function statusTone(status) {
|
||||||
const pct = total ? Math.round((value / total) * 100) : 0
|
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 (
|
return (
|
||||||
<div className={`progress-stage stage-${stage.tone}`}>
|
<div className="progress-metric">
|
||||||
<div className="progress-stage-head">
|
<strong className={accent ? 'accent' : undefined}>{value}</strong>
|
||||||
<span className="progress-stage-icon"><Icon name={stage.icon} /></span>
|
<span>{label}</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>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function StageBar({ job }) {
|
function JobRow({ job, active, onSelect }) {
|
||||||
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
|
|
||||||
const base = Math.max(used, job.total, 1)
|
|
||||||
return (
|
return (
|
||||||
|
<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-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-wrap">
|
||||||
<div className="progress-bar-labels">
|
<div className="progress-bar-labels">
|
||||||
<span>Current stage distribution</span>
|
<span>Candidate distribution by current stage</span>
|
||||||
<span>{job.total} unique applicants</span>
|
<span>{job.total.toLocaleString()} applicants</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
|
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
|
||||||
{STAGES.map((stage) => {
|
{STAGES.map((stage) => {
|
||||||
|
|
@ -66,12 +139,66 @@ function StageBar({ job }) {
|
||||||
key={stage.key}
|
key={stage.key}
|
||||||
className={`progress-bar-seg stage-${stage.tone}`}
|
className={`progress-bar-seg stage-${stage.tone}`}
|
||||||
title={`${stage.label}: ${n}`}
|
title={`${stage.label}: ${n}`}
|
||||||
style={{ width: `${(n / base) * 100}%` }}
|
style={{ width: `${(n / barBase) * 100}%` }}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</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 [searchParams, setSearchParams] = useSearchParams()
|
||||||
const deepLinkJobId = searchParams.get('job') || ''
|
const deepLinkJobId = searchParams.get('job') || ''
|
||||||
|
|
||||||
const [tab, setTab] = useState('overview')
|
|
||||||
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
const [selectedId, setSelectedId] = useState(deepLinkJobId)
|
||||||
const [query, setQuery] = useState('')
|
const [query, setQuery] = useState('')
|
||||||
|
const [status, setStatus] = useState('all')
|
||||||
|
const [page, setPage] = useState(0)
|
||||||
|
|
||||||
const statsQuery = useQuery({
|
const statsQuery = useQuery({
|
||||||
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
|
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
|
||||||
|
|
@ -94,6 +222,39 @@ export default function Progress() {
|
||||||
|
|
||||||
const jobs = statsQuery.data ?? []
|
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(() => {
|
useEffect(() => {
|
||||||
if (!jobs.length) {
|
if (!jobs.length) {
|
||||||
setSelectedId('')
|
setSelectedId('')
|
||||||
|
|
@ -102,7 +263,6 @@ export default function Progress() {
|
||||||
const matchId = (id) => jobs.some((j) => String(j.id) === String(id))
|
const matchId = (id) => jobs.some((j) => String(j.id) === String(id))
|
||||||
if (deepLinkJobId && matchId(deepLinkJobId)) {
|
if (deepLinkJobId && matchId(deepLinkJobId)) {
|
||||||
setSelectedId(String(deepLinkJobId))
|
setSelectedId(String(deepLinkJobId))
|
||||||
setTab('overview')
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (!selectedId || !matchId(selectedId)) {
|
if (!selectedId || !matchId(selectedId)) {
|
||||||
|
|
@ -113,7 +273,8 @@ export default function Progress() {
|
||||||
const selectJob = (id) => {
|
const selectJob = (id) => {
|
||||||
const next = String(id || '')
|
const next = String(id || '')
|
||||||
setSelectedId(next)
|
setSelectedId(next)
|
||||||
setTab('overview')
|
const idx = filtered.findIndex((j) => String(j.id) === next)
|
||||||
|
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
|
||||||
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)
|
||||||
|
|
@ -122,102 +283,32 @@ export default function Progress() {
|
||||||
}, { replace: true })
|
}, { replace: true })
|
||||||
}
|
}
|
||||||
|
|
||||||
const selected = jobs.find((j) => String(j.id) === String(selectedId)) || jobs[0] || null
|
const selected = jobs.find((j) => String(j.id) === String(selectedId))
|
||||||
|
|| filtered[0]
|
||||||
const visibleJobs = useMemo(() => {
|
|| jobs[0]
|
||||||
const q = query.trim().toLowerCase()
|
|| null
|
||||||
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 totalApplicants = sumField(jobs, 'total')
|
const totalApplicants = sumField(jobs, 'total')
|
||||||
const activePipeline = sumField(jobs, 'shortlist')
|
const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length
|
||||||
+ sumField(jobs, 'screened')
|
const rangeStart = filtered.length ? safePage * PAGE_SIZE + 1 : 0
|
||||||
+ sumField(jobs, 'assessment')
|
const rangeEnd = Math.min(filtered.length, safePage * PAGE_SIZE + PAGE_SIZE)
|
||||||
+ 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>,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="page progress-page">
|
<div className="page progress-page">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Progress"
|
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 && (
|
{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 && (
|
{statsQuery.isError && (
|
||||||
|
|
@ -237,194 +328,89 @@ export default function Progress() {
|
||||||
</div></div>
|
</div></div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'overview' && selected && (
|
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && (
|
||||||
<>
|
<div className="progress-shell">
|
||||||
<div className="grid g-kpi mb-18">
|
<aside className="progress-sidebar">
|
||||||
<KpiCard
|
<div className="progress-sidebar-filters">
|
||||||
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">
|
|
||||||
<div className="toolbar-search">
|
<div className="toolbar-search">
|
||||||
<Icon name="search" />
|
<Icon name="search" />
|
||||||
<input
|
<input
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search jobs, teams, recruiters…"
|
placeholder="Search jobs, teams, recruiters…"
|
||||||
|
aria-label="Search job posts"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
<DataTable
|
|
||||||
columns={tableColumns}
|
<div className="progress-sidebar-meta">
|
||||||
rows={visibleJobs}
|
<span>{filtered.length} shown · {jobs.length} total roles</span>
|
||||||
pageSize={8}
|
<span>Applicants</span>
|
||||||
empty="No job posts match this search."
|
</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>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1753,19 +1753,96 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
|
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
|
||||||
|
|
||||||
/* ================= PROGRESS (job-post stage overview) ================= */
|
/* ================= PROGRESS (job-post stage overview) ================= */
|
||||||
.progress-filter-head { flex-wrap: wrap; }
|
/* ---------- Progress (master / detail) ---------- */
|
||||||
.progress-filter-head .select { min-width: 240px; }
|
.progress-header-stats {
|
||||||
.progress-selected {
|
display: flex; gap: 24px; align-items: flex-end; flex-wrap: wrap;
|
||||||
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
|
|
||||||
flex-wrap: wrap; margin-bottom: 18px;
|
|
||||||
}
|
}
|
||||||
|
.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 {
|
.progress-eyebrow {
|
||||||
display: inline-block; font-size: var(--fs-xs); font-weight: 600; letter-spacing: .04em;
|
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;
|
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 {
|
.progress-meta {
|
||||||
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
|
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
|
||||||
|
|
@ -1773,57 +1850,17 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
}
|
}
|
||||||
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
|
.progress-meta svg { width: 14px; height: 14px; vertical-align: -2px; margin-right: 4px; }
|
||||||
.progress-selected-total {
|
.progress-selected-total {
|
||||||
display: flex; flex-direction: column; align-items: center; text-align: center;
|
display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 110px;
|
||||||
min-width: 110px;
|
|
||||||
}
|
}
|
||||||
.progress-selected-total strong {
|
.progress-selected-total strong {
|
||||||
display: block; font-family: var(--font-display); font-size: 32px; font-weight: 600;
|
display: block; font-family: var(--font-display); font-size: 28px; font-weight: 600;
|
||||||
letter-spacing: -.02em; line-height: 1;
|
letter-spacing: -.02em; line-height: 1; color: var(--text);
|
||||||
}
|
}
|
||||||
.progress-selected-total span {
|
.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 {
|
.progress-bar-wrap { margin-top: 0; }
|
||||||
display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
|
||||||
gap: 12px; margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
.progress-stage {
|
|
||||||
background: var(--bg-elev); border: 1px solid var(--border); border-radius: var(--radius);
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
.progress-stage-head {
|
|
||||||
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.progress-stage-icon {
|
|
||||||
width: 22px; height: 22px; border-radius: 7px; display: grid; place-items: center; flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
.progress-stage-icon svg { width: 13px; height: 13px; }
|
|
||||||
.progress-stage-label { font-size: var(--fs-sm); color: var(--text-2); font-weight: 500; min-width: 0; }
|
|
||||||
.progress-stage-pct { margin-left: auto; font-size: var(--fs-xs); color: var(--text-3); font-weight: 600; }
|
|
||||||
.progress-stage-value {
|
|
||||||
display: block; font-family: var(--font-display); font-size: 24px; font-weight: 600;
|
|
||||||
letter-spacing: -.02em; line-height: 1.1; margin-bottom: 10px;
|
|
||||||
}
|
|
||||||
.progress-stage-meter {
|
|
||||||
height: 4px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden;
|
|
||||||
}
|
|
||||||
.progress-stage-meter > i { display: block; height: 100%; border-radius: 99px; }
|
|
||||||
|
|
||||||
.progress-stage.stage-blue .progress-stage-icon { background: var(--info-soft); color: var(--info); }
|
|
||||||
.progress-stage.stage-blue .progress-stage-meter > i { background: var(--info); }
|
|
||||||
.progress-stage.stage-purple .progress-stage-icon { background: var(--purple-soft); color: var(--purple); }
|
|
||||||
.progress-stage.stage-purple .progress-stage-meter > i { background: var(--purple); }
|
|
||||||
.progress-stage.stage-amber .progress-stage-icon { background: var(--warning-soft); color: var(--warning); }
|
|
||||||
.progress-stage.stage-amber .progress-stage-meter > i { background: var(--warning); }
|
|
||||||
.progress-stage.stage-indigo .progress-stage-icon { background: var(--primary-soft); color: var(--primary); }
|
|
||||||
.progress-stage.stage-indigo .progress-stage-meter > i { background: var(--primary); }
|
|
||||||
.progress-stage.stage-teal .progress-stage-icon { background: var(--teal-soft); color: var(--teal); }
|
|
||||||
.progress-stage.stage-teal .progress-stage-meter > i { background: var(--teal); }
|
|
||||||
.progress-stage.stage-red .progress-stage-icon { background: var(--danger-soft); color: var(--danger); }
|
|
||||||
.progress-stage.stage-red .progress-stage-meter > i { background: var(--danger); }
|
|
||||||
|
|
||||||
.progress-bar-wrap { margin-top: 4px; }
|
|
||||||
.progress-bar-labels {
|
.progress-bar-labels {
|
||||||
display: flex; justify-content: space-between; gap: 12px;
|
display: flex; justify-content: space-between; gap: 12px;
|
||||||
font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px;
|
font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px;
|
||||||
|
|
@ -1841,23 +1878,90 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
||||||
.progress-bar-seg.stage-teal { background: var(--teal); }
|
.progress-bar-seg.stage-teal { background: var(--teal); }
|
||||||
.progress-bar-seg.stage-red { background: var(--danger); }
|
.progress-bar-seg.stage-red { background: var(--danger); }
|
||||||
|
|
||||||
.progress-health { display: flex; flex-direction: column; gap: 16px; }
|
.progress-section-head,
|
||||||
.progress-health > div { display: flex; align-items: flex-start; gap: 12px; }
|
.progress-detail h3 {
|
||||||
.progress-health-icon {
|
display: flex; align-items: center; justify-content: space-between; gap: 12px;
|
||||||
width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: 0 0 auto;
|
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-warning { color: var(--warning); }
|
||||||
.text-danger { color: var(--danger); }
|
.text-danger { color: var(--danger); }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1100px) {
|
||||||
.progress-selected-total { align-items: flex-start; text-align: left; }
|
.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) {
|
@media (max-width: 640px) {
|
||||||
.progress-stage-grid { grid-template-columns: repeat(2, 1fr); }
|
.progress-header-stats { width: 100%; justify-content: space-between; }
|
||||||
.progress-filter-head .select { width: 100%; min-width: 0; }
|
.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; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
/* ============================================================
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue