diff --git a/BUILD_NOTES.md b/BUILD_NOTES.md new file mode 100644 index 0000000..719250c --- /dev/null +++ b/BUILD_NOTES.md @@ -0,0 +1,152 @@ +# SourceKit Recruiter OS — Build Notes + +## What Was Built + +A fully separate recruiter product experience at `/recruiter` within the existing SourceKit SPA. This is not a reskin — it's a distinct product boundary with its own layout, navigation, data model, routing, and screen copy, built on SourceKit infrastructure. + +**Product Name:** SourceKit Recruiter OS +**Route Prefix:** `/recruiter` +**Target Users:** Heads of Talent, technical recruiters, sourcers, founder-operators hiring AI-native technical talent + +## How to Access + +Navigate to `/recruiter` when authenticated. The original SourceKit app at `/` remains fully intact. + +## Architecture + +- **Route Group:** `/recruiter/*` route group inside existing SPA via React Router v6 +- **Lazy Loading:** All recruiter pages are lazy-loaded for code splitting +- **Isolation:** Separate layout component (`RecruiterLayout`), dedicated CSS scope (`.recruiter-os` class), own page components, hooks, services, and types +- **Shared Infra:** Supabase client, auth, React Query provider, shadcn/ui primitives, and existing API utilities are reused + +## Sections Built + +| Section | Route | Description | +|---------|-------|-------------| +| Command Center | `/recruiter` | Operational dashboard: stats, review queue, agent runs, scorecards, pipeline snapshot | +| Search Lab | `/recruiter/search` | Role-brief-driven search workspace with archetypes, signals, source toggles, search modes | +| Candidate Intel | `/recruiter/candidates` | Dense sortable/filterable candidate table | +| Candidate Profile | `/recruiter/candidates/:id` | Full profile with 6-dimension scoring, evidence tabs, outreach history, notes | +| Team Pipeline | `/recruiter/pipeline` | Board + table views with tier columns, stage grouping, bulk actions | +| Outreach Studio | `/recruiter/outreach` | Three-column outreach workspace: queue, editor with evidence grounding, candidate evidence | +| Role Scorecards | `/recruiter/scorecards` | CRUD for reusable role definitions with signals, weights, suppressions, eval questions | +| Scorecard Detail | `/recruiter/scorecards/:id` | Full scorecard editor with launch-search integration | +| Agent Runs | `/recruiter/agents` | Run log with expandable detail, error display, filtering, auto-refresh | +| Reports | `/recruiter/reports` | 8 report types with data adapters and graceful degradation | +| Settings | `/recruiter/settings` | ATS integration, API keys, notifications, scoring defaults, team, appearance | + +## Transparent Scoring Model + +Six scoring dimensions, each with evidence citations: +- **EEA Score** — Evidence of Exceptional Ability (USCIS criteria) +- **Builder Score** — Shipping velocity, ownership, project completion +- **AI Recency Score** — Recent frontier AI work +- **Systems Depth Score** — Infrastructure and architecture signals +- **Product Instinct Score** — User-facing craft and product thinking +- **Hidden Gem Score** — High proof-to-visibility ratio + +Scores are structured as `{ score: number, evidence: [], confidence: 'high'|'medium'|'low', reason: string }` and designed to be replaceable by backend-calculated values. + +## Files Created + +### Core Structure +``` +src/recruiter/ +├── RecruiterLayout.tsx # App shell with left nav +├── RecruiterNav.tsx # Persistent sidebar navigation +├── routes.tsx # Route definitions with lazy loading +├── pages/ +│ ├── CommandCenter.tsx # /recruiter +│ ├── SearchLab.tsx # /recruiter/search +│ ├── CandidateIntelList.tsx # /recruiter/candidates +│ ├── CandidateIntelProfile.tsx# /recruiter/candidates/:id +│ ├── TeamPipeline.tsx # /recruiter/pipeline +│ ├── OutreachStudio.tsx # /recruiter/outreach +│ ├── RoleScorecardList.tsx # /recruiter/scorecards +│ ├── RoleScorecardDetail.tsx # /recruiter/scorecards/:id +│ ├── AgentRuns.tsx # /recruiter/agents +│ ├── Reports.tsx # /recruiter/reports +│ └── RecruiterSettings.tsx # /recruiter/settings +├── components/ +│ ├── TierBadge.tsx # Tier classification badge +│ ├── ScoreCard.tsx # Score display with evidence expand +│ ├── StatusBadge.tsx # Stage, contact, run status, ATS, priority badges +│ ├── PageHeader.tsx # Page header with title and actions +│ ├── StatCard.tsx # Stat card for command center +│ └── EmptyState.tsx # Empty state pattern +├── hooks/ +│ ├── useRecruiterCandidates.ts# Candidate CRUD + pipeline stats +│ ├── useRecruiterScorecard.ts # Scorecard CRUD +│ ├── useAgentRuns.ts # Agent run queries + polling +│ ├── useRecruiterOutreach.ts # Outreach CRUD +│ ├── useRecruiterNotes.ts # Candidate notes +│ └── useRecruiterSavedSearches.ts # Saved search management +├── services/ +│ ├── scoring.ts # Client-side scoring utilities +│ └── export.ts # CSV export +├── lib/ +│ ├── types.ts # All TypeScript interfaces +│ └── constants.ts # Tier/stage/score/report configs +└── styles/ + └── recruiter-tokens.css # CSS custom properties + animations +``` + +### Database Migration +``` +supabase/migrations/20260323_recruiter_os_schema.sql +``` + +7 new tables: +- `recruiter_scorecards` — Role scorecard definitions +- `recruiter_candidates` — Extended candidate model with multi-surface data +- `recruiter_candidate_notes` — Recruiter notes per candidate +- `recruiter_outreach` — Outreach messages with grounding artifacts +- `recruiter_agent_runs` — Agent job tracking +- `recruiter_saved_searches` — Saved search configurations +- `recruiter_sequence_templates` — Outreach sequence templates + +All tables have: +- UUID primary keys +- User-scoped RLS policies +- Appropriate indexes +- Updated_at triggers + +## Files Modified + +| File | Change | +|------|--------| +| `src/App.tsx` | Added `/recruiter/*` route group with lazy-loaded RecruiterLayout | +| `tailwind.config.ts` | Added `ros-fade-in` keyframe and animation | +| `vite.config.ts` | Added recruiter chunk naming for code splitting | + +## Design System + +- **Dark mode default** (forced via `.recruiter-os` CSS scope) +- **Fonts:** DM Sans (body) + JetBrains Mono (labels, scores, data) +- **Accent:** `#00e5a0` (same as SourceKit primary) +- **Background:** `#0a0a0f` (deeper than default dark mode) +- **Information density:** Compact tables, small text, sticky headers +- **CSS variables:** All recruiter tokens scoped under `.recruiter-os` class + +## Backend Assumptions / TODOs + +1. **Database migration** needs to be applied to Supabase: `supabase db push` or run the SQL manually +2. **Search execution** — Search Lab UI is wired but `recruiter-search-orchestrator` edge function needs to be built to orchestrate multi-source search +3. **Scoring backend** — `recruiter-scoring` edge function needs to be built for server-side multi-dimensional scoring with Claude +4. **Outreach generation** — `recruiter-outreach-gen` edge function needs to be built for artifact-grounded message generation +5. **Enrichment** — `recruiter-enrichment` edge function for multi-surface candidate enrichment (LinkedIn, HuggingFace, web) +6. **ATS sync** — Webhook-based export is stubbed in Settings, needs `recruiter-ats-sync` edge function +7. **Dedup** — Cross-search deduplication needs `recruiter-dedup` edge function +8. **Reports** — Some report types show placeholder structures pending sufficient data +9. **Drag-and-drop** — Pipeline board uses explicit actions (not DnD) for reliability +10. **Team features** — Data model supports `user_id` scoping; multi-user/team features deferred + +## Running the App + +```bash +npm install +npm run dev +# Navigate to http://localhost:8080/recruiter +``` + +The original SourceKit app continues to work at `http://localhost:8080/`. diff --git a/src/App.tsx b/src/App.tsx index 9485a60..52e913f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,11 @@ import DeveloperProfile from "./pages/DeveloperProfile"; import Auth from "./pages/Auth"; import NotFound from "./pages/NotFound"; import { Analytics } from "@vercel/analytics/react"; +import { lazy, Suspense } from "react"; + +// Recruiter OS — lazy-loaded route group +const RecruiterLayout = lazy(() => import("./recruiter/RecruiterLayout")); +import { recruiterRoutes } from "./recruiter/routes"; const queryClient = new QueryClient(); @@ -68,6 +73,17 @@ const App = () => { <> } /> } /> + +
+
+ }> + + + }> + {recruiterRoutes} +
} /> } /> diff --git a/src/recruiter/RecruiterLayout.tsx b/src/recruiter/RecruiterLayout.tsx new file mode 100644 index 0000000..d294c12 --- /dev/null +++ b/src/recruiter/RecruiterLayout.tsx @@ -0,0 +1,59 @@ +import { Outlet } from 'react-router-dom'; +import { useState } from 'react'; +import { Menu, X } from 'lucide-react'; +import RecruiterNav from './RecruiterNav'; +import '../recruiter/styles/recruiter-tokens.css'; + +export default function RecruiterLayout() { + const [mobileOpen, setMobileOpen] = useState(false); + + return ( +
+ {/* Desktop sidebar */} + + + {/* Mobile sidebar */} + {mobileOpen && ( + <> +
setMobileOpen(false)} + /> + + + )} + + {/* Main content */} +
+ {/* Mobile header */} +
+ +
+
+ SK +
+ Recruiter OS +
+
+ +
+ +
+
+
+ ); +} diff --git a/src/recruiter/RecruiterNav.tsx b/src/recruiter/RecruiterNav.tsx new file mode 100644 index 0000000..7bfeed8 --- /dev/null +++ b/src/recruiter/RecruiterNav.tsx @@ -0,0 +1,99 @@ +import { useLocation, useNavigate } from 'react-router-dom'; +import { + LayoutDashboard, Search, Users, Kanban, Mail, + ClipboardList, Bot, BarChart2, Settings, LogOut, +} from 'lucide-react'; +import { supabase } from '@/integrations/supabase/client'; + +const NAV_ITEMS = [ + { path: '/recruiter', label: 'Command Center', icon: LayoutDashboard, exact: true }, + { path: '/recruiter/search', label: 'Search Lab', icon: Search }, + { path: '/recruiter/candidates', label: 'Candidate Intel', icon: Users }, + { path: '/recruiter/pipeline', label: 'Team Pipeline', icon: Kanban }, + { path: '/recruiter/outreach', label: 'Outreach Studio', icon: Mail }, + { path: '/recruiter/scorecards', label: 'Role Scorecards', icon: ClipboardList }, + { path: '/recruiter/agents', label: 'Agent Runs', icon: Bot }, + { path: '/recruiter/reports', label: 'Reports', icon: BarChart2 }, + { path: '/recruiter/settings', label: 'Settings', icon: Settings }, +] as const; + +export default function RecruiterNav() { + const location = useLocation(); + const navigate = useNavigate(); + + const isActive = (item: typeof NAV_ITEMS[number]) => { + if (item.exact) return location.pathname === item.path; + return location.pathname.startsWith(item.path); + }; + + return ( +
+ {/* Logo */} +
+
+ SK +
+
+ + SourceKit + + + Recruiter OS + +
+
+ + {/* Navigation */} + + + {/* Bottom */} +
+ + +
+
+ ); +} diff --git a/src/recruiter/components/EmptyState.tsx b/src/recruiter/components/EmptyState.tsx new file mode 100644 index 0000000..57b3fdf --- /dev/null +++ b/src/recruiter/components/EmptyState.tsx @@ -0,0 +1,19 @@ +import type { ReactNode } from 'react'; + +interface EmptyStateProps { + icon?: ReactNode; + title: string; + description: string; + action?: ReactNode; +} + +export default function EmptyState({ icon, title, description, action }: EmptyStateProps) { + return ( +
+ {icon &&
{icon}
} +

{title}

+

{description}

+ {action &&
{action}
} +
+ ); +} diff --git a/src/recruiter/components/PageHeader.tsx b/src/recruiter/components/PageHeader.tsx new file mode 100644 index 0000000..f48b567 --- /dev/null +++ b/src/recruiter/components/PageHeader.tsx @@ -0,0 +1,25 @@ +import type { ReactNode } from 'react'; + +interface PageHeaderProps { + title: string; + subtitle?: string; + actions?: ReactNode; +} + +export default function PageHeader({ title, subtitle, actions }: PageHeaderProps) { + return ( +
+
+

+ {title} +

+ {subtitle && ( +

+ {subtitle} +

+ )} +
+ {actions &&
{actions}
} +
+ ); +} diff --git a/src/recruiter/components/ScoreCard.tsx b/src/recruiter/components/ScoreCard.tsx new file mode 100644 index 0000000..7aab6cd --- /dev/null +++ b/src/recruiter/components/ScoreCard.tsx @@ -0,0 +1,105 @@ +import { useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { getScoreColor, getScoreBgColor } from '../services/scoring'; +import { SCORE_DIMENSIONS } from '../lib/constants'; +import type { DimensionScore, ScoreDimension } from '../lib/types'; + +interface ScoreCardProps { + dimension: ScoreDimension; + score: DimensionScore | null; + compact?: boolean; +} + +export default function ScoreCard({ dimension, score, compact }: ScoreCardProps) { + const [expanded, setExpanded] = useState(false); + const config = SCORE_DIMENSIONS[dimension]; + const value = score?.score ?? 0; + const color = getScoreColor(value); + const bgColor = getScoreBgColor(value); + + if (compact) { + return ( +
+ {config.label} + {value} +
+ ); + } + + return ( +
+
+ + {config.label} + + +
+ +
+ {value} + /100 +
+ + {score?.reason && ( +

+ {score.reason} +

+ )} + + {/* Confidence indicator */} + {score && ( +
+
+ {[1, 2, 3].map(i => ( +
+ ))} +
+ + {score.confidence} + +
+ )} + + {/* Evidence expansion */} + {expanded && score?.evidence && score.evidence.length > 0 && ( +
+ {score.evidence.map((ev, i) => ( +
+
+ + {ev.source} + + {ev.artifact} +
+

{ev.detail}

+ {ev.url && ( + + {ev.url} + + )} +
+ ))} +
+ )} + + {expanded && (!score?.evidence || score.evidence.length === 0) && ( +

+ No evidence indexed yet. Run enrichment to surface artifacts. +

+ )} +
+ ); +} diff --git a/src/recruiter/components/StatCard.tsx b/src/recruiter/components/StatCard.tsx new file mode 100644 index 0000000..342f738 --- /dev/null +++ b/src/recruiter/components/StatCard.tsx @@ -0,0 +1,30 @@ +interface StatCardProps { + label: string; + value: string | number; + sublabel?: string; + accent?: boolean; +} + +export default function StatCard({ label, value, sublabel, accent }: StatCardProps) { + return ( +
+

+ {label} +

+

+ {value} +

+ {sublabel && ( +

+ {sublabel} +

+ )} +
+ ); +} diff --git a/src/recruiter/components/StatusBadge.tsx b/src/recruiter/components/StatusBadge.tsx new file mode 100644 index 0000000..ce4b2e2 --- /dev/null +++ b/src/recruiter/components/StatusBadge.tsx @@ -0,0 +1,79 @@ +import { STAGE_CONFIG, CONTACT_STATUS_CONFIG, AGENT_RUN_STATUS_CONFIG } from '../lib/constants'; +import type { PipelineStage, ContactStatus, AgentRunStatus, ATSSyncStatus } from '../lib/types'; + +interface StageBadgeProps { + stage: PipelineStage; +} + +export function StageBadge({ stage }: StageBadgeProps) { + const config = STAGE_CONFIG[stage]; + return ( + + {config.label} + + ); +} + +interface ContactBadgeProps { + status: ContactStatus; +} + +export function ContactBadge({ status }: ContactBadgeProps) { + const config = CONTACT_STATUS_CONFIG[status]; + return ( + + {config.label} + + ); +} + +interface RunStatusBadgeProps { + status: AgentRunStatus; +} + +export function RunStatusBadge({ status }: RunStatusBadgeProps) { + const config = AGENT_RUN_STATUS_CONFIG[status]; + return ( + + {status === 'running' && } + {config.label} + + ); +} + +interface ATSBadgeProps { + status: ATSSyncStatus; +} + +export function ATSBadge({ status }: ATSBadgeProps) { + const colors: Record = { + not_synced: 'text-zinc-500', + synced: 'text-blue-400', + sync_failed: 'text-red-400', + }; + const labels: Record = { + not_synced: 'Not synced', + synced: 'ATS synced', + sync_failed: 'Sync failed', + }; + return ( + {labels[status]} + ); +} + +interface PriorityBadgeProps { + priority: 'high' | 'medium' | 'low'; +} + +export function PriorityBadge({ priority }: PriorityBadgeProps) { + const colors = { + high: 'text-emerald-400 bg-emerald-500/15', + medium: 'text-amber-400 bg-amber-500/15', + low: 'text-zinc-400 bg-zinc-500/15', + }; + return ( + + {priority} + + ); +} diff --git a/src/recruiter/components/TierBadge.tsx b/src/recruiter/components/TierBadge.tsx new file mode 100644 index 0000000..7430446 --- /dev/null +++ b/src/recruiter/components/TierBadge.tsx @@ -0,0 +1,18 @@ +import { TIER_CONFIG } from '../lib/constants'; +import type { CandidateTier } from '../lib/types'; + +interface TierBadgeProps { + tier: CandidateTier; + size?: 'sm' | 'md'; +} + +export default function TierBadge({ tier, size = 'sm' }: TierBadgeProps) { + const config = TIER_CONFIG[tier]; + const sizeClasses = size === 'sm' ? 'text-[10px] px-1.5 py-0.5' : 'text-xs px-2 py-1'; + + return ( + + {config.label} + + ); +} diff --git a/src/recruiter/hooks/useAgentRuns.ts b/src/recruiter/hooks/useAgentRuns.ts new file mode 100644 index 0000000..e4f5e09 --- /dev/null +++ b/src/recruiter/hooks/useAgentRuns.ts @@ -0,0 +1,87 @@ +// SourceKit Recruiter OS — Agent Runs hook + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { AgentRun, AgentRunType, AgentRunStatus } from '../lib/types'; + +const TABLE = 'recruiter_agent_runs'; + +async function getUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.user?.id) throw new Error('Not authenticated'); + return session.user.id; +} + +export function useAgentRuns(filters?: { + type?: AgentRunType; + status?: AgentRunStatus; + limit?: number; +}) { + return useQuery({ + queryKey: ['recruiter-agent-runs', filters], + queryFn: async () => { + const userId = await getUserId(); + let query = supabase + .from(TABLE) + .select('*') + .eq('user_id', userId) + .order('started_at', { ascending: false }); + + if (filters?.type) query = query.eq('type', filters.type); + if (filters?.status) query = query.eq('status', filters.status); + if (filters?.limit) query = query.limit(filters.limit); + + const { data, error } = await query; + if (error) throw error; + return (data ?? []) as AgentRun[]; + }, + refetchInterval: 10000, // Poll every 10s for running jobs + }); +} + +export function useCreateAgentRun() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (run: Partial) => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .insert({ + user_id: userId, + type: run.type ?? 'search', + status: 'running', + inputs: run.inputs ?? {}, + outputs: {}, + errors: [], + candidate_ids: [], + scorecard_id: run.scorecard_id ?? null, + }) + .select() + .single(); + if (error) throw error; + return data as AgentRun; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-agent-runs'] }); + }, + }); +} + +export function useUpdateAgentRun() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => { + const { data, error } = await supabase + .from(TABLE) + .update(updates) + .eq('id', id) + .select() + .single(); + if (error) throw error; + return data as AgentRun; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-agent-runs'] }); + }, + }); +} diff --git a/src/recruiter/hooks/useRecruiterCandidates.ts b/src/recruiter/hooks/useRecruiterCandidates.ts new file mode 100644 index 0000000..a2650d1 --- /dev/null +++ b/src/recruiter/hooks/useRecruiterCandidates.ts @@ -0,0 +1,130 @@ +// SourceKit Recruiter OS — Candidate data hook +// Connects to recruiter_candidates table via Supabase + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { RecruiterCandidate, CandidateTier, PipelineStage, ContactStatus } from '../lib/types'; + +const TABLE = 'recruiter_candidates'; + +async function getUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.user?.id) throw new Error('Not authenticated'); + return session.user.id; +} + +// Fetch all candidates for the current user +export function useRecruiterCandidates(filters?: { + tier?: CandidateTier; + stage?: PipelineStage; + tags?: string[]; + needs_review?: boolean; +}) { + return useQuery({ + queryKey: ['recruiter-candidates', filters], + queryFn: async () => { + const userId = await getUserId(); + let query = supabase + .from(TABLE) + .select('*') + .eq('user_id', userId) + .order('composite_score', { ascending: false }); + + if (filters?.tier) query = query.eq('tier', filters.tier); + if (filters?.stage) query = query.eq('pipeline_stage', filters.stage); + if (filters?.needs_review) query = query.eq('needs_review', true); + if (filters?.tags?.length) query = query.overlaps('tags', filters.tags); + + const { data, error } = await query; + if (error) throw error; + return (data ?? []) as RecruiterCandidate[]; + }, + }); +} + +// Fetch a single candidate by ID +export function useRecruiterCandidate(id: string | undefined) { + return useQuery({ + queryKey: ['recruiter-candidate', id], + queryFn: async () => { + if (!id) return null; + const { data, error } = await supabase + .from(TABLE) + .select('*') + .eq('id', id) + .single(); + if (error) throw error; + return data as RecruiterCandidate; + }, + enabled: !!id, + }); +} + +// Update candidate fields +export function useUpdateCandidate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => { + const { data, error } = await supabase + .from(TABLE) + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + if (error) throw error; + return data as RecruiterCandidate; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-candidates'] }); + qc.invalidateQueries({ queryKey: ['recruiter-candidate'] }); + }, + }); +} + +// Create a new candidate +export function useCreateCandidate() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (candidate: Partial) => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .insert({ ...candidate, user_id: userId }) + .select() + .single(); + if (error) throw error; + return data as RecruiterCandidate; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-candidates'] }); + }, + }); +} + +// Pipeline stats for command center +export function useRecruiterPipelineStats() { + return useQuery({ + queryKey: ['recruiter-pipeline-stats'], + queryFn: async () => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .select('tier, pipeline_stage, contact_status, needs_review, ats_sync_status') + .eq('user_id', userId); + if (error) throw error; + + const candidates = data ?? []; + return { + total: candidates.length, + tier_1: candidates.filter(c => c.tier === 'tier_1').length, + tier_2: candidates.filter(c => c.tier === 'tier_2').length, + borderline: candidates.filter(c => c.tier === 'borderline').length, + below_bar: candidates.filter(c => c.tier === 'below_bar').length, + needs_review: candidates.filter(c => c.needs_review).length, + outreach_sent: candidates.filter(c => c.pipeline_stage === 'outreach_sent').length, + ats_synced: candidates.filter(c => c.ats_sync_status === 'synced').length, + hidden_gems: candidates.filter(c => c.tier === 'tier_1' || c.tier === 'tier_2').length, // TODO: use hidden_gem_score + }; + }, + }); +} diff --git a/src/recruiter/hooks/useRecruiterNotes.ts b/src/recruiter/hooks/useRecruiterNotes.ts new file mode 100644 index 0000000..b26ab8e --- /dev/null +++ b/src/recruiter/hooks/useRecruiterNotes.ts @@ -0,0 +1,51 @@ +// SourceKit Recruiter OS — Candidate Notes hook + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { CandidateNote } from '../lib/types'; + +const TABLE = 'recruiter_candidate_notes'; + +async function getUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.user?.id) throw new Error('Not authenticated'); + return session.user.id; +} + +export function useRecruiterNotes(candidateId: string | undefined) { + return useQuery({ + queryKey: ['recruiter-notes', candidateId], + queryFn: async () => { + if (!candidateId) return []; + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .select('*') + .eq('user_id', userId) + .eq('candidate_id', candidateId) + .order('created_at', { ascending: false }); + if (error) throw error; + return (data ?? []) as CandidateNote[]; + }, + enabled: !!candidateId, + }); +} + +export function useCreateNote() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ candidateId, content }: { candidateId: string; content: string }) => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .insert({ user_id: userId, candidate_id: candidateId, content }) + .select() + .single(); + if (error) throw error; + return data as CandidateNote; + }, + onSuccess: (_, { candidateId }) => { + qc.invalidateQueries({ queryKey: ['recruiter-notes', candidateId] }); + }, + }); +} diff --git a/src/recruiter/hooks/useRecruiterOutreach.ts b/src/recruiter/hooks/useRecruiterOutreach.ts new file mode 100644 index 0000000..94c1ce5 --- /dev/null +++ b/src/recruiter/hooks/useRecruiterOutreach.ts @@ -0,0 +1,81 @@ +// SourceKit Recruiter OS — Outreach hook + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { OutreachMessage } from '../lib/types'; + +const TABLE = 'recruiter_outreach'; + +async function getUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.user?.id) throw new Error('Not authenticated'); + return session.user.id; +} + +export function useRecruiterOutreach(candidateId?: string) { + return useQuery({ + queryKey: ['recruiter-outreach', candidateId], + queryFn: async () => { + const userId = await getUserId(); + let query = supabase + .from(TABLE) + .select('*') + .eq('user_id', userId) + .order('created_at', { ascending: false }); + + if (candidateId) query = query.eq('candidate_id', candidateId); + + const { data, error } = await query; + if (error) throw error; + return (data ?? []) as OutreachMessage[]; + }, + }); +} + +export function useCreateOutreach() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (outreach: Partial) => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .insert({ + user_id: userId, + candidate_id: outreach.candidate_id, + scorecard_id: outreach.scorecard_id ?? null, + message: outreach.message ?? '', + grounding_artifacts: outreach.grounding_artifacts ?? [], + tone: outreach.tone ?? 'professional', + sequence_step: outreach.sequence_step ?? 'initial', + channel: outreach.channel ?? 'email', + status: outreach.status ?? 'draft', + }) + .select() + .single(); + if (error) throw error; + return data as OutreachMessage; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-outreach'] }); + }, + }); +} + +export function useUpdateOutreach() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => { + const { data, error } = await supabase + .from(TABLE) + .update(updates) + .eq('id', id) + .select() + .single(); + if (error) throw error; + return data as OutreachMessage; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-outreach'] }); + }, + }); +} diff --git a/src/recruiter/hooks/useRecruiterSavedSearches.ts b/src/recruiter/hooks/useRecruiterSavedSearches.ts new file mode 100644 index 0000000..8c05061 --- /dev/null +++ b/src/recruiter/hooks/useRecruiterSavedSearches.ts @@ -0,0 +1,53 @@ +// SourceKit Recruiter OS — Saved Searches hook + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { SavedSearch, RecruiterSearchConfig } from '../lib/types'; + +const TABLE = 'recruiter_saved_searches'; + +async function getUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.user?.id) throw new Error('Not authenticated'); + return session.user.id; +} + +export function useRecruiterSavedSearches() { + return useQuery({ + queryKey: ['recruiter-saved-searches'], + queryFn: async () => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .select('*') + .eq('user_id', userId) + .order('updated_at', { ascending: false }); + if (error) throw error; + return (data ?? []) as SavedSearch[]; + }, + }); +} + +export function useSaveSearch() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ name, config, scorecardId }: { name: string; config: RecruiterSearchConfig; scorecardId?: string }) => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .insert({ + user_id: userId, + name, + scorecard_id: scorecardId ?? null, + config, + }) + .select() + .single(); + if (error) throw error; + return data as SavedSearch; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-saved-searches'] }); + }, + }); +} diff --git a/src/recruiter/hooks/useRecruiterScorecard.ts b/src/recruiter/hooks/useRecruiterScorecard.ts new file mode 100644 index 0000000..61de2b5 --- /dev/null +++ b/src/recruiter/hooks/useRecruiterScorecard.ts @@ -0,0 +1,109 @@ +// SourceKit Recruiter OS — Scorecard CRUD hook + +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; +import { supabase } from '@/integrations/supabase/client'; +import type { RoleScorecard } from '../lib/types'; + +const TABLE = 'recruiter_scorecards'; + +async function getUserId(): Promise { + const { data: { session } } = await supabase.auth.getSession(); + if (!session?.user?.id) throw new Error('Not authenticated'); + return session.user.id; +} + +export function useRecruiterScorecards() { + return useQuery({ + queryKey: ['recruiter-scorecards'], + queryFn: async () => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .select('*') + .eq('user_id', userId) + .order('updated_at', { ascending: false }); + if (error) throw error; + return (data ?? []) as RoleScorecard[]; + }, + }); +} + +export function useRecruiterScorecard(id: string | undefined) { + return useQuery({ + queryKey: ['recruiter-scorecard', id], + queryFn: async () => { + if (!id) return null; + const { data, error } = await supabase + .from(TABLE) + .select('*') + .eq('id', id) + .single(); + if (error) throw error; + return data as RoleScorecard; + }, + enabled: !!id, + }); +} + +export function useCreateScorecard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (scorecard: Partial) => { + const userId = await getUserId(); + const { data, error } = await supabase + .from(TABLE) + .insert({ + user_id: userId, + name: scorecard.name ?? 'Untitled Scorecard', + status: scorecard.status ?? 'draft', + talent_thesis: scorecard.talent_thesis ?? '', + must_have_signals: scorecard.must_have_signals ?? [], + nice_to_have_signals: scorecard.nice_to_have_signals ?? [], + suppressions: scorecard.suppressions ?? [], + scoring_weights: scorecard.scoring_weights ?? { eea: 25, builder: 20, ai_recency: 20, systems_depth: 15, product_instinct: 10, hidden_gem: 10 }, + outreach_tone: scorecard.outreach_tone ?? 'professional', + evaluation_questions: scorecard.evaluation_questions ?? [], + }) + .select() + .single(); + if (error) throw error; + return data as RoleScorecard; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-scorecards'] }); + }, + }); +} + +export function useUpdateScorecard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, updates }: { id: string; updates: Partial }) => { + const { data, error } = await supabase + .from(TABLE) + .update({ ...updates, updated_at: new Date().toISOString() }) + .eq('id', id) + .select() + .single(); + if (error) throw error; + return data as RoleScorecard; + }, + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: ['recruiter-scorecards'] }); + qc.invalidateQueries({ queryKey: ['recruiter-scorecard', id] }); + }, + }); +} + +export function useDeleteScorecard() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + const { error } = await supabase.from(TABLE).delete().eq('id', id); + if (error) throw error; + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recruiter-scorecards'] }); + }, + }); +} diff --git a/src/recruiter/lib/constants.ts b/src/recruiter/lib/constants.ts new file mode 100644 index 0000000..3ee9b87 --- /dev/null +++ b/src/recruiter/lib/constants.ts @@ -0,0 +1,168 @@ +// SourceKit Recruiter OS — Constants + +import type { CandidateTier, PipelineStage, ContactStatus, AgentRunType, AgentRunStatus, ScoreDimension, ReportType } from './types'; + +// --- Tier Configuration --- + +export const TIER_CONFIG: Record = { + tier_1: { label: 'Tier 1', color: 'text-emerald-400', bgColor: 'bg-emerald-500/15', borderColor: 'border-emerald-500/30', order: 0 }, + tier_2: { label: 'Tier 2', color: 'text-indigo-400', bgColor: 'bg-indigo-500/15', borderColor: 'border-indigo-500/30', order: 1 }, + borderline: { label: 'Borderline', color: 'text-yellow-400', bgColor: 'bg-yellow-500/15', borderColor: 'border-yellow-500/30', order: 2 }, + below_bar: { label: 'Below Bar', color: 'text-red-400', bgColor: 'bg-red-500/15', borderColor: 'border-red-500/30', order: 3 }, + false_positive: { label: 'False Positive', color: 'text-zinc-400', bgColor: 'bg-zinc-500/15', borderColor: 'border-zinc-500/30', order: 4 }, + nurture: { label: 'Nurture', color: 'text-violet-400', bgColor: 'bg-violet-500/15', borderColor: 'border-violet-500/30', order: 5 }, + ats_synced: { label: 'ATS Synced', color: 'text-blue-400', bgColor: 'bg-blue-500/15', borderColor: 'border-blue-500/30', order: 6 }, + unclassified: { label: 'Unclassified', color: 'text-zinc-500', bgColor: 'bg-zinc-500/10', borderColor: 'border-zinc-500/20', order: 7 }, +}; + +export const PIPELINE_TIERS: CandidateTier[] = ['tier_1', 'tier_2', 'borderline', 'below_bar']; + +// --- Pipeline Stages --- + +export const STAGE_CONFIG: Record = { + new: { label: 'New', color: 'text-sky-400', bgColor: 'bg-sky-500/15', borderColor: 'border-sky-500/30', order: 0 }, + under_review: { label: 'Under Review', color: 'text-amber-400', bgColor: 'bg-amber-500/15', borderColor: 'border-amber-500/30', order: 1 }, + outreach_sent: { label: 'Outreach Sent', color: 'text-indigo-400', bgColor: 'bg-indigo-500/15', borderColor: 'border-indigo-500/30', order: 2 }, + replied: { label: 'Replied', color: 'text-emerald-400', bgColor: 'bg-emerald-500/15', borderColor: 'border-emerald-500/30', order: 3 }, + screening: { label: 'Screening', color: 'text-purple-400', bgColor: 'bg-purple-500/15', borderColor: 'border-purple-500/30', order: 4 }, + moved_to_ats: { label: 'Moved to ATS', color: 'text-blue-400', bgColor: 'bg-blue-500/15', borderColor: 'border-blue-500/30', order: 5 }, + hold: { label: 'On Hold', color: 'text-zinc-400', bgColor: 'bg-zinc-500/15', borderColor: 'border-zinc-500/30', order: 6 }, + suppressed: { label: 'Suppressed', color: 'text-red-400', bgColor: 'bg-red-500/15', borderColor: 'border-red-500/30', order: 7 }, +}; + +// --- Contact Status --- + +export const CONTACT_STATUS_CONFIG: Record = { + not_contacted: { label: 'Not Contacted', color: 'text-zinc-500', icon: 'circle' }, + contacted: { label: 'Contacted', color: 'text-amber-400', icon: 'send' }, + replied: { label: 'Replied', color: 'text-emerald-400', icon: 'message-circle' }, + not_interested: { label: 'Not Interested', color: 'text-red-400', icon: 'x-circle' }, + scheduled: { label: 'Scheduled', color: 'text-blue-400', icon: 'calendar' }, +}; + +// --- Score Dimensions --- + +export const SCORE_DIMENSIONS: Record = { + eea: { label: 'EEA Score', description: 'Evidence of Exceptional Ability across USCIS criteria', icon: 'award' }, + builder: { label: 'Builder Score', description: 'Shipping velocity, ownership signals, project completion', icon: 'hammer' }, + ai_recency: { label: 'AI Recency', description: 'Recent work with frontier AI tools, models, and frameworks', icon: 'sparkles' }, + systems_depth: { label: 'Systems Depth', description: 'Infrastructure, architecture, and low-level engineering', icon: 'layers' }, + product_instinct: { label: 'Product Instinct', description: 'User-facing craft, design sense, product thinking', icon: 'lightbulb' }, + hidden_gem: { label: 'Hidden Gem', description: 'High proof-to-visibility ratio, underrecognized talent', icon: 'gem' }, +}; + +// --- Score Thresholds --- + +export const SCORE_THRESHOLDS = { + high: 80, + medium: 50, + low: 0, +} as const; + +// --- Agent Run Types --- + +export const AGENT_RUN_TYPE_CONFIG: Record = { + search: { label: 'Search', icon: 'search' }, + enrichment: { label: 'Enrichment', icon: 'database' }, + scoring: { label: 'Scoring', icon: 'bar-chart-2' }, + outreach_generation: { label: 'Outreach Generation', icon: 'mail' }, + company_mapping: { label: 'Company Mapping', icon: 'building-2' }, + monitor: { label: 'Monitor', icon: 'eye' }, + dedup_ats_sync: { label: 'Dedup / ATS Sync', icon: 'git-merge' }, +}; + +export const AGENT_RUN_STATUS_CONFIG: Record = { + running: { label: 'Running', color: 'text-blue-400', bgColor: 'bg-blue-500/15' }, + complete: { label: 'Complete', color: 'text-emerald-400', bgColor: 'bg-emerald-500/15' }, + failed: { label: 'Failed', color: 'text-red-400', bgColor: 'bg-red-500/15' }, + partial: { label: 'Partial', color: 'text-amber-400', bgColor: 'bg-amber-500/15' }, +}; + +// --- Report Types --- + +export const REPORT_CONFIG: Record = { + candidate_volume_by_tier: { label: 'Candidate Volume by Tier', description: 'Tier distribution over time', icon: 'bar-chart-2' }, + source_quality: { label: 'Source Quality', description: 'Which surfaces produce top-tier candidates', icon: 'target' }, + artifact_recency: { label: 'Artifact Recency', description: 'Evidence freshness across the pipeline', icon: 'clock' }, + response_rates: { label: 'Response Rates', description: 'Reply rates by sequence and tone', icon: 'mail-check' }, + hidden_gem_yield: { label: 'Hidden Gem Yield', description: 'Hidden gem conversion vs. standard candidates', icon: 'gem' }, + recruiter_throughput: { label: 'Recruiter Throughput', description: 'Candidates processed per week', icon: 'trending-up' }, + ats_export_volume: { label: 'ATS Export Volume', description: 'Candidates pushed to ATS by role', icon: 'upload' }, + stale_candidates: { label: 'Stale Candidates', description: 'Candidates with no activity in 14+ days', icon: 'alert-triangle' }, +}; + +// --- Default Scoring Weights --- + +export const DEFAULT_SCORING_WEIGHTS = { + eea: 25, + builder: 20, + ai_recency: 20, + systems_depth: 15, + product_instinct: 10, + hidden_gem: 10, +} as const; + +// --- Default Search Config --- + +export const DEFAULT_SEARCH_SOURCES = { + github: true, + linkedin: false, + web_blog: false, + huggingface: false, + conference_talks: false, + company_mapping: false, + exa_websets: false, +} as const; + +export const DEFAULT_SEARCH_MODES = { + standard: true, + hidden_gem: false, + company_mapping: false, + artifact_led: false, +} as const; + +// --- Artifact Types --- + +export const ARTIFACT_TYPE_CONFIG = { + repo: { label: 'Repository', icon: 'git-branch', color: 'text-emerald-400' }, + paper: { label: 'Paper', icon: 'file-text', color: 'text-blue-400' }, + blog_post: { label: 'Blog Post', icon: 'pen-tool', color: 'text-purple-400' }, + talk: { label: 'Talk', icon: 'mic', color: 'text-amber-400' }, + demo: { label: 'Demo', icon: 'play-circle', color: 'text-pink-400' }, + model: { label: 'Model', icon: 'cpu', color: 'text-indigo-400' }, + package: { label: 'Package', icon: 'package', color: 'text-teal-400' }, + other: { label: 'Other', icon: 'link', color: 'text-zinc-400' }, +} as const; + +// --- Outreach Tones --- + +export const OUTREACH_TONES = [ + { id: 'professional', label: 'Professional' }, + { id: 'warm', label: 'Warm' }, + { id: 'direct', label: 'Direct' }, + { id: 'technical', label: 'Technical' }, +] as const; + +export const SEQUENCE_STEPS = [ + { id: 'initial', label: 'Initial Outreach' }, + { id: 'follow_up_1', label: 'Follow-up 1' }, + { id: 'follow_up_2', label: 'Follow-up 2' }, + { id: 'breakup', label: 'Breakup' }, +] as const; + +// --- Archetype Suggestions --- + +export const SUGGESTED_ARCHETYPES = [ + 'ML infra builder', + 'Full-stack AI founder', + 'Systems generalist with AI pivot', + 'Applied ML engineer', + 'LLM fine-tuning specialist', + 'AI product engineer', + 'Data platform architect', + 'Robotics / embodied AI', + 'Research engineer (NLP)', + 'Multimodal ML engineer', + 'MLOps / ML platform', + 'Compiler / runtime engineer', +] as const; diff --git a/src/recruiter/lib/types.ts b/src/recruiter/lib/types.ts new file mode 100644 index 0000000..445d888 --- /dev/null +++ b/src/recruiter/lib/types.ts @@ -0,0 +1,310 @@ +// SourceKit Recruiter OS — Core Type Definitions + +// --- Scoring --- + +export interface ScoreEvidence { + source: string; // e.g. "github", "linkedin", "blog", "huggingface" + artifact: string; + url?: string; + detail: string; +} + +export interface DimensionScore { + score: number; // 0-100 + evidence: ScoreEvidence[]; + confidence: 'high' | 'medium' | 'low'; + reason: string; +} + +export interface RecruiterScores { + eea: DimensionScore; + builder: DimensionScore; + ai_recency: DimensionScore; + systems_depth: DimensionScore; + product_instinct: DimensionScore; + hidden_gem: DimensionScore; +} + +export type ScoreDimension = keyof RecruiterScores; + +// --- Tiering --- + +export type CandidateTier = + | 'tier_1' + | 'tier_2' + | 'borderline' + | 'below_bar' + | 'false_positive' + | 'nurture' + | 'ats_synced' + | 'unclassified'; + +export type PipelineStage = + | 'new' + | 'under_review' + | 'outreach_sent' + | 'replied' + | 'screening' + | 'moved_to_ats' + | 'hold' + | 'suppressed'; + +export type ContactStatus = + | 'not_contacted' + | 'contacted' + | 'replied' + | 'not_interested' + | 'scheduled'; + +export type ATSSyncStatus = 'not_synced' | 'synced' | 'sync_failed'; + +export type OutreachPriority = 'high' | 'medium' | 'low'; + +// --- Candidate --- + +export interface RecruiterCandidate { + id: string; + user_id: string; + + // Identity + name: string | null; + avatar_url: string | null; + current_title: string | null; + current_company: string | null; + location: string | null; + bio: string | null; + + // Links + github_username: string | null; + linkedin_url: string | null; + portfolio_url: string | null; + huggingface_url: string | null; + blog_url: string | null; + personal_site_url: string | null; + email: string | null; + + // Scores + eea_score: DimensionScore | null; + builder_score: DimensionScore | null; + ai_recency_score: DimensionScore | null; + systems_depth_score: DimensionScore | null; + product_instinct_score: DimensionScore | null; + hidden_gem_score: DimensionScore | null; + composite_score: number | null; + outreach_priority: OutreachPriority; + + // Classification + tier: CandidateTier; + pipeline_stage: PipelineStage; + + // Artifacts & evidence + artifacts: CandidateArtifact[]; + sourcing_rationale: string | null; + hidden_gem_reasons: string[]; + + // Contact + contact_status: ContactStatus; + + // Meta + tags: string[]; + needs_review: boolean; + search_ids: string[]; + scorecard_id: string | null; + ats_sync_status: ATSSyncStatus; + ats_synced_at: string | null; + created_at: string; + updated_at: string; +} + +export interface CandidateArtifact { + id: string; + type: 'repo' | 'paper' | 'blog_post' | 'talk' | 'demo' | 'model' | 'package' | 'other'; + title: string; + url: string; + source: string; + date: string | null; + relevance: string; + description: string; +} + +// --- Scorecard --- + +export interface ScorecardSignal { + id: string; + name: string; + description: string; + weight: number; // 0-100 + evidence_type: string; // github, linkedin, publication, etc. +} + +export interface ScoringWeights { + eea: number; + builder: number; + ai_recency: number; + systems_depth: number; + product_instinct: number; + hidden_gem: number; +} + +export interface EvaluationQuestion { + id: string; + text: string; + dimension: string; + good_answer: string; + bad_answer: string; +} + +export type ScorecardStatus = 'draft' | 'active' | 'archived'; + +export interface RoleScorecard { + id: string; + user_id: string; + name: string; + status: ScorecardStatus; + talent_thesis: string | null; + must_have_signals: ScorecardSignal[]; + nice_to_have_signals: ScorecardSignal[]; + suppressions: string[]; + scoring_weights: ScoringWeights; + outreach_tone: string; + evaluation_questions: EvaluationQuestion[]; + created_at: string; + updated_at: string; +} + +// --- Outreach --- + +export type OutreachStatus = 'draft' | 'ready' | 'personalize' | 'hold' | 'sent' | 'replied'; +export type OutreachChannel = 'email' | 'linkedin' | 'other'; +export type SequenceStep = 'initial' | 'follow_up_1' | 'follow_up_2' | 'breakup'; + +export interface OutreachMessage { + id: string; + user_id: string; + candidate_id: string; + scorecard_id: string | null; + message: string; + grounding_artifacts: ScoreEvidence[]; + tone: string; + sequence_step: SequenceStep; + channel: OutreachChannel; + status: OutreachStatus; + sent_at: string | null; + created_at: string; +} + +// --- Agent Runs --- + +export type AgentRunType = + | 'search' + | 'enrichment' + | 'scoring' + | 'outreach_generation' + | 'company_mapping' + | 'monitor' + | 'dedup_ats_sync'; + +export type AgentRunStatus = 'running' | 'complete' | 'failed' | 'partial'; + +export interface AgentRun { + id: string; + user_id: string; + type: AgentRunType; + status: AgentRunStatus; + inputs: Record; + outputs: Record; + errors: Array<{ message: string; timestamp: string; context?: string }>; + candidate_ids: string[]; + scorecard_id: string | null; + started_at: string; + completed_at: string | null; + duration_ms: number | null; +} + +// --- Saved Search --- + +export interface SearchMode { + standard: boolean; + hidden_gem: boolean; + company_mapping: boolean; + artifact_led: boolean; +} + +export interface SearchSource { + github: boolean; + linkedin: boolean; + web_blog: boolean; + huggingface: boolean; + conference_talks: boolean; + company_mapping: boolean; + exa_websets: boolean; +} + +export interface RecruiterSearchConfig { + role_name: string; + role_brief: string; + scorecard_id: string | null; + archetypes: string[]; + must_have_signals: ScorecardSignal[]; + nice_to_have_signals: ScorecardSignal[]; + sources: SearchSource; + recency_months: number; + location_preference: string; + seniority_band: string; + suppressions: string[]; + score_floor: number; + modes: SearchMode; +} + +export interface SavedSearch { + id: string; + user_id: string; + name: string; + scorecard_id: string | null; + config: RecruiterSearchConfig; + result_count: number; + last_run_at: string | null; + created_at: string; + updated_at: string; +} + +// --- Sequence Templates --- + +export interface SequenceTemplateStep { + step: SequenceStep; + tone: string; + template: string; +} + +export interface SequenceTemplate { + id: string; + user_id: string; + scorecard_id: string | null; + name: string; + steps: SequenceTemplateStep[]; + created_at: string; + updated_at: string; +} + +// --- Notes --- + +export interface CandidateNote { + id: string; + user_id: string; + candidate_id: string; + content: string; + created_at: string; + updated_at: string; +} + +// --- Reports --- + +export type ReportType = + | 'candidate_volume_by_tier' + | 'source_quality' + | 'artifact_recency' + | 'response_rates' + | 'hidden_gem_yield' + | 'recruiter_throughput' + | 'ats_export_volume' + | 'stale_candidates'; diff --git a/src/recruiter/pages/AgentRuns.tsx b/src/recruiter/pages/AgentRuns.tsx new file mode 100644 index 0000000..e55de77 --- /dev/null +++ b/src/recruiter/pages/AgentRuns.tsx @@ -0,0 +1,365 @@ +// SourceKit Recruiter OS — Agent Runs Log + +import { useState } from 'react'; +import { useAgentRuns } from '../hooks/useAgentRuns'; +import PageHeader from '../components/PageHeader'; +import { RunStatusBadge } from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import { AGENT_RUN_TYPE_CONFIG, AGENT_RUN_STATUS_CONFIG } from '../lib/constants'; +import type { AgentRunType, AgentRunStatus, AgentRun } from '../lib/types'; +import { + Bot, + Filter, + ChevronDown, + ChevronRight, + RefreshCw, + Clock, + AlertTriangle, +} from 'lucide-react'; + +// --- Formatters --- + +function formatDuration(ms: number | null): string { + if (ms === null || ms === undefined) return '--'; + if (ms < 1000) return '< 1s'; + const totalSec = Math.floor(ms / 1000); + if (totalSec < 60) return `${totalSec}s`; + const min = Math.floor(totalSec / 60); + const sec = totalSec % 60; + return `${min}m ${sec}s`; +} + +function formatDateTime(date: string | null): string { + if (!date) return '--'; + const d = new Date(date); + return d.toLocaleDateString('en-US', { + month: 'short', + day: 'numeric', + }) + ' ' + d.toLocaleTimeString('en-US', { + hour: '2-digit', + minute: '2-digit', + }); +} + +function truncateJson(obj: Record, maxLen = 80): string { + const str = JSON.stringify(obj); + if (str.length <= maxLen) return str; + return str.slice(0, maxLen) + '...'; +} + +// --- Type filter options --- + +const ALL_TYPES = Object.entries(AGENT_RUN_TYPE_CONFIG) as Array< + [AgentRunType, { label: string; icon: string }] +>; + +const ALL_STATUSES = Object.entries(AGENT_RUN_STATUS_CONFIG) as Array< + [AgentRunStatus, { label: string; color: string; bgColor: string }] +>; + +export default function AgentRuns() { + const [typeFilter, setTypeFilter] = useState(''); + const [statusFilter, setStatusFilter] = useState(''); + const [expandedIds, setExpandedIds] = useState>(new Set()); + + const { data: runs, isLoading, dataUpdatedAt } = useAgentRuns({ + type: typeFilter || undefined, + status: statusFilter || undefined, + }); + + const toggleExpand = (id: string) => { + setExpandedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const list = runs ?? []; + + return ( +
+ + {dataUpdatedAt > 0 && ( + + + Auto-refreshing + + )} +
+ } + /> + + {/* Filter bar */} +
+ + + +
+ + {/* Content */} + {isLoading ? ( +
+ {[1, 2, 3, 4].map((i) => ( +
+ ))} +
+ ) : list.length === 0 ? ( + } + title="No agent runs yet" + description="Runs are created when you search, enrich, score, or generate outreach." + /> + ) : ( +
+ {/* Table header */} +
+ + Type + Status + Started + Duration + Input Summary + Output Summary + Cands + Errors +
+ + {/* Rows */} + {list.map((run) => { + const isExpanded = expandedIds.has(run.id); + const typeCfg = AGENT_RUN_TYPE_CONFIG[run.type]; + const errorCount = run.errors?.length ?? 0; + + return ( +
+ {/* Row */} +
toggleExpand(run.id)} + > + + {isExpanded ? : } + + + {typeCfg?.label ?? run.type} + + + + + + {formatDateTime(run.started_at)} + + + {formatDuration(run.duration_ms)} + + + {truncateJson(run.inputs ?? {})} + + + {truncateJson(run.outputs ?? {})} + + + {run.candidate_ids?.length ?? 0} + + 0 ? '#f87171' : 'var(--ros-text-muted)' }} + > + {errorCount} + +
+ + {/* Expanded detail */} + {isExpanded && ( +
+ {/* Input params */} +
+

+ Input Parameters +

+
+                        {JSON.stringify(run.inputs, null, 2)}
+                      
+
+ + {/* Output summary */} +
+

+ Output Summary +

+
+                        {JSON.stringify(run.outputs, null, 2)}
+                      
+
+ + {/* Errors */} + {errorCount > 0 && ( +
+

+ + Errors ({errorCount}) +

+
+ {run.errors.map((err, i) => ( +
+ {err.message} + {err.timestamp && ( + + {formatDateTime(err.timestamp)} + + )} +
+ ))} +
+
+ )} + + {/* Meta row */} +
+ + Affected candidates: {run.candidate_ids?.length ?? 0} + + +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/src/recruiter/pages/CandidateIntelList.tsx b/src/recruiter/pages/CandidateIntelList.tsx new file mode 100644 index 0000000..717b380 --- /dev/null +++ b/src/recruiter/pages/CandidateIntelList.tsx @@ -0,0 +1,456 @@ +import { useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { useRecruiterCandidates } from '../hooks/useRecruiterCandidates'; +import PageHeader from '../components/PageHeader'; +import TierBadge from '../components/TierBadge'; +import { StageBadge, ContactBadge, PriorityBadge } from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import { getScoreColor } from '../services/scoring'; +import { TIER_CONFIG, STAGE_CONFIG } from '../lib/constants'; +import { candidatesToCSV, downloadCSV } from '../services/export'; +import type { CandidateTier, PipelineStage, RecruiterCandidate } from '../lib/types'; +import { + Users, + Search, + Filter, + Download, + ChevronUp, + ChevronDown, + X, +} from 'lucide-react'; + +// --- Sorting --- + +type SortField = 'composite_score' | 'eea_score' | 'builder_score' | 'ai_recency_score'; +type SortDir = 'asc' | 'desc'; + +function getSortValue(c: RecruiterCandidate, field: SortField): number { + if (field === 'composite_score') return c.composite_score ?? 0; + if (field === 'eea_score') return c.eea_score?.score ?? 0; + if (field === 'builder_score') return c.builder_score?.score ?? 0; + if (field === 'ai_recency_score') return c.ai_recency_score?.score ?? 0; + return 0; +} + +function compareCandidates(a: RecruiterCandidate, b: RecruiterCandidate, field: SortField, dir: SortDir): number { + const av = getSortValue(a, field); + const bv = getSortValue(b, field); + return dir === 'asc' ? av - bv : bv - av; +} + +// --- Component --- + +export default function CandidateIntelList() { + const navigate = useNavigate(); + + // Filters + const [searchText, setSearchText] = useState(''); + const [tierFilter, setTierFilter] = useState(''); + const [stageFilter, setStageFilter] = useState(''); + + // Sorting + const [sortField, setSortField] = useState('composite_score'); + const [sortDir, setSortDir] = useState('desc'); + + // Selection + const [selectedIds, setSelectedIds] = useState>(new Set()); + + const { data: candidates, isLoading } = useRecruiterCandidates({ + tier: tierFilter || undefined, + stage: stageFilter || undefined, + }); + + // Client-side text filter + const filtered = (candidates ?? []).filter((c) => { + if (!searchText) return true; + const q = searchText.toLowerCase(); + return ( + (c.name ?? '').toLowerCase().includes(q) || + (c.current_title ?? '').toLowerCase().includes(q) || + (c.current_company ?? '').toLowerCase().includes(q) || + c.tags.some((t) => t.toLowerCase().includes(q)) + ); + }); + + // Sort + const sorted = [...filtered].sort((a, b) => compareCandidates(a, b, sortField, sortDir)); + + const hasFilters = searchText || tierFilter || stageFilter; + + function clearFilters() { + setSearchText(''); + setTierFilter(''); + setStageFilter(''); + } + + function toggleSort(field: SortField) { + if (sortField === field) { + setSortDir((d) => (d === 'asc' ? 'desc' : 'asc')); + } else { + setSortField(field); + setSortDir('desc'); + } + } + + function toggleSelect(id: string) { + setSelectedIds((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + } + + function toggleSelectAll() { + if (selectedIds.size === sorted.length) { + setSelectedIds(new Set()); + } else { + setSelectedIds(new Set(sorted.map((c) => c.id))); + } + } + + function handleExportAll() { + if (!candidates?.length) return; + const csv = candidatesToCSV(candidates); + downloadCSV(csv, 'candidates-all.csv'); + } + + function handleExportSelected() { + const selected = sorted.filter((c) => selectedIds.has(c.id)); + if (!selected.length) return; + const csv = candidatesToCSV(selected); + downloadCSV(csv, 'candidates-selected.csv'); + } + + // Sort header helper + function SortHeader({ label, field }: { label: string; field: SortField }) { + const active = sortField === field; + return ( + + ); + } + + // Score cell helper + function ScoreCell({ value }: { value: number | null | undefined }) { + const v = value ?? 0; + return ( + + {v} + + ); + } + + // --- Skeleton --- + function SkeletonRows() { + return ( + <> + {[1, 2, 3].map((i) => ( + + {Array.from({ length: 10 }).map((_, j) => ( + +
+ + ))} + + ))} + + ); + } + + return ( +
+ + + {filtered.length} candidate{filtered.length !== 1 ? 's' : ''} + + +
+ } + /> + + {/* Sticky filter bar */} +
+
+ + setSearchText(e.target.value)} + placeholder="Search by name, title, company, or tag..." + className="w-full pl-7 pr-2 py-1.5 text-xs rounded border outline-none transition-colors" + style={{ + background: 'var(--ros-bg-secondary)', + borderColor: 'var(--ros-border)', + color: 'var(--ros-text-primary)', + }} + /> +
+ +
+ +
+ + + + + + {hasFilters && ( + + )} +
+ + {/* Batch action bar */} + {selectedIds.size > 0 && ( +
+ + {selectedIds.size} selected + + +
+ )} + + {/* Table */} + {!isLoading && sorted.length === 0 ? ( + } + title="No candidates found" + description="No candidates found. Run a search to populate your pipeline." + /> + ) : ( +
+ + + + + + + + + + + + + + + + + {isLoading ? ( + + ) : ( + sorted.map((c) => ( + navigate(`/recruiter/candidates/${c.id}`)} + onMouseEnter={(e) => { + (e.currentTarget as HTMLElement).style.background = 'var(--ros-bg-hover)'; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLElement).style.background = 'transparent'; + }} + > + + + + + + + + + + + + )) + )} + +
+ 0 && selectedIds.size === sorted.length} + onChange={toggleSelectAll} + className="rounded" + /> + + + Name + + + + Tier + + + + + + + + + + + + Contact + + + + Priority + + + + Tags + +
e.stopPropagation()}> + toggleSelect(c.id)} + className="rounded" + /> + +
+
+ {(c.name ?? '?')[0].toUpperCase()} +
+
+
+ {c.name ?? 'Unknown'} +
+ {c.current_company && ( +
+ {c.current_company} +
+ )} +
+
+
+ + + + + + + + + + + + + + +
+ {c.tags.slice(0, 3).map((tag) => ( + + {tag} + + ))} + {c.tags.length > 3 && ( + + +{c.tags.length - 3} + + )} +
+
+
+ )} +
+ ); +} diff --git a/src/recruiter/pages/CandidateIntelProfile.tsx b/src/recruiter/pages/CandidateIntelProfile.tsx new file mode 100644 index 0000000..6601f3f --- /dev/null +++ b/src/recruiter/pages/CandidateIntelProfile.tsx @@ -0,0 +1,662 @@ +import { useState } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { useRecruiterCandidate, useUpdateCandidate } from '../hooks/useRecruiterCandidates'; +import { useRecruiterOutreach } from '../hooks/useRecruiterOutreach'; +import { useRecruiterNotes, useCreateNote } from '../hooks/useRecruiterNotes'; +import ScoreCard from '../components/ScoreCard'; +import TierBadge from '../components/TierBadge'; +import { StageBadge, ContactBadge, ATSBadge, PriorityBadge } from '../components/StatusBadge'; +import PageHeader from '../components/PageHeader'; +import { TIER_CONFIG, SCORE_DIMENSIONS, ARTIFACT_TYPE_CONFIG } from '../lib/constants'; +import type { CandidateTier, ScoreDimension } from '../lib/types'; +import { + ArrowLeft, + Github, + Linkedin, + Globe, + Mail, + Send, + Tag, + ExternalLink, + Plus, + FileText, + Clock, + MessageSquare, + Activity, +} from 'lucide-react'; + +type Tab = 'evidence' | 'profile' | 'sourcing' | 'outreach' | 'notes'; + +const TABS: { id: Tab; label: string }[] = [ + { id: 'evidence', label: 'Evidence' }, + { id: 'profile', label: 'Profile' }, + { id: 'sourcing', label: 'Sourcing' }, + { id: 'outreach', label: 'Outreach' }, + { id: 'notes', label: 'Notes' }, +]; + +export default function CandidateIntelProfile() { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const { data: candidate, isLoading, error } = useRecruiterCandidate(id); + const updateCandidate = useUpdateCandidate(); + const { data: outreachMessages } = useRecruiterOutreach(id); + const { data: notes } = useRecruiterNotes(id); + const createNote = useCreateNote(); + + const [activeTab, setActiveTab] = useState('evidence'); + const [showTierDropdown, setShowTierDropdown] = useState(false); + const [tagInput, setTagInput] = useState(''); + const [noteText, setNoteText] = useState(''); + + // --- Loading skeleton --- + if (isLoading) { + return ( +
+
+
+
+ {[1, 2, 3, 4, 5, 6].map((i) => ( +
+ ))} +
+
+
+ ); + } + + // --- Missing candidate --- + if (!candidate || error) { + return ( +
+ +
+

