Merge pull request 'Dashboard redesign: org-wide activity summary with applications-per-job' (#44) from Talha into main
Deploy to S3 / deploy (push) Successful in 31s Details

pull/45/head
talha.ahmed 2026-09-01 17:24:19 +00:00
commit a68cc43455
15 changed files with 691 additions and 427 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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={`Couldnt 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 ?? []

View File

@ -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={<>Heres whats 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="Couldnt 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="Couldnt 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="Couldnt 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>Todays 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="Todays 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="Couldnt 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>

View File

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

View File

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

View File

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