Dashboard redesign: org-wide activity summary with applications-per-job #44
|
|
@ -96,6 +96,26 @@ async def fetch_source_performance(
|
|||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.get("/analytics/applications-per-job/fetch")
|
||||
async def fetch_applications_per_job(
|
||||
current_user: dict = Depends(require_permission(PermissionTag.ANALYTICS_VIEW)),
|
||||
top: int = Query(10,ge=1),
|
||||
from_date: datetime | None = Query(None),
|
||||
to_date: datetime | None = Query(None),
|
||||
department: str | None = Query(None),
|
||||
recruiter_id: str | None = Query(None),
|
||||
session: AsyncSession = Depends(get_session),
|
||||
):
|
||||
try:
|
||||
service=Analytics(session=session)
|
||||
data=await service.get_applications_per_job(top,from_date,to_date,department,recruiter_id)
|
||||
return JSONResponse(content={"data":data,"total":len(data),"status_code":200})
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500,detail=str(e))
|
||||
|
||||
|
||||
@router.post("/analytics/ask")
|
||||
async def ask(
|
||||
payload: AskRequest,
|
||||
|
|
|
|||
|
|
@ -20,6 +20,18 @@ def serialize_source_count(source,count,source_id=None,spend=0.0) -> dict:
|
|||
}
|
||||
|
||||
|
||||
def serialize_job_application_count(job,count) -> dict:
|
||||
return {
|
||||
"job_post_id": str(job.id),
|
||||
"title": job.title or "",
|
||||
"department": job.department or "",
|
||||
"requisition_status": job.requisition_status,
|
||||
"vacancies": int(job.vacancies or 0),
|
||||
"is_active": bool(job.is_active),
|
||||
"count": int(count or 0),
|
||||
}
|
||||
|
||||
|
||||
def serialize_recruiter_row(user_id,name,hires,open_reqs,avg_time_to_hire,completed=0) -> dict:
|
||||
return {
|
||||
"id": str(user_id) if user_id else None,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ from datetime import datetime,timedelta,timezone
|
|||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from analytics.serializers import (
|
||||
serialize_job_application_count,
|
||||
serialize_recruiter_row,
|
||||
serialize_source_count,
|
||||
serialize_stage_count,
|
||||
|
|
@ -54,6 +55,32 @@ def _resolve_windows(from_date,to_date):
|
|||
return from_date,to_date,prior_from,prior_to
|
||||
|
||||
|
||||
def _merge_job_counts(inbox_map,manual_map,job_rows,open_rows,top):
|
||||
"""Merge per-job counts from both sources, zero-fill open reqs, sort, cap.
|
||||
|
||||
job_rows are the posts that actually received applications — closed or even
|
||||
deleted ones stay visible, because their applications happened. open_rows
|
||||
zero-fill only open, non-deleted reqs, so the fill never resurrects a dead
|
||||
posting. Sorted by count desc then title, capped at `top`.
|
||||
"""
|
||||
counts={}
|
||||
for src in (inbox_map,manual_map):
|
||||
for job_id,n in src.items():
|
||||
counts[job_id]=counts.get(job_id,0)+int(n or 0)
|
||||
by_id={str(job.id):job for job in job_rows}
|
||||
for job in open_rows:
|
||||
key=str(job.id)
|
||||
counts.setdefault(key,0)
|
||||
by_id.setdefault(key,job)
|
||||
rows=[
|
||||
serialize_job_application_count(by_id[job_id],count)
|
||||
for job_id,count in counts.items()
|
||||
if job_id in by_id
|
||||
]
|
||||
rows.sort(key=lambda r: (-r["count"],r["title"].lower()))
|
||||
return rows[:max(1,int(top or 10))]
|
||||
|
||||
|
||||
def _month_key(dt):
|
||||
"""Normalize date_trunc / python month buckets for dict lookup."""
|
||||
if dt is None:
|
||||
|
|
@ -236,6 +263,33 @@ class Analytics:
|
|||
for stage in Candidate_application_Status
|
||||
]
|
||||
|
||||
async def get_applications_per_job(self,top=10,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
"""Applications received per job post — inbox + manual upload, the same
|
||||
two sources get_funnel counts, so these rows sum to the funnel total
|
||||
under identical filters. form_data stays excluded because the funnel
|
||||
excludes it; the two cards share a screen and must agree.
|
||||
|
||||
Dates pass through raw (no _resolve_windows), matching funnel/sources:
|
||||
the caller sends both bounds, and none means all-time.
|
||||
|
||||
Open reqs with zero applications are included on purpose — a req nobody
|
||||
applied to is the strongest signal this endpoint exists to surface.
|
||||
"""
|
||||
inbox_map=await Inbox_Messages.counts_by_job_post(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
manual_map=await Manual_UPLOAD_CANDIDATE.counts_by_job_post(
|
||||
self.session,from_date=from_date,to_date=to_date,
|
||||
department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
ids=set(inbox_map)|set(manual_map)
|
||||
job_rows=await JobPosts.get_by_ids(self.session,list(ids),active_only=False) if ids else []
|
||||
open_rows=await JobPosts.list_open_reqs(
|
||||
self.session,department=department,recruiter_id=recruiter_id,
|
||||
)
|
||||
return _merge_job_counts(inbox_map,manual_map,job_rows,open_rows,top)
|
||||
|
||||
async def get_hiring_trend(self,months=7,from_date=None,to_date=None,department=None,recruiter_id=None):
|
||||
months=max(1,int(months or 7))
|
||||
now=datetime.now(timezone.utc)
|
||||
|
|
|
|||
|
|
@ -1190,6 +1190,46 @@ class Inbox_Messages(SQLModel, table=True):
|
|||
counts[key] = int(n or 0)
|
||||
return counts
|
||||
|
||||
@classmethod
|
||||
async def counts_by_job_post(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
):
|
||||
"""Application counts per assigned job post, over the exact population
|
||||
counts_by_application_status counts — so a per-job breakdown sums back
|
||||
to the funnel's inbox half under the same filters.
|
||||
|
||||
Distinct from counts_by_job_post_ids (which feeds /jobs/fetch): that one
|
||||
counts raw inbox_messages with no role gate and no filters.
|
||||
"""
|
||||
from job.job_post.models import JobPosts
|
||||
statement = (
|
||||
select(cls.assigned_job_post_id, func.count().label("count"))
|
||||
.select_from(Inbox)
|
||||
.join(Users, Inbox.user_id == Users.id)
|
||||
.join(Roles, Users.role_id == Roles.id)
|
||||
.join(cls, Inbox.message_id == cls.id)
|
||||
.join(JobPosts, cls.assigned_job_post_id == JobPosts.id)
|
||||
.where(cls.assigned_job_post_id.is_not(None))
|
||||
.where(Roles.role_name == EnumRoles.CANDIDATE.value)
|
||||
)
|
||||
if from_date is not None:
|
||||
statement = statement.where(Inbox.created_at >= from_date)
|
||||
if to_date is not None:
|
||||
statement = statement.where(Inbox.created_at < to_date)
|
||||
if department:
|
||||
statement = statement.where(JobPosts.department == department)
|
||||
try:
|
||||
rid = uuid.UUID(str(recruiter_id)) if recruiter_id not in (None, "") else None
|
||||
except (TypeError, ValueError):
|
||||
rid = None
|
||||
if rid is not None:
|
||||
statement = statement.where(
|
||||
or_(cls.recruiter_id == rid, JobPosts.current_recruiter_id == rid)
|
||||
)
|
||||
statement = statement.group_by(cls.assigned_job_post_id)
|
||||
result = await session.execute(statement)
|
||||
return {str(job_id): int(n or 0) for job_id, n in result.all()}
|
||||
|
||||
@classmethod
|
||||
async def counts_by_source(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
|
|
|
|||
|
|
@ -214,6 +214,35 @@ class Manual_UPLOAD_CANDIDATE(SQLModel, table=True):
|
|||
counts[key]=counts.get(key,0)+int(n or 0)
|
||||
return counts
|
||||
|
||||
@classmethod
|
||||
async def counts_by_job_post(
|
||||
cls, session: AsyncSession, from_date=None, to_date=None, department=None, recruiter_id=None,
|
||||
):
|
||||
"""Per-job counts for the same manual-upload population as
|
||||
counts_by_application_status, so inbox + manual per-job sums stay equal
|
||||
to the funnel total under identical filters.
|
||||
"""
|
||||
from users.models import Users
|
||||
from job.job_post.models import JobPosts
|
||||
qry=(
|
||||
select(cls.job_post_id,func.count())
|
||||
.select_from(cls)
|
||||
.join(Users,cls.user_id==Users.id)
|
||||
.join(JobPosts,cls.job_post_id==JobPosts.id)
|
||||
)
|
||||
if from_date is not None:
|
||||
qry=qry.where(cls.created_at>=from_date)
|
||||
if to_date is not None:
|
||||
qry=qry.where(cls.created_at<to_date)
|
||||
if department:
|
||||
qry=qry.where(JobPosts.department==department)
|
||||
rid=cls._as_uuid(recruiter_id)
|
||||
if rid is not None:
|
||||
qry=qry.where(JobPosts.current_recruiter_id==rid)
|
||||
qry=qry.group_by(cls.job_post_id)
|
||||
result=await session.execute(qry)
|
||||
return {str(job_id):int(n or 0) for job_id,n in result.all()}
|
||||
|
||||
@staticmethod
|
||||
def _as_uuid(record_id) -> uuid.UUID | None:
|
||||
if record_id in (None, ""):
|
||||
|
|
|
|||
|
|
@ -262,6 +262,19 @@ class JobPosts(SQLModel, table=True):
|
|||
result = await session.execute(statement)
|
||||
return int(result.scalar_one() or 0)
|
||||
|
||||
@classmethod
|
||||
async def list_open_reqs(cls, session: AsyncSession, department=None, recruiter_id=None):
|
||||
"""Open, non-deleted requisitions — the zero-application fill for
|
||||
analytics' per-job counts. Same scoping semantics as count_requisitions.
|
||||
"""
|
||||
statement = select(cls).where(
|
||||
cls.is_deleted == False, # noqa: E712
|
||||
cls.requisition_status == RequisitionStatus.OPEN.value,
|
||||
)
|
||||
statement = cls._scoped(statement, department, recruiter_id)
|
||||
result = await session.execute(statement)
|
||||
return list(result.scalars().all())
|
||||
|
||||
@classmethod
|
||||
async def count_open_snapshot(cls, session: AsyncSession, as_of, department=None, recruiter_id=None):
|
||||
"""Jobs that existed and were still open at `as_of` (best-effort)."""
|
||||
|
|
|
|||
|
|
@ -73,6 +73,23 @@ export function sourcePerformance({ fromDate, toDate, department, recruiterId }
|
|||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Applications received per job post — inbox + manual upload, the same two
|
||||
* sources the funnel counts, so this card and the pipeline card always agree.
|
||||
* Open reqs with zero applications are included; that emptiness is the signal.
|
||||
*/
|
||||
export function applicationsPerJob({ top = 10, fromDate, toDate, department, recruiterId } = {}) {
|
||||
return request('/analytics/applications-per-job/fetch', {
|
||||
params: {
|
||||
top,
|
||||
from_date: fromDate,
|
||||
to_date: toDate,
|
||||
department,
|
||||
recruiter_id: recruiterId,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Natural-language analytics. The backend maps the question onto one whitelisted
|
||||
* analytics intent, runs the same governed query the dashboard uses, and returns
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ export const REQUISITION_STATUSES = [
|
|||
{ value: 'closed', label: 'Closed' },
|
||||
{ value: 'completed', label: 'Completed' },
|
||||
]
|
||||
const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
|
||||
export const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
|
||||
const LABEL_TO_STATUS = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.label, s.value]))
|
||||
export const JOB_STATUSES = REQUISITION_STATUSES.map((s) => s.label)
|
||||
|
||||
|
|
@ -42,6 +42,21 @@ export function listRequisitionStatuses() {
|
|||
return request('/jobs/requisition-statuses/fetch')
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct departments across all requisitions (closed included — a past
|
||||
* hiring department is a legitimate lens on a past window). The shared option
|
||||
* list for the Dashboard and Analytics department filters: both screens cache
|
||||
* it under qk.jobs.list({ scope: 'departments' }), and React Query caches on
|
||||
* the key alone, so every consumer of that key must use THIS fetcher — two
|
||||
* queryFns returning different shapes on one key overwrite each other (the
|
||||
* React error #31 failure documented in api/inbox.js).
|
||||
*/
|
||||
export async function fetchDepartmentOptions() {
|
||||
const res = await list({ top: 500, activeOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return [...new Set(rows.map((r) => r.department).filter(Boolean))].sort()
|
||||
}
|
||||
|
||||
function experienceLabel(min, max) {
|
||||
if (min == null && max == null) return null
|
||||
if (min != null && max != null) return `${min}–${max} years`
|
||||
|
|
|
|||
|
|
@ -117,6 +117,7 @@ export const qk = {
|
|||
trend: (p = {}) => ['analytics', 'trend', p],
|
||||
sources: (p = {}) => ['analytics', 'sources', p],
|
||||
recruiters: (p = {}) => ['analytics', 'recruiters', p],
|
||||
jobApps: (p = {}) => ['analytics', 'job-apps', p],
|
||||
},
|
||||
offers: { all: () => ['offers'], list: (p = {}) => ['offers', 'list', p] },
|
||||
forms: {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,27 @@
|
|||
/* ============================================================
|
||||
timeRanges.js — the Week / Month / Quarter / Year filter vocabulary shared
|
||||
by Dashboard and Analytics. One definition so the two screens can never
|
||||
disagree about what "Quarter" means.
|
||||
|
||||
rangeWindow returns FRESH ISO strings on every call — callers must memoise
|
||||
on the range key (never on the result) or their query keys churn each render
|
||||
and refetch everything.
|
||||
============================================================ */
|
||||
|
||||
export const RANGES = [
|
||||
{ key: 'week', label: 'Week', days: 7 },
|
||||
{ key: 'month', label: 'Month', days: 30 },
|
||||
{ key: 'quarter', label: 'Quarter', days: 90 },
|
||||
{ key: 'year', label: 'Year', days: 365 },
|
||||
]
|
||||
|
||||
export function rangeWindow(key) {
|
||||
const range = RANGES.find((r) => r.key === key) ?? RANGES[1]
|
||||
const to = new Date()
|
||||
const from = new Date(to.getTime() - range.days * 86400000)
|
||||
return { fromDate: from.toISOString(), toDate: to.toISOString() }
|
||||
}
|
||||
|
||||
export function rangeLabel(key) {
|
||||
return (RANGES.find((r) => r.key === key) ?? RANGES[1]).label
|
||||
}
|
||||
|
|
@ -30,10 +30,12 @@ import { useMutation, useQueries, useQuery } from '@tanstack/react-query'
|
|||
|
||||
import Chart, { ChartLegend } from '../ui/Chart'
|
||||
import Charts from '../lib/charts'
|
||||
import ChartCard from '../ui/ChartCard'
|
||||
import DataTable from '../ui/DataTable'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { EmptyState, Icon } from '../ui/primitives'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { RANGES, rangeWindow } from '../lib/timeRanges'
|
||||
import { friendlyAuthError } from '../lib/errors'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
import * as offersApi from '../api/offers'
|
||||
|
|
@ -43,20 +45,6 @@ import * as jobsApi from '../api/jobs'
|
|||
const DEPT_CAP = 12
|
||||
const TREND_MONTHS = 7
|
||||
|
||||
const RANGES = [
|
||||
{ key: 'week', label: 'Week', days: 7 },
|
||||
{ key: 'month', label: 'Month', days: 30 },
|
||||
{ key: 'quarter', label: 'Quarter', days: 90 },
|
||||
{ key: 'year', label: 'Year', days: 365 },
|
||||
]
|
||||
|
||||
function rangeWindow(key) {
|
||||
const range = RANGES.find((r) => r.key === key) ?? RANGES[1]
|
||||
const to = new Date()
|
||||
const from = new Date(to.getTime() - range.days * 86400000)
|
||||
return { fromDate: from.toISOString(), toDate: to.toISOString() }
|
||||
}
|
||||
|
||||
const INTENT_LABELS = {
|
||||
kpis: 'KPI summary',
|
||||
funnel: 'pipeline funnel',
|
||||
|
|
@ -150,28 +138,6 @@ function AskAnalyticsCard() {
|
|||
)
|
||||
}
|
||||
|
||||
/** Every chart that can render is wrapped in this, so one failing read never blanks the page. */
|
||||
function ChartCard({ title, sub, query, height = 260, permission, children, footer }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>{title}</h3>{sub && <span className="ch-sub">{sub}</span>}</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{query.isPending && <EmptyState icon="clock" title="Loading…">Fetching from the server.</EmptyState>}
|
||||
{query.isError && (
|
||||
<EmptyState icon="alert" title={`Couldn’t load ${title.toLowerCase()}`}>
|
||||
{friendlyAuthError(query.error, 'The server did not answer.')}
|
||||
{permission && <> This card needs the <code>{permission}</code> permission.</>}
|
||||
</EmptyState>
|
||||
)}
|
||||
{!query.isPending && !query.isError && children(height)}
|
||||
{!query.isPending && !query.isError && footer}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Analytics() {
|
||||
const [rangeKey, setRangeKey] = useState('month')
|
||||
const [department, setDepartment] = useState('')
|
||||
|
|
@ -231,16 +197,11 @@ export default function Analytics() {
|
|||
retry: false,
|
||||
})
|
||||
|
||||
/* Department options come from the requisition list, so the filter can only
|
||||
offer departments that exist. active_only is false: a closed requisition's
|
||||
department is still a legitimate lens on a past window. */
|
||||
/* Shared with the Dashboard under one key — the fetcher lives in api/jobs.js
|
||||
so both screens can never cache different shapes on it. */
|
||||
const deptsQuery = useQuery({
|
||||
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||||
queryFn: async () => {
|
||||
const res = await jobsApi.list({ top: 500, activeOnly: false })
|
||||
const rows = Array.isArray(res?.data) ? res.data : []
|
||||
return [...new Set(rows.map((r) => r.department).filter(Boolean))].sort()
|
||||
},
|
||||
queryFn: jobsApi.fetchDepartmentOptions,
|
||||
})
|
||||
const departments = deptsQuery.data ?? []
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,52 @@
|
|||
/* ============================================================
|
||||
Dashboard — the org-wide recruiting activity summary.
|
||||
|
||||
Redesigned from the personal-workspace version: My Tasks, Today's Schedule
|
||||
and Quick Actions moved out (they have their own tabs), and every widget now
|
||||
obeys a shared Week/Month/Quarter/Year + department filter, like Analytics.
|
||||
The two screens stay complementary on purpose — this one is the operational
|
||||
snapshot (what is happening, what needs attention), Analytics keeps the deep
|
||||
lenses (sources, recruiters, cycle time, Ask).
|
||||
|
||||
The hero is Applications per Job — /analytics/applications-per-job/fetch,
|
||||
which counts the same two sources the funnel counts (inbox + manual upload),
|
||||
so the hero rows and the pipeline card always agree. Open reqs with ZERO
|
||||
applications are rendered on purpose: an empty bar under a job title is the
|
||||
strongest signal on the page. Rendered as DOM rows, not a canvas
|
||||
horizontalBar — the chart engine truncates labels at 13 characters and job
|
||||
titles do not survive that.
|
||||
|
||||
The pipeline card reads /analytics/funnel/fetch, not the board endpoint the
|
||||
old dashboard used, so it obeys the window/department filter and shares the
|
||||
hero's counting basis. Cost: a drag on the pipeline board no longer shows
|
||||
here instantly — staleTime 0 plus the 60s poll bounds the staleness.
|
||||
============================================================ */
|
||||
|
||||
import { useMemo, useState } from 'react'
|
||||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import Chart, { ChartLegend } from '../ui/Chart'
|
||||
import Chart from '../ui/Chart'
|
||||
import Charts from '../lib/charts'
|
||||
import Dropdown from '../ui/Dropdown'
|
||||
import ChartCard, { widgetError } from '../ui/ChartCard'
|
||||
import PageHeader from '../ui/PageHeader'
|
||||
import { Avatar, Badge, EmptyState, Icon, KpiTile, PRIORITY_CLASS, ProgressBar } from '../ui/primitives'
|
||||
import { useToast } from '../ui/Toast'
|
||||
import { Badge, EmptyState, Icon, KpiTile } from '../ui/primitives'
|
||||
import { useAuth } from '../auth/AuthContext'
|
||||
import { qk } from '../lib/queryKeys'
|
||||
import { ApiError, friendlyAuthError } from '../lib/errors'
|
||||
import { fmtShort, money, initials as initialsOf, avatarColor } from '../data/seed'
|
||||
import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges'
|
||||
import { fmtShort, money } from '../data/seed'
|
||||
import * as activityApi from '../api/activity'
|
||||
import * as analyticsApi from '../api/analytics'
|
||||
import * as interviewsApi from '../api/interviews'
|
||||
import * as inboxApi from '../api/inbox'
|
||||
import * as jobsApi from '../api/jobs'
|
||||
import * as pipelineApi from '../api/pipeline'
|
||||
import * as tasksApi from '../api/tasks'
|
||||
import * as offersApi from '../api/offers'
|
||||
|
||||
const POLL_MS = 60_000
|
||||
const BOARD_ACTIVE = [
|
||||
'Shortlist', 'Screening', 'Assessment', 'Interview', 'Offer',
|
||||
'Approved', 'Hired', 'On Hold', 'Rejected',
|
||||
]
|
||||
const TREND_MONTHS = 7
|
||||
/** Fetch more rows than the hero shows: the surplus is what makes the derived
|
||||
"open jobs with no applications" count accurate past the visible ten. */
|
||||
const JOBS_FETCHED = 100
|
||||
const JOBS_SHOWN = 10
|
||||
|
||||
function asObject(data) {
|
||||
return data && typeof data === 'object' && !Array.isArray(data) ? data : null
|
||||
|
|
@ -61,7 +85,6 @@ function trendProps(cur, prior, { lowerIsBetter = false, fmt = pctDelta } = {})
|
|||
return { trend: text, dir: good ? 'up' : 'down', arrow: went }
|
||||
}
|
||||
|
||||
|
||||
function greetingFor(now = new Date()) {
|
||||
const h = now.getHours()
|
||||
if (h < 12) return 'Good morning'
|
||||
|
|
@ -78,52 +101,10 @@ function formatDashDate(d = new Date()) {
|
|||
})
|
||||
}
|
||||
|
||||
function initialsFrom(name) {
|
||||
if (!name) return '?'
|
||||
try {
|
||||
return initialsOf(name)
|
||||
} catch {
|
||||
return String(name).slice(0, 2).toUpperCase()
|
||||
}
|
||||
}
|
||||
|
||||
function mapInterviewRow(iv) {
|
||||
const whenRaw = iv.interview_date || iv.interview_time
|
||||
const when = whenRaw ? new Date(whenRaw) : null
|
||||
const name = iv.candidate_name || 'Candidate'
|
||||
return {
|
||||
id: iv.id,
|
||||
candidate: name,
|
||||
candInitials: initialsFrom(name),
|
||||
color: avatarColor(name),
|
||||
type: iv.interview_type || 'Interview',
|
||||
jobTitle: iv.job_title || '',
|
||||
when,
|
||||
status: iv.interview_status || '',
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What actually went wrong, rather than one guess applied to everything.
|
||||
*
|
||||
* Every widget used to append "This widget needs the <permission> permission"
|
||||
* to EVERY error, so a 500, an expired session and a dead API server all read
|
||||
* on screen as a permissions problem — the misleading state called out in
|
||||
* backend/README.md's known issues. The permission line is now shown only for
|
||||
* the status that actually means it (403), and the status is always named so a
|
||||
* server fault is diagnosable from the page.
|
||||
*/
|
||||
function widgetError(err, permission, fallback) {
|
||||
const status = err instanceof ApiError ? err.status : null
|
||||
const message = friendlyAuthError(err, fallback)
|
||||
if (status === 403) {
|
||||
return <>{message} This widget needs the <code>{permission}</code> permission.</>
|
||||
}
|
||||
if (status === 401) return <>{message} Sign in again to reload this widget.</>
|
||||
// status 0 is the client-side "could not reach the server" ApiError, whose
|
||||
// message already says so; naming a fake HTTP status there would be a lie.
|
||||
if (!status) return <>{message}</>
|
||||
return <>HTTP {status} — {message}</>
|
||||
function fmtWhen(iso) {
|
||||
if (!iso) return '—'
|
||||
const d = new Date(iso)
|
||||
return Number.isNaN(d.getTime()) ? '—' : fmtShort(d)
|
||||
}
|
||||
|
||||
function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) {
|
||||
|
|
@ -152,143 +133,105 @@ function ListGate({ query, title, permission, children, emptyTitle, emptyHint })
|
|||
return children(rows)
|
||||
}
|
||||
|
||||
function clock(d) {
|
||||
return d
|
||||
? d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
|
||||
: '—'
|
||||
}
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate()
|
||||
const { user, can } = useAuth()
|
||||
const { toast } = useToast()
|
||||
const qc = useQueryClient()
|
||||
const { user } = useAuth()
|
||||
const firstName = (user?.name || 'there').split(' ')[0]
|
||||
const todayLabel = formatDashDate()
|
||||
const [trendMonths, setTrendMonths] = useState(7)
|
||||
|
||||
// Frozen at mount so the interviews query key does not churn every render
|
||||
// (or every 60s poll) and refetch the widget.
|
||||
const dayStart = useMemo(() => {
|
||||
const d = new Date()
|
||||
d.setHours(0, 0, 0, 0)
|
||||
return d
|
||||
}, [])
|
||||
const dayEnd = useMemo(() => {
|
||||
const d = new Date(dayStart)
|
||||
d.setDate(d.getDate() + 1)
|
||||
return d
|
||||
}, [dayStart])
|
||||
const [rangeKey, setRangeKey] = useState('month')
|
||||
const [department, setDepartment] = useState('')
|
||||
// rangeWindow returns fresh ISO strings on every call — recompute only when
|
||||
// the key changes, or every render would churn the query keys below.
|
||||
const span = useMemo(() => rangeWindow(rangeKey), [rangeKey])
|
||||
const filters = useMemo(
|
||||
() => ({ ...span, department: department || undefined }),
|
||||
[span, department],
|
||||
)
|
||||
/* One stable identity for every query key. recruiterId is pinned to null so
|
||||
the keys line up with Analytics' and the two screens share cache entries
|
||||
for the same window. */
|
||||
const keyParams = useMemo(
|
||||
() => ({ range: rangeKey, department: department || null, recruiterId: null }),
|
||||
[rangeKey, department],
|
||||
)
|
||||
|
||||
const kpisQuery = useQuery({
|
||||
queryKey: qk.analytics.kpis(),
|
||||
queryFn: async () => asObject((await analyticsApi.kpis()).data),
|
||||
queryKey: qk.analytics.kpis(keyParams),
|
||||
queryFn: async () => asObject((await analyticsApi.kpis(filters))?.data),
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
|
||||
// Sparkline only — the full trend chart lives on Analytics.
|
||||
const trendQuery = useQuery({
|
||||
queryKey: qk.analytics.trend({ months: trendMonths }),
|
||||
queryFn: async () => asObject((await analyticsApi.hiringTrend({ months: trendMonths })).data) || {
|
||||
labels: [],
|
||||
applications: [],
|
||||
hires: [],
|
||||
queryKey: qk.analytics.trend({ months: TREND_MONTHS }),
|
||||
queryFn: async () => asObject((await analyticsApi.hiringTrend({ months: TREND_MONTHS }))?.data)
|
||||
|| { labels: [], applications: [], hires: [] },
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
|
||||
const jobAppsQuery = useQuery({
|
||||
queryKey: qk.analytics.jobApps({ ...keyParams, top: JOBS_FETCHED }),
|
||||
queryFn: async () => {
|
||||
const res = await analyticsApi.applicationsPerJob({ top: JOBS_FETCHED, ...filters })
|
||||
return asList(res?.data)
|
||||
},
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
/* Same counts the /pipeline board paints — not /analytics/funnel. Funnel is a
|
||||
second query with a 60s staleTime, so a drag to Screening left this widget
|
||||
on the previous snapshot (Interview still filled the doughnut). The board
|
||||
already invalidates qk.pipeline.all() on drop. staleTime 0 so navigating
|
||||
back here never serves that snapshot as "fresh". */
|
||||
|
||||
/* staleTime 0 so navigating back here never serves a pre-drag snapshot as
|
||||
fresh — the board invalidates qk.pipeline.*, not this key. */
|
||||
const funnelQuery = useQuery({
|
||||
queryKey: qk.pipeline.board({ scope: 'dashboard' }),
|
||||
queryFn: async () => {
|
||||
const res = await pipelineApi.listApplications({ limit: 1 })
|
||||
return pipelineApi.toStageCounts(res?.counts?.by_status)
|
||||
},
|
||||
queryKey: qk.analytics.funnel(keyParams),
|
||||
queryFn: async () => asList((await analyticsApi.funnel(filters))?.data),
|
||||
staleTime: 0,
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
|
||||
const interviewsQuery = useQuery({
|
||||
queryKey: qk.interviews.range({
|
||||
fromDate: dayStart.toISOString(),
|
||||
toDate: dayEnd.toISOString(),
|
||||
top: 8,
|
||||
}),
|
||||
queryFn: async () => asList((await interviewsApi.listRange({
|
||||
fromDate: dayStart.toISOString(),
|
||||
toDate: dayEnd.toISOString(),
|
||||
top: 8,
|
||||
})).data).map(mapInterviewRow),
|
||||
/* Shared with Analytics under one key — the fetcher lives in api/jobs.js so
|
||||
the two screens can never cache different shapes on it. */
|
||||
const deptsQuery = useQuery({
|
||||
queryKey: qk.jobs.list({ scope: 'departments' }),
|
||||
queryFn: jobsApi.fetchDepartmentOptions,
|
||||
})
|
||||
const departments = deptsQuery.data ?? []
|
||||
|
||||
/* Point-in-time by design: /offers/fetch has no date filter, and the deep
|
||||
acceptance-rate doughnut stays on Analytics. */
|
||||
const offersQuery = useQuery({
|
||||
queryKey: qk.offers.list({ top: 500, scope: 'dashboard' }),
|
||||
queryFn: async () => asList((await offersApi.list({ top: 500 }))?.data),
|
||||
retry: false,
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: qk.jobs.list({ top: 5 }),
|
||||
queryFn: async () => asList((await jobsApi.list({ top: 5 })).data).map(jobsApi.toJobView),
|
||||
// Whole counts object under the SHARED sidebar-badge key — see api/inbox.js.
|
||||
const inboxCountsQuery = useQuery({
|
||||
queryKey: qk.mailbox.counts(),
|
||||
queryFn: inboxApi.fetchCounts,
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
|
||||
const tasksQuery = useQuery({
|
||||
queryKey: qk.tasks.list({ top: 8 }),
|
||||
queryFn: async () => asList((await tasksApi.list({ top: 8 })).data).map(tasksApi.toTaskView),
|
||||
const activityQuery = useQuery({
|
||||
queryKey: qk.activity.feed({ top: 8 }),
|
||||
queryFn: async () => asList((await activityApi.feed({ top: 8 }))?.data),
|
||||
refetchInterval: POLL_MS,
|
||||
})
|
||||
|
||||
const k = kpisQuery.data
|
||||
const canEditTasks = can('tasks.edit')
|
||||
|
||||
const flip = useMutation({
|
||||
mutationFn: ({ id, done }) => tasksApi.update(id, { status: done ? 'done' : 'open' }),
|
||||
onMutate: async ({ id, done }) => {
|
||||
await qc.cancelQueries({ queryKey: qk.tasks.all() })
|
||||
const key = qk.tasks.list({ top: 8 })
|
||||
const previous = qc.getQueryData(key)
|
||||
qc.setQueryData(key, (old = []) =>
|
||||
old.map((t) => (t.id === id ? { ...t, done } : t)),
|
||||
)
|
||||
return { previous, key }
|
||||
},
|
||||
onError: (err, _vars, ctx) => {
|
||||
if (ctx?.previous) qc.setQueryData(ctx.key, ctx.previous)
|
||||
toast(friendlyAuthError(err, 'Could not update the task.'), 'error')
|
||||
},
|
||||
onSuccess: (_res, { done }) => {
|
||||
toast(done ? 'Task completed' : 'Task reopened', done ? 'success' : 'info')
|
||||
},
|
||||
onSettled: () => qc.invalidateQueries({ queryKey: qk.tasks.all() }),
|
||||
})
|
||||
|
||||
function toggleTask(task) {
|
||||
if (!canEditTasks) {
|
||||
toast('Requires tasks.edit', 'info')
|
||||
return
|
||||
}
|
||||
flip.mutate({ id: task.id, done: !task.done })
|
||||
}
|
||||
|
||||
const trendData = useMemo(() => {
|
||||
const t = trendQuery.data || { labels: [], applications: [], hires: [] }
|
||||
return {
|
||||
labels: t.labels || [],
|
||||
area: true,
|
||||
datasets: [
|
||||
{ label: 'Applications', data: t.applications || [], color: Charts.PALETTE[4] },
|
||||
{ label: 'Hires', data: t.hires || [], color: Charts.PALETTE[0] },
|
||||
],
|
||||
}
|
||||
}, [trendQuery.data])
|
||||
/* ---------- derived (chart payloads memoised: Chart requires stable identity) ---------- */
|
||||
|
||||
const candidateSpark = useMemo(
|
||||
() => asList(trendQuery.data?.applications),
|
||||
[trendQuery.data],
|
||||
)
|
||||
|
||||
/* Occupied board columns only. Zero-count stages in the doughnut are a
|
||||
canvas footgun (arc(a,a) can paint a full circle and hide Screening). */
|
||||
/* Occupied stages only. Zero-count stages in the doughnut are a canvas
|
||||
footgun (arc(a,a) can paint a full circle and hide Screening). */
|
||||
const pipeRows = useMemo(() => {
|
||||
const folded = funnelQuery.data && typeof funnelQuery.data === 'object'
|
||||
? funnelQuery.data
|
||||
: {}
|
||||
const rows = BOARD_ACTIVE
|
||||
.map((stage) => ({ stage, count: folded[stage] || 0 }))
|
||||
const rows = analyticsApi
|
||||
.toBoardStageRows(funnelQuery.data ?? [], { includeRejected: true })
|
||||
.filter((r) => r.count > 0)
|
||||
const total = rows.reduce((sum, r) => sum + r.count, 0)
|
||||
const pal = Charts.PALETTE
|
||||
|
|
@ -307,14 +250,27 @@ export default function Dashboard() {
|
|||
centerLabel: 'In pipeline',
|
||||
}), [pipeRows])
|
||||
|
||||
const legend = useMemo(
|
||||
() => [
|
||||
{ label: 'Applications', color: Charts.PALETTE[4] },
|
||||
{ label: 'Hires', color: Charts.PALETTE[0] },
|
||||
],
|
||||
[],
|
||||
// DOM bars, no canvas — no identity hazard, but the % math is shared.
|
||||
const jobRows = useMemo(() => {
|
||||
const shown = asList(jobAppsQuery.data).slice(0, JOBS_SHOWN)
|
||||
const max = Math.max(1, ...shown.map((r) => r.count || 0))
|
||||
return shown.map((r) => ({ ...r, pct: Math.round(((r.count || 0) / max) * 100) }))
|
||||
}, [jobAppsQuery.data])
|
||||
|
||||
const starvingCount = useMemo(
|
||||
() => asList(jobAppsQuery.data)
|
||||
.filter((r) => r.requisition_status === 'open' && !r.count).length,
|
||||
[jobAppsQuery.data],
|
||||
)
|
||||
|
||||
const offerCounts = useMemo(() => {
|
||||
const counts = Object.fromEntries(offersApi.OFFER_STATUSES.map((s) => [s, 0]))
|
||||
for (const o of asList(offersQuery.data)) {
|
||||
if (o.status in counts) counts[o.status] += 1
|
||||
}
|
||||
return counts
|
||||
}, [offersQuery.data])
|
||||
|
||||
const pending = kpisQuery.isPending
|
||||
const dash = (v) => (pending || v == null || v === '' ? '—' : v)
|
||||
|
||||
|
|
@ -326,12 +282,30 @@ export default function Dashboard() {
|
|||
spark: null,
|
||||
},
|
||||
{
|
||||
label: 'Total Candidates',
|
||||
label: 'Applications',
|
||||
value: dash(k?.total_candidates),
|
||||
...trendProps(k?.total_candidates, k?.total_candidates_prior),
|
||||
spark: candidateSpark,
|
||||
sparkColor: Charts.PALETTE[4],
|
||||
},
|
||||
{
|
||||
label: 'Hires',
|
||||
value: dash(k?.hires),
|
||||
...trendProps(k?.hires, k?.hires_prior),
|
||||
spark: null,
|
||||
},
|
||||
{
|
||||
label: 'Offers Sent',
|
||||
value: dash(k?.offers_sent),
|
||||
...trendProps(k?.offers_sent, k?.offers_sent_prior),
|
||||
spark: null,
|
||||
},
|
||||
{
|
||||
label: 'Offers Accepted',
|
||||
value: dash(k?.offers_accepted),
|
||||
...trendProps(k?.offers_accepted, k?.offers_accepted_prior),
|
||||
spark: null,
|
||||
},
|
||||
{
|
||||
label: 'Interviews Today',
|
||||
value: dash(k?.interviews_today),
|
||||
|
|
@ -339,15 +313,6 @@ export default function Dashboard() {
|
|||
dir: 'flat',
|
||||
spark: null,
|
||||
},
|
||||
{
|
||||
// No sparkline: the only monthly series in the payload are applications
|
||||
// and hires, and a hires line under an "Offers Accepted" label plots the
|
||||
// wrong metric.
|
||||
label: 'Offers Accepted',
|
||||
value: dash(k?.offers_accepted),
|
||||
...trendProps(k?.offers_accepted, k?.offers_accepted_prior),
|
||||
spark: null,
|
||||
},
|
||||
{
|
||||
label: 'Time to Hire',
|
||||
value: k?.time_to_hire != null && !pending ? `${Math.round(k.time_to_hire)} days` : '—',
|
||||
|
|
@ -360,113 +325,147 @@ export default function Dashboard() {
|
|||
...trendProps(k?.cost_per_hire, k?.cost_per_hire_prior, { lowerIsBetter: true }),
|
||||
spark: null,
|
||||
},
|
||||
{
|
||||
// Closed jobs means closed requisitions, full stop — the tile used to
|
||||
// add hires on top, which double-counts a hire on a still-open req and
|
||||
// mislabels the metric.
|
||||
label: 'Closed Jobs',
|
||||
value: dash(k?.closed_jobs),
|
||||
...trendProps(k?.closed_jobs, k?.closed_jobs_prior),
|
||||
spark: null,
|
||||
},
|
||||
]
|
||||
|
||||
const now = new Date()
|
||||
const counts = inboxCountsQuery.data ?? {}
|
||||
const attentionRows = [
|
||||
{
|
||||
key: 'unread',
|
||||
title: 'Unread applications',
|
||||
hint: 'Waiting for a first look',
|
||||
count: counts.unread,
|
||||
to: '/inbox',
|
||||
},
|
||||
{
|
||||
key: 'unassigned',
|
||||
title: 'Not assigned to a job',
|
||||
hint: 'Applications without a requisition',
|
||||
count: counts.unassigned,
|
||||
to: '/inbox',
|
||||
},
|
||||
{
|
||||
key: 'duplicates',
|
||||
title: 'Flagged duplicates',
|
||||
hint: 'Same candidate, more than once',
|
||||
count: counts.duplicates,
|
||||
to: '/inbox',
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
title={<>{greetingFor()}, {firstName} 👋</>}
|
||||
sub={<>Here’s what’s happening with your hiring today — {todayLabel}</>}
|
||||
sub={<>Org-wide recruiting activity — {todayLabel}</>}
|
||||
actions={<>
|
||||
<div className="pill-tabs">
|
||||
{RANGES.map((r) => (
|
||||
<span
|
||||
key={r.key}
|
||||
className={`pill-tab${rangeKey === r.key ? ' active' : ''}`}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => setRangeKey(r.key)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRangeKey(r.key) } }}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<select className="select" value={department} onChange={(e) => setDepartment(e.target.value)}>
|
||||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d}>{d}</option>)}
|
||||
</select>
|
||||
<Link className="btn btn-secondary" to="/reports">
|
||||
<Icon name="download" /> Export
|
||||
</Link>
|
||||
<Dropdown
|
||||
trigger={({ toggle }) => (
|
||||
<button className="btn btn-secondary" onClick={toggle}>
|
||||
Quick Actions <Icon name="chevron-down" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<Link className="dropdown-link" to="/interviews" state={{ openSchedule: true }}>
|
||||
<Icon name="calendar" /> Schedule Interview
|
||||
</Link>
|
||||
<Link className="dropdown-link" to="/tasks">
|
||||
<Icon name="check-square" /> New Task
|
||||
</Link>
|
||||
<Link className="dropdown-link" to="/candidates">
|
||||
<Icon name="user-plus" /> Add Candidate
|
||||
</Link>
|
||||
</Dropdown>
|
||||
<Link className="btn btn-primary" to="/jobs" state={{ openCreate: true }}>
|
||||
<Icon name="plus" /> Create Job
|
||||
</Link>
|
||||
</>}
|
||||
/>
|
||||
|
||||
<div className="grid g-kpi-7">
|
||||
{kpisQuery.isError && (
|
||||
<div className="card mb-18">
|
||||
<div className="card-body">
|
||||
<EmptyState icon="alert" title="Couldn’t load the summary">
|
||||
{widgetError(kpisQuery.error, 'analytics.view', 'The server did not return the KPIs.')}
|
||||
</EmptyState>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid g-kpi-8">
|
||||
{tiles.map((c) => <KpiTile key={c.label} {...c} />)}
|
||||
</div>
|
||||
|
||||
<div className="grid g-2-1 mt-18">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Hiring Trend</h3>
|
||||
<span className="ch-sub">
|
||||
{trendQuery.isPending
|
||||
? 'Loading…'
|
||||
: `Hires vs applications over the last ${trendMonths === 7 ? '7 months' : 'year'}`}
|
||||
</span>
|
||||
</div>
|
||||
<Dropdown
|
||||
trigger={({ toggle }) => (
|
||||
<button className="btn btn-ghost btn-sm" onClick={toggle}>
|
||||
{trendMonths === 7 ? '7M' : '1Y'} <Icon name="chevron-down" />
|
||||
</button>
|
||||
)}
|
||||
>
|
||||
<button type="button" className="dropdown-link" onClick={() => setTrendMonths(7)}>
|
||||
7 months
|
||||
</button>
|
||||
<button type="button" className="dropdown-link" onClick={() => setTrendMonths(12)}>
|
||||
1 year
|
||||
</button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{trendQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load hiring trend">
|
||||
{widgetError(trendQuery.error, 'analytics.view', 'The server did not return the trend.')}
|
||||
<ChartCard
|
||||
title="Applications per Job"
|
||||
sub={`Top ${JOBS_SHOWN} · ${rangeLabel(rangeKey)}${department ? ` · ${department}` : ''} · empty bars are open jobs nobody applied to`}
|
||||
query={jobAppsQuery}
|
||||
permission="analytics.view"
|
||||
>
|
||||
{() => (
|
||||
jobRows.length === 0 ? (
|
||||
<EmptyState icon="briefcase" title="No jobs in this window">
|
||||
Create a requisition or widen the range to see applications per job.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<>
|
||||
<div className="chart-wrap">
|
||||
<Chart type="line" data={trendData} height={280} />
|
||||
</div>
|
||||
<ChartLegend items={legend} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Candidate Pipeline</h3>
|
||||
<span className="ch-sub">
|
||||
{funnelQuery.isPending ? 'Loading…' : 'Active by stage'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{funnelQuery.isError ? (
|
||||
<EmptyState icon="alert" title="Couldn’t load pipeline">
|
||||
{widgetError(funnelQuery.error, 'pipeline.view', 'The server did not return the pipeline.')}
|
||||
</EmptyState>
|
||||
) : funnelQuery.isSuccess && pipeRows.length === 0 ? (
|
||||
<div className="list-tight">
|
||||
{jobRows.map((j) => (
|
||||
<div
|
||||
key={j.job_post_id}
|
||||
className="jobapp-row"
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => navigate('/jobs', { state: { openJob: j.job_post_id } })}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
navigate('/jobs', { state: { openJob: j.job_post_id } })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="jobapp-main">
|
||||
<div className="lr-title">
|
||||
{j.title}
|
||||
{j.requisition_status !== 'open' && (
|
||||
<Badge className="b-gray" style={{ marginLeft: 8 }}>
|
||||
{jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="lr-sub">
|
||||
{j.department || 'No department'}
|
||||
{j.vacancies ? ` · ${j.vacancies} vacanc${j.vacancies === 1 ? 'y' : 'ies'}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pipe-track">
|
||||
<div
|
||||
className="pipe-fill"
|
||||
style={{ width: `${j.count ? j.pct : 0}%`, background: Charts.PALETTE[0] }}
|
||||
/>
|
||||
</div>
|
||||
<span className="jobapp-count">{j.count}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ChartCard>
|
||||
|
||||
<ChartCard
|
||||
title="Candidate Pipeline"
|
||||
sub="Active by stage · this window"
|
||||
query={funnelQuery}
|
||||
height={160}
|
||||
permission="analytics.view"
|
||||
>
|
||||
{(h) => (
|
||||
pipeRows.length === 0 ? (
|
||||
<EmptyState icon="inbox" title="No pipeline data yet">
|
||||
Stage counts appear once applications are in the system.
|
||||
Stage counts appear once applications land in this window.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="pipe-split">
|
||||
|
|
@ -485,153 +484,132 @@ export default function Dashboard() {
|
|||
))}
|
||||
</div>
|
||||
<div className="chart-wrap">
|
||||
<Chart type="doughnut" data={pipelineDoughnut} height={160} />
|
||||
<Chart type="doughnut" data={pipelineDoughnut} height={h} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</ChartCard>
|
||||
</div>
|
||||
|
||||
<div className="grid g-3 mt-18">
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>Recent Job Openings</h3></div>
|
||||
<Link className="btn btn-ghost btn-sm" to="/jobs">View all</Link>
|
||||
<div>
|
||||
<h3>Offer Book</h3>
|
||||
<span className="ch-sub">All offers by status · point-in-time</span>
|
||||
</div>
|
||||
<Link className="btn btn-ghost btn-sm" to="/offers">View all</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
<ListGate
|
||||
query={jobsQuery}
|
||||
title="jobs"
|
||||
permission="jobs.view"
|
||||
emptyTitle="No job openings"
|
||||
emptyHint="Create a requisition to see it here."
|
||||
>
|
||||
{(jobs) => jobs.map((job) => (
|
||||
<div
|
||||
key={job.id}
|
||||
className="list-row"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/jobs', { state: { openJob: job.id } })}
|
||||
>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{job.title}</div>
|
||||
<div className="lr-sub">
|
||||
{job.applicantCount} Applicant{job.applicantCount === 1 ? '' : 's'}
|
||||
{job.department ? ` · ${job.department}` : ''}
|
||||
</div>
|
||||
{offersQuery.isPending && <EmptyState icon="clock" title="Loading offers">Counting offers…</EmptyState>}
|
||||
{offersQuery.isError && (
|
||||
<EmptyState icon="lock" title="Offers not visible">
|
||||
{widgetError(offersQuery.error, 'offers.view', 'The offers table did not answer.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{!offersQuery.isPending && !offersQuery.isError && (
|
||||
asList(offersQuery.data).length === 0 ? (
|
||||
<EmptyState icon="file" title="No offers yet">
|
||||
This fills in once the first offer is issued.
|
||||
</EmptyState>
|
||||
) : (
|
||||
<div className="info-grid">
|
||||
{offersApi.OFFER_STATUSES.map((s) => (
|
||||
<div className="info-item" key={s}>
|
||||
<div className="il">{offersApi.OFFER_STATUS_LABEL[s]}</div>
|
||||
<div className="iv">{offerCounts[s]}</div>
|
||||
</div>
|
||||
<Badge>{job.status}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</ListGate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>My Tasks</h3></div>
|
||||
<Link className="btn btn-ghost btn-sm" to="/tasks">View all</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
<ListGate
|
||||
query={tasksQuery}
|
||||
title="tasks"
|
||||
permission="tasks.view"
|
||||
emptyTitle="No tasks yet"
|
||||
emptyHint="Tasks assigned to you will show up here."
|
||||
>
|
||||
{(tasks) => {
|
||||
const done = tasks.filter((t) => t.done).length
|
||||
const pct = tasks.length ? Math.round((done / tasks.length) * 100) : 0
|
||||
return (
|
||||
<>
|
||||
{tasks.map((t) => {
|
||||
const overdue = !t.done && t.due && t.due < now
|
||||
return (
|
||||
<div className="list-row" style={{ alignItems: 'center' }} key={t.id}>
|
||||
<span
|
||||
className={`checkbox ${t.done ? 'on' : ''}`}
|
||||
onClick={() => toggleTask(t)}
|
||||
role="checkbox"
|
||||
aria-checked={t.done}
|
||||
tabIndex={canEditTasks ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
toggleTask(t)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="check" />
|
||||
</span>
|
||||
<div className="lr-main">
|
||||
<div
|
||||
className="lr-title"
|
||||
style={t.done ? { textDecoration: 'line-through', color: 'var(--text-3)' } : undefined}
|
||||
>
|
||||
{t.title}
|
||||
</div>
|
||||
<div className="lr-sub">
|
||||
{t.due ? fmtShort(t.due) : 'No due date'}
|
||||
{overdue ? ' · Overdue' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<Badge className={PRIORITY_CLASS[t.priority]}>{t.priority}</Badge>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="task-foot">
|
||||
<ProgressBar pct={pct} />
|
||||
<span>{done} of {tasks.length}</span>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}}
|
||||
</ListGate>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Today’s Schedule</h3>
|
||||
<span className="ch-sub">Interviews on the calendar today</span>
|
||||
<h3>Needs Attention</h3>
|
||||
<span className="ch-sub">Work queued right now</span>
|
||||
</div>
|
||||
<Link className="btn btn-ghost btn-sm" to="/interviews">View all</Link>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<div className="list-tight">
|
||||
<ListGate
|
||||
query={interviewsQuery}
|
||||
title="interviews"
|
||||
permission="candidates.view"
|
||||
emptyTitle="Nothing scheduled today"
|
||||
emptyHint="Today’s interviews will show up here."
|
||||
>
|
||||
{(upcoming) => upcoming.map((iv) => (
|
||||
{inboxCountsQuery.isPending && <EmptyState icon="clock" title="Loading counts">Checking the inbox…</EmptyState>}
|
||||
{inboxCountsQuery.isError && (
|
||||
<EmptyState icon="alert" title="Couldn’t load inbox counts">
|
||||
{widgetError(inboxCountsQuery.error, 'inbox.view', 'The inbox did not answer.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{!inboxCountsQuery.isPending && !inboxCountsQuery.isError && (
|
||||
<div className="list-tight">
|
||||
{attentionRows.map((row) => (
|
||||
<div
|
||||
key={iv.id}
|
||||
key={row.key}
|
||||
className="list-row"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/interviews')}
|
||||
onClick={() => navigate(row.to)}
|
||||
>
|
||||
<span className="sched-time">{clock(iv.when)}</span>
|
||||
<Avatar name={iv.candidate} initials={iv.candInitials} color={iv.color} />
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">{iv.candidate}</div>
|
||||
<div className="lr-sub">{iv.jobTitle || '—'}</div>
|
||||
<div className="lr-title">{row.title}</div>
|
||||
<div className="lr-sub">{row.hint}</div>
|
||||
</div>
|
||||
<Badge className="badge-plain">{iv.type}</Badge>
|
||||
<Badge className={row.count ? 'b-amber' : 'b-gray'}>{row.count ?? 0}</Badge>
|
||||
</div>
|
||||
))}
|
||||
</ListGate>
|
||||
{/* Derived from the per-job rows already on this page — no extra read. */}
|
||||
<div
|
||||
className="list-row"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/jobs')}
|
||||
>
|
||||
<div className="lr-main">
|
||||
<div className="lr-title">Open jobs with no applications</div>
|
||||
<div className="lr-sub">In the selected window</div>
|
||||
</div>
|
||||
<Badge className={starvingCount ? 'b-amber' : 'b-gray'}>
|
||||
{jobAppsQuery.isSuccess ? starvingCount : '—'}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div>
|
||||
<h3>Recent Activity</h3>
|
||||
<span className="ch-sub">Latest across the org</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<ListGate
|
||||
query={activityQuery}
|
||||
title="activity"
|
||||
permission="candidates.view"
|
||||
emptyTitle="No activity yet"
|
||||
emptyHint="Actions on candidates appear here as they happen."
|
||||
>
|
||||
{(rows) => (
|
||||
<div className="timeline">
|
||||
{rows.map((a) => (
|
||||
<div className="tl-item" key={a.id}>
|
||||
<div className="tl-dot"><Icon name="zap" /></div>
|
||||
<div className="tl-title">{a.activity_type || 'Activity'}</div>
|
||||
<div className="tl-meta">
|
||||
{fmtWhen(a.activity_date)}
|
||||
{a.actor_name ? ` · ${a.actor_name}` : ''}
|
||||
</div>
|
||||
{(a.description || a.activity_status) && (
|
||||
<div className="tl-desc">{a.description || a.activity_status}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ListGate>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -227,6 +227,38 @@ export default function TalentPool() {
|
|||
toast(`${c.name} moved to ${stage}`, 'success')
|
||||
}
|
||||
|
||||
/* CSV of the FILTERED grid, built client-side — there is no /candidate export
|
||||
endpoint (jobs and reports each own theirs). Exporting `list` rather than
|
||||
`pool` means the file always matches what the recruiter is looking at,
|
||||
search and department filter included. Company/skills are seed-overlay
|
||||
values, same as the cards render. */
|
||||
function exportCsv() {
|
||||
if (!list.length) {
|
||||
toast('Nothing to export — current filters match no candidates', 'warning')
|
||||
return
|
||||
}
|
||||
const esc = (v) => {
|
||||
const s = v == null ? '' : String(v)
|
||||
return /[",\n]/.test(s) ? `"${s.replaceAll('"', '""')}"` : s
|
||||
}
|
||||
const header = ['Name', 'Email', 'Current Title', 'Company', 'Departments', 'Stage', 'Experience (yrs)', 'Source', 'AI Score', 'Skills']
|
||||
const lines = list.map((c) => [
|
||||
c.name, c.email, c.currentTitle, c.currentCompany,
|
||||
(c.departments || []).join('; '), c.stage, c.experience,
|
||||
c.source, c.aiScore ?? '', (c.skills || []).join('; '),
|
||||
].map(esc).join(','))
|
||||
const blob = new Blob([[header.join(','), ...lines].join('\n')], { type: 'text/csv;charset=utf-8' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `talent-pool-${new Date().toISOString().slice(0, 10)}.csv`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
a.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
toast(`Exported ${list.length} candidate${list.length === 1 ? '' : 's'} to CSV`, 'success')
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<PageHeader
|
||||
|
|
@ -250,6 +282,9 @@ export default function TalentPool() {
|
|||
<option value="">All Departments</option>
|
||||
{departments.map((d) => <option key={d} value={d}>{d}</option>)}
|
||||
</select>
|
||||
<button className="btn btn-secondary" onClick={exportCsv}>
|
||||
<Icon name="download" /> Export
|
||||
</button>
|
||||
<PageSizeField
|
||||
value={pageSize}
|
||||
onChange={setPageSize}
|
||||
|
|
|
|||
|
|
@ -1636,6 +1636,15 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
.kpi-spark canvas { width: 100%; display: block; }
|
||||
|
||||
.g-kpi-7 { grid-template-columns: repeat(7, 1fr); }
|
||||
.g-kpi-8 { grid-template-columns: repeat(8, 1fr); }
|
||||
|
||||
/* Applications per Job — dashboard hero rows. Same track/fill as .pipe-*,
|
||||
but the label column is wide enough for a real job title. */
|
||||
.jobapp-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; cursor: pointer; }
|
||||
.jobapp-row:first-child { padding-top: 0; }
|
||||
.jobapp-main { flex: 0 0 46%; min-width: 0; }
|
||||
.jobapp-main .lr-title { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.jobapp-count { flex: 0 0 36px; text-align: right; font-size: 13px; font-weight: 700; }
|
||||
|
||||
.pipe-split { display: grid; grid-template-columns: 1fr 150px; gap: 16px; align-items: center; }
|
||||
.pipe-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
|
|
@ -1658,10 +1667,13 @@ canvas { width: 100%; max-width: 100%; display: block; }
|
|||
|
||||
@media (max-width: 1400px) {
|
||||
.g-kpi-7 { grid-template-columns: repeat(4, 1fr); }
|
||||
.g-kpi-8 { grid-template-columns: repeat(4, 1fr); }
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.g-kpi-7 { grid-template-columns: repeat(2, 1fr); }
|
||||
.g-kpi-8 { grid-template-columns: repeat(2, 1fr); }
|
||||
.pipe-split { grid-template-columns: 1fr; }
|
||||
.jobapp-main { flex-basis: 40%; }
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
|
|
|
|||
|
|
@ -0,0 +1,50 @@
|
|||
import { EmptyState } from './primitives'
|
||||
import { ApiError, friendlyAuthError } from '../lib/errors'
|
||||
|
||||
/**
|
||||
* What actually went wrong, rather than one guess applied to everything.
|
||||
*
|
||||
* Widgets used to append "needs the <permission> permission" to EVERY error,
|
||||
* so a 500, an expired session and a dead API server all read on screen as a
|
||||
* permissions problem — the misleading state called out in backend/README.md's
|
||||
* known issues. The permission line is shown only for the status that actually
|
||||
* means it (403), and the status is always named so a server fault is
|
||||
* diagnosable from the page.
|
||||
*/
|
||||
export function widgetError(err, permission, fallback) {
|
||||
const status = err instanceof ApiError ? err.status : null
|
||||
const message = friendlyAuthError(err, fallback)
|
||||
if (status === 403 && permission) {
|
||||
return <>{message} This widget needs the <code>{permission}</code> permission.</>
|
||||
}
|
||||
if (status === 401) return <>{message} Sign in again to reload this widget.</>
|
||||
// status 0 is the client-side "could not reach the server" ApiError, whose
|
||||
// message already says so; naming a fake HTTP status there would be a lie.
|
||||
if (!status) return <>{message}</>
|
||||
return <>HTTP {status} — {message}</>
|
||||
}
|
||||
|
||||
/**
|
||||
* Every chart that can render is wrapped in this, so one failing read never
|
||||
* blanks the page. Shared by Dashboard and Analytics — extracted from
|
||||
* Analytics.jsx, error copy upgraded to the status-aware widgetError above.
|
||||
*/
|
||||
export default function ChartCard({ title, sub, query, height = 260, permission, children, footer }) {
|
||||
return (
|
||||
<div className="card">
|
||||
<div className="card-head">
|
||||
<div><h3>{title}</h3>{sub && <span className="ch-sub">{sub}</span>}</div>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{query.isPending && <EmptyState icon="clock" title="Loading…">Fetching from the server.</EmptyState>}
|
||||
{query.isError && (
|
||||
<EmptyState icon="alert" title={`Couldn’t load ${String(title).toLowerCase()}`}>
|
||||
{widgetError(query.error, permission, 'The server did not answer.')}
|
||||
</EmptyState>
|
||||
)}
|
||||
{!query.isPending && !query.isError && children(height)}
|
||||
{!query.isPending && !query.isError && footer}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in New Issue