+ Candidate not found. It may have been removed or you may not have access. +

+
+
+ ); + } + + // --- Handlers --- + + function handleTierChange(tier: CandidateTier) { + updateCandidate.mutate({ id: candidate!.id, updates: { tier } }); + setShowTierDropdown(false); + } + + function handleAddTag() { + const tag = tagInput.trim(); + if (!tag || candidate!.tags.includes(tag)) { + setTagInput(''); + return; + } + updateCandidate.mutate({ + id: candidate!.id, + updates: { tags: [...candidate!.tags, tag] }, + }); + setTagInput(''); + } + + function handleSaveNote() { + const content = noteText.trim(); + if (!content) return; + createNote.mutate({ candidateId: candidate!.id, content }); + setNoteText(''); + } + + // --- Links --- + const links = [ + { url: candidate.github_username ? `https://github.com/${candidate.github_username}` : null, icon: Github, label: 'GitHub' }, + { url: candidate.linkedin_url, icon: Linkedin, label: 'LinkedIn' }, + { url: candidate.portfolio_url, icon: Globe, label: 'Portfolio' }, + { url: candidate.blog_url, icon: FileText, label: 'Blog' }, + { url: candidate.personal_site_url, icon: Globe, label: 'Site' }, + ].filter((l) => l.url); + + // --- Score dimensions --- + const scoreDimensions: { dimension: ScoreDimension; score: typeof candidate.eea_score }[] = [ + { dimension: 'eea', score: candidate.eea_score }, + { dimension: 'builder', score: candidate.builder_score }, + { dimension: 'ai_recency', score: candidate.ai_recency_score }, + { dimension: 'systems_depth', score: candidate.systems_depth_score }, + { dimension: 'product_instinct', score: candidate.product_instinct_score }, + { dimension: 'hidden_gem', score: candidate.hidden_gem_score }, + ]; + + return ( +
+ {/* 1. Back + breadcrumb */} +
+ + + Candidate Intel + + / + + {candidate.name ?? 'Unknown'} + +
+ + {/* 2. Header bar */} +
+
+ {/* Avatar */} +
+ {(candidate.name ?? '?')[0].toUpperCase()} +
+ +
+
+

