import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { request } from './api' // THE MANAGER VIEW. // // A manager running six recruiters opened this product and got the recruiter dashboard: identical screen, // org wide, one window, no comparison, nothing actionable. Three things were missing and all three are reads // over data already held: scope, comparison, and capacity. export interface Comparison { now: string previous: string // direction describes the NUMBER. better says whether that movement is what the firm wants, which is metric // specific: a rising fill rate is good and a rising time to fill is not. The server decides, because a // component deciding for itself gets it backwards on half the metrics. direction: 'up' | 'down' | 'flat' better?: 'yes' | 'no' | 'flat' } export interface RecruiterLoad { user_id?: string name: string open_requisitions: number active_applications: number band: 'ok' | 'watch' | 'strained' | 'overloaded' } export interface ManagerView { scope: 'organization' | 'team' | 'user' scope_label: string period_days: number previous_end: string placements: Comparison submissions: Comparison fill_rate: Comparison time_to_fill_days: Comparison capacity: RecruiterLoad[] overloaded: number } // The published benchmarks the bands come from, stated in the interface so a manager can see WHY somebody is // flagged rather than trusting a colour. export const LOAD_BENCHMARK = 14 export const LOAD_STRAINED = 20 export function bandColor(b: string): string { if (b === 'overloaded') return 'var(--color-risk)' if (b === 'strained') return 'var(--color-warn)' if (b === 'watch') return 'var(--color-blue-soft)' return 'var(--color-ok)' } // betterColor colours a movement by whether it is WANTED, never by its direction. export function betterColor(c: Comparison): string { if (c.better === 'yes') return 'var(--color-ok)' if (c.better === 'no') return 'var(--color-risk)' return 'var(--color-muted)' } // THE KEY IS THE QUESTION ASKED, NEVER THE CLOCK READING THAT ANSWERS IT. // // This hook first took a `since` instant computed by the screen. That instant carried milliseconds and was // recomputed on every render, so the query key differed on every render, so TanStack refetched, which // rerendered, which produced another key. The result was an unbounded request loop: the manager view issued // roughly one request per 15ms, never left its loading state, and burned the API rate limiter so the NEXT // screen a recruiter opened got a 429 and rendered empty. // // A manager asks for "the last 30 days". That is the cache key. The instant is a detail of answering it, so // it is computed inside queryFn, fresh at fetch time, and never observed by the key. export function useManagerView(teamID: string, owner: string, days: string) { return useQuery({ queryKey: ['recruitmatch', 'manager', teamID, owner, days], queryFn: () => { const d = new Date() d.setDate(d.getDate() - Number(days)) const p = new URLSearchParams() if (teamID) p.set('team_id', teamID) if (owner) p.set('owner', owner) p.set('since', d.toISOString()) return request(`/recruiting/manager?${p.toString()}`) }, }) } // THE EXCEPTION QUEUE, AND THE ACTION THAT ANSWERS IT. // // Phase 1 was a report. These two turn it into a tool: the queue says which of a manager's people has a // problem, and the desk move is what they do about it. export type ExceptionKind = 'aging_order' | 'silent_desk' | 'unclaimed_redeployment' export interface Exception { kind: ExceptionKind // Two severities, not five. A manager scanning a list makes a binary decision, look now or look later. severity: 'watch' | 'act' subject_type: string subject_id: string title: string detail: string owner_user_id?: string owner_name?: string days: number } export interface ExceptionQueue { scope: 'organization' | 'team' | 'user' scope_label: string exceptions: Exception[] counts: Partial> // The server says plainly when it cut the list. The interface must repeat that rather than let a // manager read a capped count as the real one. truncated: boolean total: number } export const EXCEPTION_LABELS: Record = { aging_order: 'Orders nobody has touched', silent_desk: 'Desks that have gone quiet', unclaimed_redeployment: 'Redeployments nobody picked up', } export function severityColor(s: string): string { return s === 'act' ? 'var(--color-risk)' : 'var(--color-warn)' } export function useExceptions(teamID: string, owner: string) { const p = new URLSearchParams() if (teamID) p.set('team_id', teamID) if (owner) p.set('owner', owner) const qs = p.toString() return useQuery({ queryKey: ['recruitmatch', 'exceptions', teamID, owner], queryFn: () => request(`/recruiting/exceptions${qs ? `?${qs}` : ''}`), }) } export interface DeskMove { from_user_id: string from_name?: string to_user_id: string to_name?: string clients: number requisitions: number applications: number total: number } export const MOVABLE_KINDS = ['clients', 'requisitions', 'applications'] as const // useMoveDesk invalidates BOTH the manager summary and the exception queue on success. // // Moving a desk changes capacity and it changes who owns the neglected orders, so leaving either cached // would show a manager the world as it was before the action they just took and make them wonder whether // it worked. export function useMoveDesk() { const qc = useQueryClient() return useMutation({ mutationFn: (v: { from_user_id: string; to_user_id: string; include?: string[] }) => request('/recruiting/desk/reassign', { method: 'POST', body: JSON.stringify(v) }), onSuccess: () => { qc.invalidateQueries({ queryKey: ['recruitmatch', 'manager'] }) qc.invalidateQueries({ queryKey: ['recruitmatch', 'exceptions'] }) }, }) } // PHASE 4: GOALS AND THE FORECAST. // // Phase 1 answers "better or worse than last month". A goal is what makes it answer "is this enough", // which is the question the business actually asks. export const GOAL_METRICS = ['placements', 'submissions', 'fill_rate', 'time_to_fill_days'] as const export type GoalMetric = (typeof GOAL_METRICS)[number] export const GOAL_METRIC_LABELS: Record = { placements: 'Placements', submissions: 'Submissions', fill_rate: 'Fill rate', time_to_fill_days: 'Days to fill', } export interface Goal { id: string scope: 'organization' | 'team' | 'user' subject_id?: string subject_name?: string metric: GoalMetric period_start: string period_days: number target: string note?: string } export interface StageConversion { stage: string reached: number placed: number rate: string // measured false means the sample was too thin. The rate is zero and MUST be shown as unknown rather // than as a forecast of nothing. measured: boolean } export interface Forecast { scope: string scope_label: string open_applications: number expected_placements: string conversions: StageConversion[] unmeasured_stages?: string[] sample_floor: number } export function useGoals(teamID: string, owner: string) { const p = new URLSearchParams() if (teamID) p.set('team_id', teamID) if (owner) p.set('owner', owner) const qs = p.toString() return useQuery({ queryKey: ['recruitmatch', 'goals', teamID, owner], queryFn: () => request<{ goals: Goal[]; count: number }>(`/recruiting/goals${qs ? `?${qs}` : ''}`), }) } export function useForecast(teamID: string, owner: string) { const p = new URLSearchParams() if (teamID) p.set('team_id', teamID) if (owner) p.set('owner', owner) const qs = p.toString() return useQuery({ queryKey: ['recruitmatch', 'forecast', teamID, owner], queryFn: () => request(`/recruiting/forecast${qs ? `?${qs}` : ''}`), }) } export function useSetGoal() { const qc = useQueryClient() return useMutation({ mutationFn: (v: { scope: string subject_id?: string metric: GoalMetric period_start: string period_days: number target: string note?: string }) => request('/recruiting/goals', { method: 'POST', body: JSON.stringify(v) }), onSuccess: () => qc.invalidateQueries({ queryKey: ['recruitmatch', 'goals'] }), }) } // attainment is computed in the BROWSER from the figure already on screen, deliberately. The server does // not recompute the actual for a goal, because the number a manager is judged against must be the same // number they are looking at, and two independent computations of "placements this period" is exactly how // two screens end up disagreeing. export function attainment(actual: string, target: string): number | null { const t = Number(target) const a = Number(actual) if (!isFinite(t) || !isFinite(a) || t <= 0) return null return Math.round((a / t) * 100) }