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}"}
async def get_event(event_id, token=None):
"""GET {base}/calendar/events/{id} -> event dict, or None on 404."""
encoded_id=quote(str(event_id),safe="")
async with httpx.AsyncClient(timeout=30.0) as client:
response=await client.get(
f"{_base_url()}/calendar/events/{encoded_id}",
headers={**_auth_headers(token),"accept":"application/json"},
)
if response.status_code==404:
return None
if response.status_code>=400:
raise httpx.HTTPStatusError(
response.text,
request=response.request,
response=response,
)
if not response.content:
return {}
return response.json()
async def create_event(payload, token=None):
"""POST {base}/calendar/events -> created event dict."""
async with httpx.AsyncClient(timeout=30.0) as client:

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:
"""Upstream calendar event -> the fields the interview row stores."""
if not isinstance(payload,dict):

View File

@ -913,6 +913,18 @@ class Interviews(SQLModel, table=True):
)
return result.scalars().first()
@classmethod
async def get_interviews_by_ids(cls, session: AsyncSession, ids):
keys=[]
for raw in ids or []:
uid=cls._as_uuid(raw)
if uid is not None:
keys.append(uid)
if not keys:
return []
result=await session.execute(select(cls).where(cls.id.in_(keys)))
return list(result.scalars().all())
@classmethod
async def get_interviews_by_inbox(cls, session: AsyncSession, inbox_id: int):
result = await session.execute(

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 {
"id": str(row.id),
"user_id": str(row.user_id) if row.user_id else None,
@ -16,5 +16,7 @@ def serialize_history(row, actor_name=None) -> dict:
"actor_name": actor_name,
"actor_kind": row.actor_kind,
"meta": row.meta,
"organizer_email": organizer_email,
"attendee_emails": attendee_emails or None,
"created_at": row.created_at.isoformat() if row.created_at else None,
}

View File

@ -1,15 +1,25 @@
import asyncio
import logging
from datetime import datetime
from sqlalchemy.ext.asyncio import AsyncSession
from inbox.models import Inbox
from job.candidate.models import CandidateHistory, Manual_UPLOAD_CANDIDATE
from job.candidate.models import CandidateHistory, Interviews, Manual_UPLOAD_CANDIDATE
from job.history.enums import HistoryEvent
from job.history.serializers import serialize_history
from users.models import Users
logger = logging.getLogger(__name__)
INTERVIEW_HISTORY_EVENTS = {
HistoryEvent.INTERVIEW_CREATED.value,
HistoryEvent.INTERVIEW_UPDATED.value,
HistoryEvent.CALENDAR_CREATED.value,
HistoryEvent.CALENDAR_RESCHEDULED.value,
HistoryEvent.CALENDAR_CANCELLED.value,
}
def _text(value):
if value is None:
@ -111,10 +121,70 @@ class HistoryRecorder:
logger.exception("candidate history rollback failed")
return None
async def _event_emails(self, event_id):
from interview.plugins import get_event
from interview.serializers import participants_from_event
try:
raw = await get_event(event_id)
except Exception:
logger.exception("calendar event fetch failed for history")
return None, []
if not raw:
return None, []
organizer, attendees = participants_from_event(raw)
org_email = (organizer or {}).get("email")
attendee_emails = [a.get("email") for a in (attendees or []) if a.get("email")]
return org_email, attendee_emails
async def _attach_outlook_emails(self, rows, items):
pairs = [
(row, item)
for row, item in zip(rows, items)
if row.event_type in INTERVIEW_HISTORY_EVENTS and row.entity_id
]
if not pairs:
return
interviews = await Interviews.get_interviews_by_ids(
self.session, {row.entity_id for row, _item in pairs}
)
event_by_interview = {
str(r.id): r.graph_event_id for r in interviews if r.graph_event_id
}
pending = []
event_ids = []
for row, item in pairs:
eid = event_by_interview.get(str(row.entity_id))
if not eid:
continue
event_ids.append(eid)
pending.append((item, eid))
ids = list(dict.fromkeys(event_ids))
if not ids:
return
sem = asyncio.Semaphore(5)
async def one(eid):
async with sem:
return eid, *(await self._event_emails(eid))
fetched = {eid: (org, atts) for eid, org, atts in await asyncio.gather(*[one(eid) for eid in ids])}
for item, eid in pending:
org_email, attendee_emails = fetched.get(eid, (None, []))
if org_email:
item["organizer_email"] = org_email
if attendee_emails:
item["attendee_emails"] = attendee_emails
async def list_for_user(self, user_id, *, limit=200, offset=0):
rows, total = await CandidateHistory.fetch_by_user(
self.session, user_id, limit=limit, offset=offset
)
actor_ids = {r.actor_id for r in rows if r.actor_id}
names = await Users.names_by_ids(self.session, actor_ids)
return [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows], total
items = [serialize_history(r, actor_name=names.get(str(r.actor_id))) for r in rows]
try:
await self._attach_outlook_emails(rows, items)
except Exception:
logger.exception("calendar participant hydrate failed for history")
return items, total

View File

@ -345,6 +345,7 @@ class JobPosts(SQLModel, table=True):
cls.location,
cls.requisition_status,
cls.current_recruiter_id,
cls.created_at,
Recruiter.name.label("recruiter_name"),
func.coalesce(stats.c.total_applicants, 0).label("total_applicants"),
func.coalesce(stats.c.shortlisting, 0).label("shortlisting"),

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:
"""One job post's live pipeline-stage counts. `row` is a mapping from fetch_job_stats."""
recruiter_id=row.get("current_recruiter_id")
created_at = row.get("created_at")
return {
"job_post_id": str(row["job_post_id"]),
"title": row["title"],
@ -94,6 +95,8 @@ def serialize_job_stats(row) -> dict:
"requisition_status": row["requisition_status"],
"current_recruiter_id": str(recruiter_id) if recruiter_id else None,
"recruiter_name": row.get("recruiter_name") or None,
# Frontend computes days-open vs client clock; no server days_open field.
"created_at": created_at.isoformat() if created_at else None,
"total_applicants": int(row["total_applicants"] or 0),
"shortlisting": int(row["shortlisting"] or 0),
"screened": int(row["screened"] or 0),

View File

@ -24,8 +24,8 @@
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Belleza&family=Inter:wght@400;500;600;700;800&family=Inter+Tight:wght@500;600;700&display=swap" />
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><rect width='100' height='100' rx='22' fill='%23004d43'/><g transform='translate(14 32) scale(0.72)'><path d='M100 3.65C97.86 20.99 91.89 43.03 79.48 55.33 76 58.77 71.84 61.46 66.96 62.14 50.4 64.46 41.84 47.5 29.07 42.7 21.85 39.98 14.5 42.02 9.66 47.95 6.54 51.78 4.49 56.35 2.97 61.13 2.41 61.64 0.97 61.66 0 61.31L0 0.13C1.05 0 2.27 0.02 3.09 0.28 14.9 15.86 26.77 30.82 40.15 45.28L60.79 24.7C67.38 18.22 74.41 12.74 82.59 8.51 88.11 5.83 93.64 3.93 100 3.65Z' fill='%23ceff71'/></g></svg>" />
<script type="module" crossorigin src="/assets/index-CsFrnWUK.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-C6biZ8qR.css">
<script type="module" crossorigin src="/assets/index-CLFuERsT.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-CuQsXTK_.css">
</head>
<body>
<div id="root"></div>

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.
const MODULES = ['dashboard', 'inbox', 'jobs', 'candidates', 'pipeline', 'interviews', 'assessments',
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent']
'offers', 'reports', 'analytics', 'job_board', 'settings', 'rbac_users', 'tasks', 'talent',
'requisitions']
const ACTIONS = ['view', 'create', 'edit', 'delete', 'approve', 'export', 'manage', 'configure']
const permissions = MODULES.flatMap((m) => ACTIONS.map((a) => `${m}.${a}`))

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. */
export function toJobStatsView(row) {
const createdAt = row.created_at || null
return {
id: row.job_post_id,
title: row.title || 'Untitled role',
@ -38,6 +47,8 @@ export function toJobStatsView(row) {
requisitionStatus: row.requisition_status,
recruiterId: row.current_recruiter_id || null,
recruiterName: row.recruiter_name || null,
createdAt,
daysOpen: daysOpen(createdAt),
total: Number(row.total_applicants) || 0,
shortlist: Number(row.shortlisting) || 0,
screened: Number(row.screened) || 0,

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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
@ -836,14 +814,22 @@ function HistoryTab({ userId }) {
)
}
const INTERVIEW_HISTORY_TYPES = new Set([
'interview.created', 'interview.updated',
'calendar.created', 'calendar.rescheduled', 'calendar.cancelled',
])
function HistoryRow({ row: r }) {
const look = HISTORY_ICON[r.event_type] || { icon: 'clock', tone: 'i-blue' }
const title = HISTORY_TITLE[r.event_type] || r.event_type
const change = [r.from_value, r.to_value].some((v) => v != null && v !== '')
? `${r.from_value ?? '—'}${r.to_value ?? '—'}`
: null
const isInterview = INTERVIEW_HISTORY_TYPES.has(r.event_type)
const actor = r.actor_name || (r.actor_id ? 'Unknown' : 'System')
const when = [fmtWhen(r.created_at), fmtClock(r.created_at)].filter(Boolean).join(' · ')
const organizerEmail = r.organizer_email || null
const attendeeEmails = Array.isArray(r.attendee_emails) ? r.attendee_emails.filter(Boolean) : []
return (
<div className="list-row">
<span className={`kpi-icn ${look.tone}`} style={{ width: 38, height: 38, borderRadius: 10 }}>
@ -853,12 +839,20 @@ function HistoryRow({ row: r }) {
<div className="lr-title">{title}</div>
{change && <div className="lr-sub">{change}</div>}
{r.description && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>{r.description}</div>}
<div className="lr-sub">{actor} · {when}</div>
{organizerEmail && <div className="lr-sub" style={{ color: 'var(--text-2)' }}>Organizer: {organizerEmail}</div>}
{attendeeEmails.length > 0 && (
<div className="lr-sub" style={{ color: 'var(--text-2)' }}>
Attendees: {attendeeEmails.join(', ')}
</div>
)}
<div className="lr-sub">{isInterview ? when : `${actor} · ${when}`}</div>
</div>
<div className="lr-right">
{r.actor_name || r.actor_id
? <Avatar name={actor} />
: <Badge className="b-gray">System</Badge>}
{isInterview
? null
: (r.actor_name || r.actor_id
? <Avatar name={actor} />
: <Badge className="b-gray">System</Badge>)}
</div>
</div>
)

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).
Counts are unique applicants by email; recruiter comes from
job_posts.current_recruiter_id.
Left: searchable/filterable job index (paged). Right: selected job
pipeline breakdown. Days-open is computed client-side from created_at.
============================================================ */
import { useEffect, useMemo, useState } from 'react'
@ -11,65 +10,193 @@ import { useSearchParams } from 'react-router-dom'
import { useQuery } from '@tanstack/react-query'
import PageHeader from '../ui/PageHeader'
import DataTable from '../ui/DataTable'
import { Badge, EmptyState, Icon, KpiCard, SkeletonRows } from '../ui/primitives'
import { Badge, EmptyState, Icon, SkeletonRows } from '../ui/primitives'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as jobStatsApi from '../api/jobStats'
const PAGE_SIZE = 10
const STAGES = [
{ key: 'shortlist', label: 'Shortlisted', icon: 'star', tone: 'blue' },
{ key: 'screened', label: 'Screened', icon: 'eye', tone: 'purple' },
{ key: 'assessment', label: 'Assessment', icon: 'check-square', tone: 'amber' },
{ key: 'interviewed', label: 'Interviewed', icon: 'calendar', tone: 'indigo' },
{ key: 'offered', label: 'Offered', icon: 'send', tone: 'teal' },
{ key: 'onHold', label: 'On Hold', icon: 'clock', tone: 'amber' },
{ key: 'rejected', label: 'Rejected', icon: 'x-circle', tone: 'red' },
{ key: 'shortlist', label: 'Shortlisted', tone: 'blue' },
{ key: 'screened', label: 'Screened', tone: 'purple' },
{ key: 'assessment', label: 'Assessment', tone: 'amber' },
{ key: 'interviewed', label: 'Interviewed', tone: 'indigo' },
{ key: 'offered', label: 'Offered', tone: 'teal' },
{ key: 'onHold', label: 'On hold', tone: 'amber' },
{ key: 'rejected', label: 'Rejected', tone: 'red' },
{ key: 'hired', label: 'Hired', tone: 'teal' },
]
function sumField(jobs, key) {
return jobs.reduce((total, job) => total + (Number(job[key]) || 0), 0)
}
function StageTile({ stage, value, total }) {
const pct = total ? Math.round((value / total) * 100) : 0
function statusTone(status) {
const value = String(status || '').toLowerCase()
if (value === 'open') return 'success'
if (value === 'on_hold' || value === 'on hold') return 'warning'
return 'muted'
}
function Metric({ value, label, accent = false }) {
return (
<div className={`progress-stage stage-${stage.tone}`}>
<div className="progress-stage-head">
<span className="progress-stage-icon"><Icon name={stage.icon} /></span>
<span className="progress-stage-label">{stage.label}</span>
<span className="progress-stage-pct">{pct}%</span>
</div>
<strong className="progress-stage-value">{value}</strong>
<div className="progress-stage-meter" aria-hidden="true">
<i style={{ width: `${pct}%` }} />
</div>
<div className="progress-metric">
<strong className={accent ? 'accent' : undefined}>{value}</strong>
<span>{label}</span>
</div>
)
}
function StageBar({ job }) {
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
const base = Math.max(used, job.total, 1)
function JobRow({ job, active, onSelect }) {
return (
<div className="progress-bar-wrap">
<div className="progress-bar-labels">
<span>Current stage distribution</span>
<span>{job.total} unique applicants</span>
<button
type="button"
className={`progress-job-row${active ? ' active' : ''}`}
onClick={onSelect}
aria-current={active ? 'true' : undefined}
>
<div className="progress-job-row-top">
<span className="progress-job-title">{job.title}</span>
<span className="progress-job-count">{job.total.toLocaleString()}</span>
</div>
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
{STAGES.map((stage) => {
const n = job[stage.key] || 0
if (!n) return null
return (
<div
key={stage.key}
className={`progress-bar-seg stage-${stage.tone}`}
title={`${stage.label}: ${n}`}
style={{ width: `${(n / base) * 100}%` }}
/>
)
})}
<div className="progress-job-row-sub">
<span>
{[job.department, job.recruiterName || 'Unassigned'].filter(Boolean).join(' · ')}
</span>
<i className={`progress-status-dot tone-${statusTone(job.requisitionStatus)}`} aria-hidden="true" />
</div>
</button>
)
}
function StageRow({ job, stage }) {
const count = job[stage.key] || 0
const pct = job.total ? Math.round((count / job.total) * 100) : 0
return (
<div className="progress-stage-row">
<div className="progress-stage-row-label">
<i className={`progress-stage-dot stage-${stage.tone}`} aria-hidden="true" />
<span>{stage.label}</span>
</div>
<div className="progress-stage-row-meter" aria-hidden="true">
<i className={`stage-${stage.tone}`} style={{ width: `${pct}%` }} />
</div>
<strong>{count.toLocaleString()}</strong>
<span className="progress-stage-row-pct">{pct}%</span>
</div>
)
}
function JobDetail({ job }) {
const interviewRate = job.total ? Math.round((job.interviewed / job.total) * 100) : null
const offerRate = job.offered ? Math.round((job.hired / job.offered) * 100) : null
const active = job.shortlist + job.screened + job.assessment + job.interviewed
const used = STAGES.reduce((n, stage) => n + (job[stage.key] || 0), 0)
const barBase = Math.max(used, job.total, 1)
return (
<div className="progress-detail">
<div className="progress-detail-head">
<div>
<div className="progress-detail-tags">
{job.department && <span className="progress-eyebrow">{job.department}</span>}
<Badge className={`b-${statusTone(job.requisitionStatus) === 'success' ? 'green' : statusTone(job.requisitionStatus) === 'warning' ? 'amber' : 'gray'}`}>
{job.status}
</Badge>
</div>
<h2>{job.title}</h2>
<p className="progress-meta">
{job.location && <span><Icon name="map" /> {job.location}</span>}
<span>
Recruiter ·{' '}
{job.recruiterName
? <b>{job.recruiterName}</b>
: <span className="text-muted">Unassigned</span>}
</span>
{job.daysOpen != null && (
<span>Open {job.daysOpen} day{job.daysOpen === 1 ? '' : 's'}</span>
)}
</p>
</div>
<div className="progress-selected-total">
<strong>{job.total.toLocaleString()}</strong>
<span>unique applicants</span>
</div>
</div>
<div className="progress-bar-wrap">
<div className="progress-bar-labels">
<span>Candidate distribution by current stage</span>
<span>{job.total.toLocaleString()} applicants</span>
</div>
<div className="progress-bar-track" role="img" aria-label="Stage distribution">
{STAGES.map((stage) => {
const n = job[stage.key] || 0
if (!n) return null
return (
<div
key={stage.key}
className={`progress-bar-seg stage-${stage.tone}`}
title={`${stage.label}: ${n}`}
style={{ width: `${(n / barBase) * 100}%` }}
/>
)
})}
</div>
</div>
<div className="progress-breakdown">
<div className="progress-section-head">
<h3>Pipeline breakdown</h3>
<span>Count · share of applicants</span>
</div>
<div className="progress-stage-rows">
{STAGES.map((stage) => (
<StageRow key={stage.key} job={job} stage={stage} />
))}
</div>
</div>
<div className="progress-metric-grid">
<Metric value={active.toLocaleString()} label="Active pipeline" />
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
<Metric value={job.offered.toLocaleString()} label="Offers made" accent />
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
</div>
<div className="progress-bottom-grid">
<div>
<h3>Attention needed</h3>
<div className="progress-attention-list">
<div className="progress-attention-item">
<span>Candidates on hold</span>
<strong className="text-warning">{job.onHold}</strong>
</div>
<div className="progress-attention-item">
<span>Job aging</span>
<strong>
{job.daysOpen == null ? '—' : `${job.daysOpen} day${job.daysOpen === 1 ? '' : 's'}`}
</strong>
</div>
<div className="progress-attention-item">
<span>Rejected</span>
<strong className="text-danger">{job.rejected}</strong>
</div>
</div>
</div>
<div className="card progress-outcome-card">
<div className="card-body">
<h3>Hiring outcome</h3>
<div className="progress-outcome-grid">
<Metric value={job.offered} label="Offers" />
<Metric value={job.hired} label="Hired" accent />
<Metric value={interviewRate == null ? '—' : `${interviewRate}%`} label="Interview rate" />
<Metric value={offerRate == null ? '—' : `${offerRate}%`} label="Offer-to-hire" />
</div>
</div>
</div>
</div>
</div>
)
@ -79,9 +206,10 @@ export default function Progress() {
const [searchParams, setSearchParams] = useSearchParams()
const deepLinkJobId = searchParams.get('job') || ''
const [tab, setTab] = useState('overview')
const [selectedId, setSelectedId] = useState(deepLinkJobId)
const [query, setQuery] = useState('')
const [status, setStatus] = useState('all')
const [page, setPage] = useState(0)
const statsQuery = useQuery({
queryKey: qk.jobs.stats({ top: 500, skip: 0 }),
@ -94,6 +222,39 @@ export default function Progress() {
const jobs = statsQuery.data ?? []
const filtered = useMemo(() => {
const term = query.trim().toLowerCase()
return jobs
.filter((job) => {
if (status !== 'all' && String(job.requisitionStatus || '').toLowerCase() !== status) {
return false
}
if (!term) return true
return (
job.title.toLowerCase().includes(term)
|| (job.department || '').toLowerCase().includes(term)
|| (job.location || '').toLowerCase().includes(term)
|| (job.recruiterName || '').toLowerCase().includes(term)
)
})
.sort((a, b) => (b.total - a.total) || a.title.localeCompare(b.title))
}, [jobs, query, status])
const pageCount = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE))
const safePage = Math.min(page, pageCount - 1)
const pageRows = filtered.slice(safePage * PAGE_SIZE, safePage * PAGE_SIZE + PAGE_SIZE)
useEffect(() => {
setPage(0)
}, [query, status])
// Deep-link: once jobs load, jump the sidebar page to that role.
useEffect(() => {
if (!deepLinkJobId || !filtered.length) return
const idx = filtered.findIndex((j) => String(j.id) === String(deepLinkJobId))
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
}, [deepLinkJobId, filtered])
useEffect(() => {
if (!jobs.length) {
setSelectedId('')
@ -102,7 +263,6 @@ export default function Progress() {
const matchId = (id) => jobs.some((j) => String(j.id) === String(id))
if (deepLinkJobId && matchId(deepLinkJobId)) {
setSelectedId(String(deepLinkJobId))
setTab('overview')
return
}
if (!selectedId || !matchId(selectedId)) {
@ -113,7 +273,8 @@ export default function Progress() {
const selectJob = (id) => {
const next = String(id || '')
setSelectedId(next)
setTab('overview')
const idx = filtered.findIndex((j) => String(j.id) === next)
if (idx >= 0) setPage(Math.floor(idx / PAGE_SIZE))
setSearchParams((prev) => {
const nextParams = new URLSearchParams(prev)
if (next) nextParams.set('job', next)
@ -122,102 +283,32 @@ export default function Progress() {
}, { replace: true })
}
const selected = jobs.find((j) => String(j.id) === String(selectedId)) || jobs[0] || null
const visibleJobs = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return jobs
return jobs.filter((job) => (
job.title.toLowerCase().includes(q)
|| (job.department || '').toLowerCase().includes(q)
|| (job.location || '').toLowerCase().includes(q)
|| (job.recruiterName || '').toLowerCase().includes(q)
))
}, [jobs, query])
const selected = jobs.find((j) => String(j.id) === String(selectedId))
|| filtered[0]
|| jobs[0]
|| null
const totalApplicants = sumField(jobs, 'total')
const activePipeline = sumField(jobs, 'shortlist')
+ sumField(jobs, 'screened')
+ sumField(jobs, 'assessment')
+ sumField(jobs, 'interviewed')
const tableColumns = [
{
key: 'title',
label: 'Job post',
sortable: true,
render: (j) => (
<div>
<div className="cell-primary">{j.title}</div>
<div className="cell-sub">
{[j.department, j.location].filter(Boolean).join(' · ') || '—'}
</div>
</div>
),
},
{
key: 'recruiterName',
label: 'Recruiter',
sortable: true,
render: (j) => (
j.recruiterName
? <b>{j.recruiterName}</b>
: <span className="text-muted">Unassigned</span>
),
},
{
key: 'status',
label: 'Status',
sortable: true,
render: (j) => <Badge>{j.status}</Badge>,
},
{ key: 'total', label: 'Applicants', sortable: true, align: 'right', render: (j) => <b>{j.total}</b> },
{ key: 'shortlist', label: 'Shortlisted', sortable: true, align: 'right' },
{ key: 'screened', label: 'Screened', sortable: true, align: 'right' },
{ key: 'interviewed', label: 'Interviewed', sortable: true, align: 'right' },
{ key: 'offered', label: 'Offered', sortable: true, align: 'right' },
{
key: 'onHold',
label: 'On hold',
sortable: true,
align: 'right',
render: (j) => <span className="text-warning">{j.onHold}</span>,
},
{
key: 'rejected',
label: 'Rejected',
sortable: true,
align: 'right',
render: (j) => <span className="text-danger">{j.rejected}</span>,
},
]
const activeRoles = jobs.filter((j) => String(j.requisitionStatus || '').toLowerCase() === 'open').length
const rangeStart = filtered.length ? safePage * PAGE_SIZE + 1 : 0
const rangeEnd = Math.min(filtered.length, safePage * PAGE_SIZE + PAGE_SIZE)
return (
<div className="page progress-page">
<PageHeader
title="Progress"
sub="Candidate progress across every job post, at a glance"
sub="One operational view for high-volume recruiting"
actions={(
<div className="progress-header-stats">
<Metric value={jobs.length.toLocaleString()} label="job posts" />
<Metric value={totalApplicants.toLocaleString()} label="unique applicants" />
<Metric value={activeRoles.toLocaleString()} label="active roles" accent />
</div>
)}
/>
<div className="tabs" role="tablist" aria-label="Progress views">
<button
type="button"
className={`tab ${tab === 'overview' ? 'active' : ''}`}
onClick={() => setTab('overview')}
>
<Icon name="dashboard" /> Overview
</button>
<button
type="button"
className={`tab ${tab === 'jobs' ? 'active' : ''}`}
onClick={() => setTab('jobs')}
>
<Icon name="briefcase" /> All job posts
</button>
</div>
{statsQuery.isPending && (
<div className="card"><div className="card-body"><SkeletonRows rows={6} /></div></div>
<div className="card"><div className="card-body"><SkeletonRows rows={8} /></div></div>
)}
{statsQuery.isError && (
@ -237,194 +328,89 @@ export default function Progress() {
</div></div>
)}
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'overview' && selected && (
<>
<div className="grid g-kpi mb-18">
<KpiCard
icon="users"
tone="i-indigo"
label="Total applicants"
value={totalApplicants}
foot={`Across ${jobs.length} job post${jobs.length === 1 ? '' : 's'}`}
/>
<KpiCard
icon="pipeline"
tone="i-purple"
label="Active pipeline"
value={activePipeline}
foot="Currently progressing"
/>
<KpiCard
icon="calendar"
tone="i-blue"
label="Interviewed"
value={sumField(jobs, 'interviewed')}
foot="Candidate interviews"
/>
<KpiCard
icon="send"
tone="i-green"
label="Offers made"
value={sumField(jobs, 'offered')}
foot={`${sumField(jobs, 'hired')} candidates hired`}
/>
</div>
<div className="card mb-18">
<div className="card-head progress-filter-head">
<div>
<h3>Pipeline at a glance</h3>
<span className="ch-sub">Select a job post to inspect its current candidate distribution</span>
</div>
<select
className="select"
value={selectedId}
onChange={(e) => selectJob(e.target.value)}
aria-label="Select job post"
>
{jobs.map((job) => (
<option key={job.id} value={String(job.id)}>{job.title}</option>
))}
</select>
</div>
<div className="card-body">
<div className="progress-selected">
<div>
{selected.department && (
<span className="progress-eyebrow">{selected.department}</span>
)}
<h2>{selected.title}</h2>
<p className="progress-meta">
{selected.location && (
<span><Icon name="map" /> {selected.location}</span>
)}
<span>
Recruiter ·{' '}
{selected.recruiterName
? <b>{selected.recruiterName}</b>
: <span className="text-muted">Unassigned</span>}
</span>
</p>
</div>
<div className="progress-selected-total">
<strong>{selected.total}</strong>
<span>unique applicants</span>
</div>
<Badge>{selected.status}</Badge>
</div>
<div className="progress-stage-grid">
{STAGES.map((stage) => (
<StageTile
key={stage.key}
stage={stage}
value={selected[stage.key] || 0}
total={selected.total}
/>
))}
</div>
<StageBar job={selected} />
</div>
</div>
<div className="grid g-2">
<div className="card">
<div className="card-head">
<div>
<h3>Attention needed</h3>
<span className="ch-sub">Where attention is needed</span>
</div>
</div>
<div className="card-body progress-health">
<div>
<span className="progress-health-icon i-amber"><Icon name="clock" /></span>
<div>
<strong>{selected.onHold} candidates on hold</strong>
<span>Review before the next hiring round</span>
</div>
</div>
<div>
<span className="progress-health-icon i-red"><Icon name="x-circle" /></span>
<div>
<strong>{selected.rejected} rejected</strong>
<span>
{selected.total
? `${Math.round((selected.rejected / selected.total) * 100)}% of total applicants`
: 'No applicants yet'}
</span>
</div>
</div>
</div>
</div>
<div className="card">
<div className="card-head">
<div>
<h3>Hiring outcome</h3>
<span className="ch-sub">Selected job post</span>
</div>
<Badge>{selected.status}</Badge>
</div>
<div className="card-body">
<div className="info-grid">
<div className="info-item">
<div className="il">Offers</div>
<div className="iv">{selected.offered}</div>
</div>
<div className="info-item">
<div className="il">Hired</div>
<div className="iv">{selected.hired}</div>
</div>
<div className="info-item">
<div className="il">Interview rate</div>
<div className="iv">
{selected.total
? `${Math.round((selected.interviewed / selected.total) * 100)}%`
: '—'}
</div>
</div>
<div className="info-item">
<div className="il">Offer-to-hire</div>
<div className="iv">
{selected.offered
? `${Math.round((selected.hired / selected.offered) * 100)}%`
: '—'}
</div>
</div>
</div>
</div>
</div>
</div>
</>
)}
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && tab === 'jobs' && (
<div className="card">
<div className="card-head">
<div>
<h3>All job posts</h3>
<span className="ch-sub">{visibleJobs.length} role{visibleJobs.length === 1 ? '' : 's'}</span>
</div>
</div>
<div className="card-body" style={{ paddingBottom: 0 }}>
<div className="toolbar">
{!statsQuery.isPending && !statsQuery.isError && jobs.length > 0 && (
<div className="progress-shell">
<aside className="progress-sidebar">
<div className="progress-sidebar-filters">
<div className="toolbar-search">
<Icon name="search" />
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search jobs, teams, recruiters…"
aria-label="Search job posts"
/>
</div>
<div className="progress-sidebar-controls">
<select
className="select"
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label="Filter by status"
>
<option value="all">All statuses</option>
<option value="open">Open</option>
<option value="on_hold">On hold</option>
<option value="closed">Closed</option>
</select>
<span className="progress-sort-chip">Sort: applicants</span>
</div>
</div>
</div>
<DataTable
columns={tableColumns}
rows={visibleJobs}
pageSize={8}
empty="No job posts match this search."
/>
<div className="progress-sidebar-meta">
<span>{filtered.length} shown · {jobs.length} total roles</span>
<span>Applicants</span>
</div>
<div className="progress-job-list">
{pageRows.length === 0 ? (
<div className="progress-sidebar-empty">No jobs match this filter.</div>
) : (
pageRows.map((job) => (
<JobRow
key={job.id}
job={job}
active={String(job.id) === String(selected?.id)}
onSelect={() => selectJob(job.id)}
/>
))
)}
</div>
<div className="progress-sidebar-pager">
<span>
{rangeStart}{rangeEnd} of {filtered.length}
</span>
<div className="progress-pager-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={safePage <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Previous
</button>
<button
type="button"
className="btn btn-secondary btn-sm"
disabled={safePage >= pageCount - 1}
onClick={() => setPage((p) => Math.min(pageCount - 1, p + 1))}
>
Next
</button>
</div>
</div>
</aside>
<main className="progress-main">
{selected
? <JobDetail job={selected} />
: (
<EmptyState icon="briefcase" title="Select a job post">
Choose a role from the list to inspect its pipeline.
</EmptyState>
)}
</main>
</div>
)}
</div>

View File

@ -1753,19 +1753,96 @@ canvas { width: 100%; max-width: 100%; display: block; }
.hf-cta { padding: 11px 26px; font-size: 14.5px; }
/* ================= PROGRESS (job-post stage overview) ================= */
.progress-filter-head { flex-wrap: wrap; }
.progress-filter-head .select { min-width: 240px; }
.progress-selected {
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px;
flex-wrap: wrap; margin-bottom: 18px;
/* ---------- Progress (master / detail) ---------- */
.progress-header-stats {
display: flex; gap: 24px; align-items: flex-end; flex-wrap: wrap;
}
.progress-metric strong {
display: block; font-family: var(--font-display); font-size: 20px; font-weight: 600;
letter-spacing: -.02em; line-height: 1.1; color: var(--text);
}
.progress-metric strong.accent { color: var(--primary); }
.progress-metric span {
display: block; margin-top: 5px; font-size: var(--fs-sm); color: var(--text-2);
}
.progress-shell {
display: grid; grid-template-columns: minmax(280px, 32%) minmax(0, 1fr);
min-height: 690px; border: 1px solid var(--border); border-radius: var(--radius-lg);
overflow: hidden; background: var(--bg-elev);
}
.progress-sidebar {
display: flex; flex-direction: column; min-width: 0;
border-right: 1px solid var(--border); background: var(--bg);
}
.progress-sidebar-filters {
padding: 14px; border-bottom: 1px solid var(--border); display: flex; flex-direction: column; gap: 9px;
}
.progress-sidebar-filters .toolbar-search { max-width: none; min-width: 0; }
.progress-sidebar-controls { display: flex; gap: 8px; align-items: center; }
.progress-sidebar-controls .select { flex: 1; min-width: 0; }
.progress-sort-chip {
flex: 0 0 auto; padding: 8px 12px; border-radius: 9px; border: 1px solid var(--border-strong);
background: var(--bg-elev); color: var(--text); font-size: var(--fs-sm); font-weight: 500;
white-space: nowrap;
}
.progress-sidebar-meta {
display: flex; justify-content: space-between; gap: 12px;
padding: 9px 14px; border-bottom: 1px solid var(--border);
font-size: var(--fs-sm); color: var(--text-2);
}
.progress-job-list { flex: 1; overflow-y: auto; min-height: 0; max-height: 528px; }
.progress-sidebar-empty {
padding: 28px 16px; text-align: center; color: var(--text-3); font-size: var(--fs-sm);
}
.progress-job-row {
width: 100%; border: 0; border-bottom: 1px solid var(--border);
border-left: 3px solid transparent; background: transparent; color: var(--text);
padding: 12px 12px 12px 13px; text-align: left; cursor: pointer; font: inherit;
}
.progress-job-row:hover { background: var(--bg-sunken); }
.progress-job-row.active {
background: var(--primary-soft); border-left-color: var(--primary);
}
.progress-job-row-top,
.progress-job-row-sub {
display: flex; align-items: center; gap: 8px;
}
.progress-job-row-sub { margin-top: 5px; font-size: var(--fs-sm); color: var(--text-2); }
.progress-job-row-sub > span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.progress-job-title {
flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
font-weight: 600; font-size: var(--fs-base); color: var(--text);
}
.progress-job-count {
flex: 0 0 auto; font-family: var(--font-display); font-weight: 600; color: var(--text);
}
.progress-status-dot {
width: 7px; height: 7px; border-radius: 99px; flex: 0 0 auto; margin-left: auto;
background: var(--text-3);
}
.progress-status-dot.tone-success { background: var(--success); }
.progress-status-dot.tone-warning { background: var(--warning); }
.progress-sidebar-pager {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 12px; border-top: 1px solid var(--border); font-size: var(--fs-sm); color: var(--text-2);
}
.progress-pager-actions { display: flex; gap: 8px; }
.progress-main { padding: 20px; min-width: 0; color: var(--text); }
.progress-detail { display: flex; flex-direction: column; gap: 20px; }
.progress-detail-head {
display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.progress-detail-tags { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 6px; }
.progress-eyebrow {
display: inline-block; font-size: var(--fs-xs); font-weight: 600; letter-spacing: .04em;
text-transform: uppercase; color: var(--primary); margin-bottom: 4px;
text-transform: uppercase; color: var(--primary);
}
.progress-selected h2 {
.progress-detail h2 {
font-family: var(--font-display); font-size: var(--fs-xl); font-weight: 600;
letter-spacing: -.02em; margin: 0 0 6px;
letter-spacing: -.02em; margin: 0 0 6px; color: var(--text);
}
.progress-meta {
display: flex; flex-wrap: wrap; gap: 14px; align-items: center;
@ -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-selected-total {
display: flex; flex-direction: column; align-items: center; text-align: center;
min-width: 110px;
display: flex; flex-direction: column; align-items: center; text-align: center; min-width: 110px;
}
.progress-selected-total strong {
display: block; font-family: var(--font-display); font-size: 32px; font-weight: 600;
letter-spacing: -.02em; line-height: 1;
display: block; font-family: var(--font-display); font-size: 28px; font-weight: 600;
letter-spacing: -.02em; line-height: 1; color: var(--text);
}
.progress-selected-total span {
display: block; font-size: var(--fs-xs); color: var(--text-3); margin-top: 4px;
display: block; font-size: var(--fs-xs); color: var(--text-2); margin-top: 4px;
}
.progress-stage-grid {
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-wrap { margin-top: 0; }
.progress-bar-labels {
display: flex; justify-content: space-between; gap: 12px;
font-size: var(--fs-sm); color: var(--text-2); margin-bottom: 8px;
@ -1841,23 +1878,90 @@ canvas { width: 100%; max-width: 100%; display: block; }
.progress-bar-seg.stage-teal { background: var(--teal); }
.progress-bar-seg.stage-red { background: var(--danger); }
.progress-health { display: flex; flex-direction: column; gap: 16px; }
.progress-health > div { display: flex; align-items: flex-start; gap: 12px; }
.progress-health-icon {
width: 36px; height: 36px; border-radius: 10px; display: grid; place-items: center; flex: 0 0 auto;
.progress-section-head,
.progress-detail h3 {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
margin: 0; font-size: var(--fs-md); font-weight: 600; color: var(--text);
}
.progress-section-head span { font-size: var(--fs-sm); font-weight: 400; color: var(--text-2); }
.progress-stage-rows { margin-top: 8px; }
.progress-stage-row {
display: grid; grid-template-columns: 118px minmax(120px, 1fr) 56px 42px;
gap: 12px; align-items: center; padding: 9px 0;
}
.progress-stage-row-label {
display: flex; align-items: center; gap: 8px; min-width: 0;
font-size: var(--fs-sm); color: var(--text);
}
.progress-stage-dot {
width: 8px; height: 8px; border-radius: 99px; flex: 0 0 auto;
}
.progress-stage-dot.stage-blue { background: var(--info); }
.progress-stage-dot.stage-purple { background: var(--purple); }
.progress-stage-dot.stage-amber { background: var(--warning); }
.progress-stage-dot.stage-indigo { background: var(--primary); }
.progress-stage-dot.stage-teal { background: var(--teal); }
.progress-stage-dot.stage-red { background: var(--danger); }
.progress-stage-row-meter {
height: 8px; border-radius: 99px; background: var(--bg-sunken); overflow: hidden;
}
.progress-stage-row-meter > i {
display: block; height: 100%; min-width: 0; border-radius: 99px;
}
.progress-stage-row-meter > i.stage-blue { background: var(--info); }
.progress-stage-row-meter > i.stage-purple { background: var(--purple); }
.progress-stage-row-meter > i.stage-amber { background: var(--warning); }
.progress-stage-row-meter > i.stage-indigo { background: var(--primary); }
.progress-stage-row-meter > i.stage-teal { background: var(--teal); }
.progress-stage-row-meter > i.stage-red { background: var(--danger); }
.progress-stage-row strong {
text-align: right; font-family: var(--font-display); font-weight: 600; color: var(--text);
}
.progress-stage-row-pct { text-align: right; font-size: var(--fs-sm); color: var(--text-2); }
.progress-metric-grid {
display: grid; grid-template-columns: repeat(4, minmax(110px, 1fr));
gap: 1px; background: var(--border); border: 1px solid var(--border);
border-radius: var(--radius); overflow: hidden;
}
.progress-metric-grid > .progress-metric {
padding: 14px; background: var(--bg-elev);
}
.progress-bottom-grid {
display: grid; grid-template-columns: minmax(240px, 1fr) minmax(240px, 1fr); gap: 16px;
}
.progress-attention-list { display: flex; flex-direction: column; gap: 10px; margin-top: 10px; }
.progress-attention-item {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 12px; border-radius: 7px; background: var(--bg-sunken);
font-size: var(--fs-sm); color: var(--text-2);
}
.progress-attention-item strong {
font-family: var(--font-display); font-size: var(--fs-base); color: var(--text);
}
.progress-outcome-card { margin: 0; }
.progress-outcome-card h3 { margin-bottom: 14px; }
.progress-outcome-grid {
display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px;
}
.progress-health-icon svg { width: 18px; height: 18px; }
.progress-health strong { display: block; font-size: var(--fs-base); margin-bottom: 2px; }
.progress-health span { font-size: var(--fs-sm); color: var(--text-3); }
.text-warning { color: var(--warning); }
.text-danger { color: var(--danger); }
@media (max-width: 900px) {
.progress-selected-total { align-items: flex-start; text-align: left; }
@media (max-width: 1100px) {
.progress-shell { grid-template-columns: 1fr; min-height: 0; }
.progress-sidebar { border-right: 0; border-bottom: 1px solid var(--border); }
.progress-job-list { max-height: 320px; }
.progress-metric-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.progress-bottom-grid { grid-template-columns: 1fr; }
}
@media (max-width: 640px) {
.progress-stage-grid { grid-template-columns: repeat(2, 1fr); }
.progress-filter-head .select { width: 100%; min-width: 0; }
.progress-header-stats { width: 100%; justify-content: space-between; }
.progress-stage-row { grid-template-columns: 96px minmax(80px, 1fr) 44px 36px; gap: 8px; }
.progress-selected-total { align-items: flex-start; text-align: left; }
.progress-sidebar-controls { flex-wrap: wrap; }
.progress-sort-chip { width: 100%; text-align: center; }
}
/* ============================================================