+ {candidate.name ?? 'Unknown'} +

+ +
+ {(candidate.current_title || candidate.current_company) && ( +

+ {candidate.current_title} + {candidate.current_title && candidate.current_company ? ' at ' : ''} + {candidate.current_company} +

+ )} +
+
+ + {/* Quick actions row */} +
+ + + {/* Tier dropdown */} +
+ + {showTierDropdown && ( +
+ {(Object.keys(TIER_CONFIG) as CandidateTier[]).map((t) => ( + + ))} +
+ )} +
+ + {/* Add tag */} +
+ + setTagInput(e.target.value)} + onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} + placeholder="Add tag..." + className="text-xs px-2 py-1 rounded border outline-none w-24" + style={{ + background: 'var(--ros-bg-secondary)', + borderColor: 'var(--ros-border)', + color: 'var(--ros-text-primary)', + }} + /> + +
+ + +
+ + {/* Links row */} + {links.length > 0 && ( + + )} +
+ + {/* 3. Score panel */} +
+ {scoreDimensions.map(({ dimension, score }) => ( +
+ +
+ ))} +
+ + Outreach Priority + + +
+
+ + {/* 4. Tabbed content */} +
+ {/* Tab bar */} +
+ {TABS.map((tab) => ( + + ))} +
+ + {/* Tab content */} +
+ {/* Evidence tab */} + {activeTab === 'evidence' && ( +
+

+ Strongest Artifacts +

+ {candidate.artifacts.length === 0 ? ( +

+ No artifacts indexed yet. Run enrichment to surface this candidate's work. +

+ ) : ( + candidate.artifacts.map((artifact) => { + const artConfig = ARTIFACT_TYPE_CONFIG[artifact.type]; + return ( +
+ +
+ {artifact.source} + {artifact.date && ( + <> + - + {artifact.date} + + )} + + {artifact.relevance} + +
+

+ {artifact.description} +

+
+ ); + }) + )} +
+ )} + + {/* Profile tab */} + {activeTab === 'profile' && ( +
+ {candidate.bio && ( +
+

+ Bio +

+

+ {candidate.bio} +

+
+ )} + {candidate.location && ( +
+

+ Location +

+

+ {candidate.location} +

+
+ )} +
+

+ Skills / Tags +

+ {candidate.tags.length > 0 ? ( +
+ {candidate.tags.map((tag) => ( + + {tag} + + ))} +
+ ) : ( +

No tags.

+ )} +
+
+

+ Links +

+ {links.length > 0 ? ( +
+ {links.map((link) => { + const Icon = link.icon; + return ( + + + {link.url} + + ); + })} +
+ ) : ( +

No links available.

+ )} +
+
+ )} + + {/* Sourcing tab */} + {activeTab === 'sourcing' && ( +
+
+

+ Sourcing Rationale +

+

+ {candidate.sourcing_rationale ?? 'No sourcing rationale recorded.'} +

+
+
+

+ Hidden Gem Reasons +

+ {candidate.hidden_gem_reasons.length > 0 ? ( +
    + {candidate.hidden_gem_reasons.map((reason, i) => ( +
  • + - + {reason} +
  • + ))} +
+ ) : ( +

None identified.

+ )} +
+
+

+ Search IDs +

+ {candidate.search_ids.length > 0 ? ( +
+ {candidate.search_ids.map((sid) => ( + + {sid} + + ))} +
+ ) : ( +

No searches linked.

+ )} +
+
+ )} + + {/* Outreach tab */} + {activeTab === 'outreach' && ( +
+
+

+ Outreach Messages +

+ +
+ {(!outreachMessages || outreachMessages.length === 0) ? ( +

+ No outreach generated. Draft a message grounded in this candidate's evidence. +

+ ) : ( + outreachMessages.map((msg) => ( +
+
+ + {msg.channel} + + + {msg.tone} + + + + {msg.status} + + + + {new Date(msg.created_at).toLocaleDateString()} + +
+

+ {msg.message} +

+
+ )) + )} +
+ )} + + {/* Notes tab */} + {activeTab === 'notes' && ( +
+
+