add history

add_history_logs
ahmed.mujtaba 2026-09-02 15:33:20 +05:00
parent 3ab1dc9117
commit c7d630875c
13 changed files with 646 additions and 410 deletions

View File

@ -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:

View File

@ -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):

View File

@ -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(

View File

@ -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,
} }

View File

@ -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

View File

@ -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"),

View File

@ -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),

View File

@ -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>

View File

@ -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}`))

View File

@ -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,

View File

@ -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
? <Avatar name={actor} /> ? null
: <Badge className="b-gray">System</Badge>} : (r.actor_name || r.actor_id
? <Avatar name={actor} />
: <Badge className="b-gray">System</Badge>)}
</div> </div>
</div> </div>
) )

View File

@ -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,65 +10,193 @@ 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 (
<div className="progress-bar-wrap"> <button
<div className="progress-bar-labels"> type="button"
<span>Current stage distribution</span> className={`progress-job-row${active ? ' active' : ''}`}
<span>{job.total} unique applicants</span> 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>
<div className="progress-bar-track" role="img" aria-label="Stage distribution"> <div className="progress-job-row-sub">
{STAGES.map((stage) => { <span>
const n = job[stage.key] || 0 {[job.department, job.recruiterName || 'Unassigned'].filter(Boolean).join(' · ')}
if (!n) return null </span>
return ( <i className={`progress-status-dot tone-${statusTone(job.requisitionStatus)}`} aria-hidden="true" />
<div </div>
key={stage.key} </button>
className={`progress-bar-seg stage-${stage.tone}`} )
title={`${stage.label}: ${n}`} }
style={{ width: `${(n / base) * 100}%` }}
/> 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>
</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 <div className="progress-sidebar-meta">
columns={tableColumns} <span>{filtered.length} shown · {jobs.length} total roles</span>
rows={visibleJobs} <span>Applicants</span>
pageSize={8} </div>
empty="No job posts match this search."
/> <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>

View File

@ -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; }
} }
/* ============================================================ /* ============================================================