diff --git a/backend/analytics/app.py b/backend/analytics/app.py index c996f8c..3dab351 100644 --- a/backend/analytics/app.py +++ b/backend/analytics/app.py @@ -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, diff --git a/backend/analytics/serializers.py b/backend/analytics/serializers.py index 8be2007..2c7699a 100644 --- a/backend/analytics/serializers.py +++ b/backend/analytics/serializers.py @@ -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, diff --git a/backend/analytics/views.py b/backend/analytics/views.py index 34872ce..146bb7d 100644 --- a/backend/analytics/views.py +++ b/backend/analytics/views.py @@ -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) diff --git a/backend/inbox/models.py b/backend/inbox/models.py index 5fec8ce..7d1dd4d 100644 --- a/backend/inbox/models.py +++ b/backend/inbox/models.py @@ -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, diff --git a/backend/job/candidate/models.py b/backend/job/candidate/models.py index 4f03884..ee7f6f2 100644 --- a/backend/job/candidate/models.py +++ b/backend/job/candidate/models.py @@ -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 uuid.UUID | None: if record_id in (None, ""): diff --git a/backend/job/job_post/models.py b/backend/job/job_post/models.py index 364de81..ea98f48 100644 --- a/backend/job/job_post/models.py +++ b/backend/job/job_post/models.py @@ -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).""" diff --git a/frontend/src/api/analytics.js b/frontend/src/api/analytics.js index 7eed04c..9b9f873 100644 --- a/frontend/src/api/analytics.js +++ b/frontend/src/api/analytics.js @@ -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 diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 0b5b0c5..6a21ffc 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -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` diff --git a/frontend/src/lib/queryKeys.js b/frontend/src/lib/queryKeys.js index c639220..ce15d0a 100644 --- a/frontend/src/lib/queryKeys.js +++ b/frontend/src/lib/queryKeys.js @@ -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: { diff --git a/frontend/src/lib/timeRanges.js b/frontend/src/lib/timeRanges.js new file mode 100644 index 0000000..3ea06fb --- /dev/null +++ b/frontend/src/lib/timeRanges.js @@ -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 +} diff --git a/frontend/src/screens/Analytics.jsx b/frontend/src/screens/Analytics.jsx index 77b0887..6d07849 100644 --- a/frontend/src/screens/Analytics.jsx +++ b/frontend/src/screens/Analytics.jsx @@ -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 ( -
-
-

{title}

{sub && {sub}}
-
-
- {query.isPending && Fetching from the server.} - {query.isError && ( - - {friendlyAuthError(query.error, 'The server did not answer.')} - {permission && <> This card needs the {permission} permission.} - - )} - {!query.isPending && !query.isError && children(height)} - {!query.isPending && !query.isError && footer} -
-
- ) -} - 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 ?? [] diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 5ddfbca..f72cc54 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -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" - * 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 {permission} 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 (
{greetingFor()}, {firstName} 👋} - sub={<>Here’s what’s happening with your hiring today — {todayLabel}} + sub={<>Org-wide recruiting activity — {todayLabel}} actions={<> +
+ {RANGES.map((r) => ( + setRangeKey(r.key)} + onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setRangeKey(r.key) } }} + > + {r.label} + + ))} +
+ Export - ( - - )} - > - - Schedule Interview - - - New Task - - - Add Candidate - - Create Job } /> -
+ {kpisQuery.isError && ( +
+
+ + {widgetError(kpisQuery.error, 'analytics.view', 'The server did not return the KPIs.')} + +
+
+ )} + +
{tiles.map((c) => )}
-
-
-
-

Hiring Trend

- - {trendQuery.isPending - ? 'Loading…' - : `Hires vs applications over the last ${trendMonths === 7 ? '7 months' : 'year'}`} - -
- ( - - )} - > - - - -
-
- {trendQuery.isError ? ( - - {widgetError(trendQuery.error, 'analytics.view', 'The server did not return the trend.')} + + {() => ( + jobRows.length === 0 ? ( + + Create a requisition or widen the range to see applications per job. ) : ( - <> -
- -
- - - )} -
-
-
-
-
-

Candidate Pipeline

- - {funnelQuery.isPending ? 'Loading…' : 'Active by stage'} - -
-
-
- {funnelQuery.isError ? ( - - {widgetError(funnelQuery.error, 'pipeline.view', 'The server did not return the pipeline.')} - - ) : funnelQuery.isSuccess && pipeRows.length === 0 ? ( +
+ {jobRows.map((j) => ( +
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 } }) + } + }} + > +
+
+ {j.title} + {j.requisition_status !== 'open' && ( + + {jobsApi.REQ_STATUS_LABEL[j.requisition_status] || j.requisition_status} + + )} +
+
+ {j.department || 'No department'} + {j.vacancies ? ` · ${j.vacancies} vacanc${j.vacancies === 1 ? 'y' : 'ies'}` : ''} +
+
+
+
+
+ {j.count} +
+ ))} +
+ ) + )} + + + + {(h) => ( + pipeRows.length === 0 ? ( - Stage counts appear once applications are in the system. + Stage counts appear once applications land in this window. ) : (
@@ -485,153 +484,132 @@ export default function Dashboard() { ))}
- +
- )} -
-
+ ) + )} +
-

Recent Job Openings

- View all +
+

Offer Book

+ All offers by status · point-in-time +
+ View all
-
- - {(jobs) => jobs.map((job) => ( -
navigate('/jobs', { state: { openJob: job.id } })} - > -
-
{job.title}
-
- {job.applicantCount} Applicant{job.applicantCount === 1 ? '' : 's'} - {job.department ? ` · ${job.department}` : ''} -
+ {offersQuery.isPending && Counting offers…} + {offersQuery.isError && ( + + {widgetError(offersQuery.error, 'offers.view', 'The offers table did not answer.')} + + )} + {!offersQuery.isPending && !offersQuery.isError && ( + asList(offersQuery.data).length === 0 ? ( + + This fills in once the first offer is issued. + + ) : ( +
+ {offersApi.OFFER_STATUSES.map((s) => ( +
+
{offersApi.OFFER_STATUS_LABEL[s]}
+
{offerCounts[s]}
- {job.status} -
- ))} - -
-
-
- -
-
-

My Tasks

- View all -
-
-
- - {(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 ( -
- toggleTask(t)} - role="checkbox" - aria-checked={t.done} - tabIndex={canEditTasks ? 0 : -1} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault() - toggleTask(t) - } - }} - > - - -
-
- {t.title} -
-
- {t.due ? fmtShort(t.due) : 'No due date'} - {overdue ? ' · Overdue' : ''} -
-
- {t.priority} -
- ) - })} -
- - {done} of {tasks.length} -
- - ) - }} -
-
+ ))} +
+ ) + )}
-

Today’s Schedule

- Interviews on the calendar today +

Needs Attention

+ Work queued right now
- View all
-
- - {(upcoming) => upcoming.map((iv) => ( + {inboxCountsQuery.isPending && Checking the inbox…} + {inboxCountsQuery.isError && ( + + {widgetError(inboxCountsQuery.error, 'inbox.view', 'The inbox did not answer.')} + + )} + {!inboxCountsQuery.isPending && !inboxCountsQuery.isError && ( +
+ {attentionRows.map((row) => (
navigate('/interviews')} + onClick={() => navigate(row.to)} > - {clock(iv.when)} -
-
{iv.candidate}
-
{iv.jobTitle || '—'}
+
{row.title}
+
{row.hint}
- {iv.type} + {row.count ?? 0}
))} - + {/* Derived from the per-job rows already on this page — no extra read. */} +
navigate('/jobs')} + > +
+
Open jobs with no applications
+
In the selected window
+
+ + {jobAppsQuery.isSuccess ? starvingCount : '—'} + +
+
+ )} +
+
+ +
+
+
+

Recent Activity

+ Latest across the org
+
+ + {(rows) => ( +
+ {rows.map((a) => ( +
+
+
{a.activity_type || 'Activity'}
+
+ {fmtWhen(a.activity_date)} + {a.actor_name ? ` · ${a.actor_name}` : ''} +
+ {(a.description || a.activity_status) && ( +
{a.description || a.activity_status}
+ )} +
+ ))} +
+ )} +
+
diff --git a/frontend/src/screens/TalentPool.jsx b/frontend/src/screens/TalentPool.jsx index 20c3961..78548ee 100644 --- a/frontend/src/screens/TalentPool.jsx +++ b/frontend/src/screens/TalentPool.jsx @@ -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 (
All Departments {departments.map((d) => )} + 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 {permission} 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 ( +
+
+

{title}

{sub && {sub}}
+
+
+ {query.isPending && Fetching from the server.} + {query.isError && ( + + {widgetError(query.error, permission, 'The server did not answer.')} + + )} + {!query.isPending && !query.isError && children(height)} + {!query.isPending && !query.isError && footer} +
+
+ ) +}