diff --git a/claw4hr-passive-talent-intelligence/.env.example b/claw4hr-passive-talent-intelligence/.env.example index fffdffd..50bd523 100644 --- a/claw4hr-passive-talent-intelligence/.env.example +++ b/claw4hr-passive-talent-intelligence/.env.example @@ -1,14 +1,22 @@ -# HrFlow.ai (get from hackathon organizers) +# HrFlow.ai HRFLOW_API_KEY=ask_... -HRFLOW_API_EMAIL=your-email@example.com -HRFLOW_SOURCE_KEY=your-source-key-here -HRFLOW_BOARD_KEY=your-board-key-here -HRFLOW_ALGORITHM_KEY=your-algorithm-key-here - -# Sourcing passif (optional) -PROXYCURL_API_KEY=your-proxycurl-key-here -GITHUB_TOKEN=ghp_your-github-token-here - -# Ollama LLM (optional — Mac Mini via Tailscale) -OLLAMA_BASE_URL=http://localhost:11434 -OLLAMA_MODEL=qwen3:14b +HRFLOW_API_EMAIL= +HRFLOW_BOARD_KEY= +HRFLOW_SOURCE_KEY= +HRFLOW_LIVE_SOURCE_KEY= +HRFLOW_ALGORITHM_KEY= +NEXT_PUBLIC_HRFLOW_DEFAULT_JOB_KEY= + +# Supabase (auth + persistence) +NEXT_PUBLIC_SUPABASE_URL= +NEXT_PUBLIC_SUPABASE_ANON_KEY= + +# Mistral (LLM for chat / SWOT) +MISTRAL_API_KEY= + +# Trigger.dev (sourcing pipeline) +TRIGGER_SECRET_KEY= +TRIGGER_WEBHOOK_SECRET= + +# App +NEXT_PUBLIC_APP_URL=http://localhost:3000 diff --git a/claw4hr-passive-talent-intelligence/.gitignore b/claw4hr-passive-talent-intelligence/.gitignore new file mode 100644 index 0000000..50fed11 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/.gitignore @@ -0,0 +1,60 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files +.env +.env.local +.env*.local +!.env.example + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts + +# AI assistant tooling (not source code) +.claude/ +.agents/ +skills-lock.json +.mcp.json +CLAUDE.md +AGENTS.md + +# Internal docs +CONTEXT.md +DEMO_SCRIPT.md +PITCH_DECK.md +OPENCLAW_PROMPT.md +CLAW4HR_VISION.md +.superpowers/ diff --git a/claw4hr-passive-talent-intelligence/README.md b/claw4hr-passive-talent-intelligence/README.md index cc1ce7e..5c16577 100644 --- a/claw4hr-passive-talent-intelligence/README.md +++ b/claw4hr-passive-talent-intelligence/README.md @@ -1,109 +1,147 @@ -# Claw4HR — Passive Talent Intelligence +# Claw4HR — AI Talent Sourcing Platform -> AI recruiter agent: describe a role, get scored candidates instantly. +Passive talent sourcing agent. Built for the HrFlow GenAI & RH Hackathon 2026. -## What it does +A recruiter describes a role in natural language → multi-source agents discover matching profiles on GitHub, LinkedIn, Reddit, and the web → HrFlow scores each candidate → the recruiter interacts with profiles via Q&A and manages a pipeline. -A recruiter describes a position via chat (text or Telegram voice message). The agent: -1. Extracts criteria (skills, location, seniority) via NLP -2. Finds the most relevant job in the HrFlow board -3. Scores indexed profiles (10k+) using HrFlow AI Scoring -4. Displays top candidates with animated matching scores, clickable skills, and detailed profiles -5. Provides SWOT analysis (strengths/gaps) and Q&A per candidate +**Deploy:** https://hrflowhackathon2026.vercel.app -The real-time pipeline feed traces every step of the process. +--- -**Live demo:** https://hrflowhackathon2026.vercel.app +## Setup -## HrFlow.ai APIs used +```bash +npm install +cp .env.example .env.local # fill in your keys +npm run dev # http://localhost:3000 +``` -- `GET /v1/profiles/searching` — List indexed profiles -- `GET /v1/profiles/scoring` — AI scoring of profiles against a job -- `GET /v1/profile/asking` — Q&A on a specific profile -- `GET /v1/profile/upskilling` — SWOT analysis (strengths + gaps) -- `POST /v1/profile/parsing` — Upload and parse a CV -- `GET /v1/jobs/searching` — List jobs from a board +## Environment variables -## How to run +| Variable | Description | +|---|---| +| `HRFLOW_API_KEY` | HrFlow API key (`ask_...`) | +| `HRFLOW_API_EMAIL` | HrFlow account email | +| `HRFLOW_SOURCE_KEY` | HrFlow profile source key | +| `HRFLOW_BOARD_KEY` | HrFlow job board key | +| `TRIGGER_SECRET_KEY` | trigger.dev secret key (sourcing pipeline) | +| `TRIGGER_PROJECT_ID` | trigger.dev project ID | +| `TRIGGER_WEBHOOK_SECRET` | Shared secret for webhook auth (optional) | +| `NEXT_PUBLIC_APP_URL` | App URL used to build the webhook callback | -### Prerequisites +--- -- Node.js 20+ -- npm +## Architecture -### Setup +### User flow -```bash -cd claw4hr-passive-talent-intelligence +``` +SearchView → LoadingView → ResultsView → ProfileDetailView +``` -# Install dependencies -npm install +1. **SearchView** — natural language search bar +2. **LoadingView** — animated agents (GitHub / LinkedIn / Reddit / Web) with live terminal feed +3. **ResultsView** — scored candidate grid with filters and analytics +4. **ProfileDetailView** — full profile + HrFlow Q&A + SWOT analysis -# Copy environment variables -cp .env.example .env -# Then fill in your actual API keys in .env +### Data flow -# Start the app (dev mode) -npm run dev -# App runs at http://localhost:3000 +``` +Recruiter types a query + ↓ +POST /api/trigger/run → triggers sourcing-task on trigger.dev + ↓ +sourcing-task runs externally → POSTs profiles + agent events to webhook + ↓ +POST /api/trigger/webhook → pushes to in-memory event store + ↓ +GET /api/trigger/stream (SSE) → dashboard updates in real time ``` -### Environment variables - -| Variable | Required | Description | -|----------|----------|-------------| -| `HRFLOW_API_KEY` | Yes | HrFlow.ai API key (prefix `ask_`) | -| `HRFLOW_API_EMAIL` | Yes | HrFlow.ai account email | -| `HRFLOW_SOURCE_KEY` | Yes | HrFlow.ai source containing profiles | -| `HRFLOW_BOARD_KEY` | Yes | HrFlow.ai board containing jobs | -| `HRFLOW_ALGORITHM_KEY` | Yes | HrFlow.ai AI scoring algorithm key | -| `PROXYCURL_API_KEY` | No | LinkedIn enrichment (Proxycurl) | -| `GITHUB_TOKEN` | No | GitHub passive sourcing | -| `OLLAMA_BASE_URL` | No | Ollama LLM endpoint | -| `OLLAMA_MODEL` | No | Ollama model (default: qwen3:14b) | - -## Architecture +### File structure ``` app/ - page.tsx Dashboard 3-column layout - layout.tsx Layout with Geist font - globals.css Dark "mission control" theme + animations components/ - TopBar.tsx Status bar (OpenClaw, Indeed, HrFlow connections) - Dashboard.tsx Main orchestrator (state, fetch, layout) - WhatsAppPanel.tsx Recruiter chat (left panel) - CandidatePanel.tsx Candidate cards with scoring (center) - AgentFeed.tsx Real-time agent pipeline feed (right) + Dashboard.tsx Orchestrator (state, SSE, handlers) + SearchView.tsx Search bar + LoadingView.tsx Animated agents + live terminal + ResultsView.tsx Profile grid + live agent strip + CandidateCard.tsx Candidate card (score, skills, sources) + ProfileDetailView.tsx Full profile + Q&A + PixelAgent.tsx Pixel art sprites per source + ScoreRing.tsx Animated score ring lib/ - hrflow.ts Centralized HrFlow client - types.ts TypeScript types - api/hrflow/ - parse/route.ts POST — Upload & parse CV - score/route.ts GET — Score profiles vs job - ask/route.ts GET — Q&A on a profile - upskill/route.ts GET — SWOT analysis profile vs job - profiles/route.ts GET — List indexed profiles - jobs/route.ts GET — List board jobs - api/openclaw/ - webhook/route.ts POST — Receive OpenClaw events - events/route.ts GET — Dashboard polling (cursor-based) + types.ts TypeScript types (SourcedProfile, FeedLog, ...) + hrflow.ts HrFlow API client + eventStore.ts In-memory pub/sub (webhook → SSE stream) + api/ + trigger/ + run/route.ts POST — start a sourcing run via trigger.dev + webhook/route.ts POST — receive profiles + events from the task + stream/route.ts GET — SSE stream to dashboard + events/route.ts GET — cursor-based polling alternative + hrflow/ + parse/route.ts POST — parse a resume + score/route.ts GET — score profiles against a job + ask/route.ts GET — Q&A on a profile + upskill/route.ts GET — SWOT strengths/gaps analysis + profiles/route.ts GET — list profiles + jobs/route.ts GET — list jobs + account/ + searches/route.ts Search history (Supabase) + shortlist/route.ts Shortlisted candidates (Supabase) + outreach/route.ts Outreach messages (Supabase) + demo/ + ask/route.ts Q&A fallback (Mistral) + swot/route.ts SWOT fallback (Mistral) + outreach/ + generate/route.ts Outreach message generation (Mistral) +trigger/ + sourcing-task.ts trigger.dev task — multi-source candidate discovery ``` -## Screenshots +--- -![Preview](./assets/preview.png) +## Connecting the sourcing pipeline -## Stack +The sourcing task (`trigger/sourcing-task.ts`) is a trigger.dev v3 task. Once configured, it receives a query and webhook URL, runs the multi-source sourcing logic, and POSTs each discovered profile back to the dashboard. -- **Frontend**: Next.js 16, React 19, Tailwind CSS v4 -- **Backend**: Next.js API Routes -- **AI/HR**: HrFlow.ai (parsing, scoring, asking, AI algorithm) -- **Orchestrator**: OpenClaw -- **LLM**: Ollama + Qwen3 14B -- **Chat**: Telegram (via OpenClaw) +### Sending profiles to the dashboard -## Team +```bash +curl -X POST https://your-app.vercel.app/api/trigger/webhook \ + -H "Content-Type: application/json" \ + -H "x-trigger-secret: YOUR_WEBHOOK_SECRET" \ + -d '{ + "channel": "profile", + "payload": { + "profile": { + "key": "unique-uuid", + "name": "Jane Doe", + "title": "Senior Data Scientist", + "location": "Paris, France", + "experience_years": 6, + "summary": "3-sentence recruiter summary", + "sources": [{"type": "github", "url": "https://github.com/...", "label": "github.com/..."}], + "skills": ["Python", "NLP", "PyTorch"], + "experiences": [{"title": "...", "company": "...", "location": "Paris", "period": "2021 — 2024", "description": "..."}], + "educations": [{"degree": "...", "school": "...", "period": "..."}], + "hrflow_score": -1, + "hrflow_key": null, + "avatar_color": "#1f2937" + } + } + }' +``` + +--- + +## Stack -- **Matki** — Frontend/Backend Developer -- **Emile** — OpenClaw Integration +- **Frontend / Backend:** Next.js 16, React 19, Tailwind CSS v4, TypeScript +- **HR AI:** HrFlow.ai (parsing, scoring, Q&A, SWOT) +- **Sourcing pipeline:** trigger.dev v3 +- **Outreach generation:** Mistral AI +- **Persistence:** Supabase (search history, shortlist, outreach) +- **Deploy:** Vercel diff --git a/claw4hr-passive-talent-intelligence/app/api/account/outreach/route.ts b/claw4hr-passive-talent-intelligence/app/api/account/outreach/route.ts new file mode 100644 index 0000000..23feb76 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/account/outreach/route.ts @@ -0,0 +1,41 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextRequest, NextResponse } from "next/server"; + +function db() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} + +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("session_id"); + if (!sessionId) return NextResponse.json({ data: [] }); + + const { data, error } = await db() + .from("outreach") + .select("*") + .eq("session_id", sessionId) + .order("created_at", { ascending: false }) + .limit(20); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ data }); +} + +export async function DELETE(req: NextRequest) { + const body = await req.json(); + const { session_id, outreach_id } = body; + if (!session_id || !outreach_id) { + return NextResponse.json({ error: "missing fields" }, { status: 400 }); + } + + const { error } = await db() + .from("outreach") + .delete() + .eq("session_id", session_id) + .eq("id", outreach_id); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ ok: true }); +} diff --git a/claw4hr-passive-talent-intelligence/app/api/account/searches/route.ts b/claw4hr-passive-talent-intelligence/app/api/account/searches/route.ts new file mode 100644 index 0000000..bd7b789 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/account/searches/route.ts @@ -0,0 +1,58 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextRequest, NextResponse } from "next/server"; + +function db() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} + +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("session_id"); + if (!sessionId) return NextResponse.json({ data: [] }); + + const { data, error } = await db() + .from("searches") + .select("*") + .eq("session_id", sessionId) + .order("created_at", { ascending: false }) + .limit(10); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ data }); +} + +export async function DELETE(req: NextRequest) { + const body = await req.json(); + const { session_id, id } = body; + if (!session_id || !id) { + return NextResponse.json({ error: "missing fields" }, { status: 400 }); + } + + const { error } = await db() + .from("searches") + .delete() + .eq("id", id) + .eq("session_id", session_id); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ success: true }); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { session_id, query, profile_count } = body; + if (!session_id || !query) { + return NextResponse.json({ error: "missing fields" }, { status: 400 }); + } + + const { data, error } = await db() + .from("searches") + .insert({ session_id, query, profile_count: profile_count ?? 0 }) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ data }); +} diff --git a/claw4hr-passive-talent-intelligence/app/api/account/shortlist/route.ts b/claw4hr-passive-talent-intelligence/app/api/account/shortlist/route.ts new file mode 100644 index 0000000..ab45984 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/account/shortlist/route.ts @@ -0,0 +1,81 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextRequest, NextResponse } from "next/server"; + +function db() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} + +export async function GET(req: NextRequest) { + const sessionId = req.nextUrl.searchParams.get("session_id"); + if (!sessionId) return NextResponse.json({ data: [] }); + + const { data, error } = await db() + .from("shortlist") + .select("*") + .eq("session_id", sessionId) + .order("saved_at", { ascending: false }); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ data }); +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { session_id, profile_key, profile_data } = body; + if (!session_id || !profile_key || !profile_data) { + return NextResponse.json({ error: "missing fields" }, { status: 400 }); + } + + const { data, error } = await db() + .from("shortlist") + .upsert({ session_id, profile_key, profile_data }, { onConflict: "session_id,profile_key" }) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ data }); +} + +export async function DELETE(req: NextRequest) { + const body = await req.json(); + const { session_id, profile_key } = body; + if (!session_id || !profile_key) { + return NextResponse.json({ error: "missing fields" }, { status: 400 }); + } + + const { error } = await db() + .from("shortlist") + .delete() + .eq("session_id", session_id) + .eq("profile_key", profile_key); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ ok: true }); +} + +export async function PATCH(req: NextRequest) { + const body = await req.json(); + const { session_id, profile_key, pipeline_stage } = body; + if (!session_id || !profile_key || !pipeline_stage) { + return NextResponse.json({ error: "missing fields" }, { status: 400 }); + } + + const VALID_STAGES = ["shortlisted", "contacted", "waiting", "discussing", "archived"] as const; + if (!VALID_STAGES.includes(pipeline_stage as typeof VALID_STAGES[number])) { + return NextResponse.json({ error: "invalid pipeline_stage" }, { status: 400 }); + } + + const { data, error } = await db() + .from("shortlist") + .update({ pipeline_stage }) + .eq("session_id", session_id) + .eq("profile_key", profile_key) + .select() + .single(); + + if (error) return NextResponse.json({ error: error.message }, { status: 500 }); + return NextResponse.json({ data }); +} diff --git a/claw4hr-passive-talent-intelligence/app/api/auth/validate-token/route.ts b/claw4hr-passive-talent-intelligence/app/api/auth/validate-token/route.ts new file mode 100644 index 0000000..adfef06 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/auth/validate-token/route.ts @@ -0,0 +1,19 @@ +import { NextRequest, NextResponse } from "next/server"; +import { createClient } from "@supabase/supabase-js"; + +function db() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} + +export async function POST(req: NextRequest) { + const { token } = await req.json(); + if (!token) return NextResponse.json({ valid: false }); + + const { data, error } = await db().rpc("validate_invite_token", { input_token: token }); + + if (error) return NextResponse.json({ valid: false }); + return NextResponse.json({ valid: !!data }); +} diff --git a/claw4hr-passive-talent-intelligence/app/api/demo/ask/route.ts b/claw4hr-passive-talent-intelligence/app/api/demo/ask/route.ts new file mode 100644 index 0000000..6e40167 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/demo/ask/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { SourcedProfile } from "@/app/lib/types"; + +/** + * POST /api/demo/ask + * Q&A on demo profiles (no HrFlow key) — uses Claude directly. + * Body: { profile: SourcedProfile, question: string } + */ +export async function POST(req: NextRequest) { + const { profile, question } = (await req.json()) as { + profile: SourcedProfile; + question: string; + }; + + if (!profile || !question) { + return NextResponse.json({ error: "Missing profile or question" }, { status: 400 }); + } + + const apiKey = process.env.MISTRAL_API_KEY; + if (!apiKey) { + return NextResponse.json({ data: { response: profile.summary } }); + } + + const systemPrompt = `Tu es un assistant recruteur expert. On te fournit le profil d'un candidat passif et une question d'un recruteur. Réponds en français, de façon concise (3-5 phrases max), factuelle et utile pour un recruteur. + +Profil : +- Nom : ${profile.name} +- Titre : ${profile.title} +- Localisation : ${profile.location} +- Expérience : ${profile.experience_years} ans +- Entreprise actuelle : ${profile.experiences[0]?.company ?? "N/A"} +- Poste actuel : ${profile.experiences[0]?.title ?? profile.title} +- Compétences : ${profile.skills.join(", ")} +- Résumé : ${profile.summary} +${profile.experiences[1] ? `- Expérience précédente : ${profile.experiences[1].title} chez ${profile.experiences[1].company}` : ""} +${profile.educations[0] ? `- Formation : ${profile.educations[0].degree}, ${profile.educations[0].school}` : ""}`; + + try { + const res = await fetch("https://api.mistral.ai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "mistral-small-latest", + max_tokens: 300, + messages: [ + { role: "system", content: systemPrompt }, + { role: "user", content: question }, + ], + }), + }); + + if (!res.ok) { + return NextResponse.json({ data: { response: profile.summary } }); + } + + const data = await res.json(); + const response = data.choices?.[0]?.message?.content ?? profile.summary; + return NextResponse.json({ data: { response } }); + } catch { + return NextResponse.json({ data: { response: profile.summary } }); + } +} diff --git a/claw4hr-passive-talent-intelligence/app/api/demo/swot/route.ts b/claw4hr-passive-talent-intelligence/app/api/demo/swot/route.ts new file mode 100644 index 0000000..83e6716 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/demo/swot/route.ts @@ -0,0 +1,89 @@ +import { NextRequest, NextResponse } from "next/server"; +import type { SourcedProfile } from "@/app/lib/types"; + +/** + * POST /api/demo/swot + * Generate SWOT analysis for demo profiles (no HrFlow key) via Mistral. + * Body: { profile: SourcedProfile, job_title?: string } + */ +export async function POST(req: NextRequest) { + const { profile, job_title } = (await req.json()) as { + profile: SourcedProfile; + job_title?: string; + }; + + if (!profile) { + return NextResponse.json({ error: "Missing profile" }, { status: 400 }); + } + + const apiKey = process.env.MISTRAL_API_KEY; + if (!apiKey) { + return NextResponse.json({ data: buildFallback(profile) }); + } + + const jobContext = job_title ?? "Lead Data Scientist Python Paris"; + + const prompt = `Tu es un expert RH. Analyse ce profil candidat pour le poste "${jobContext}". + +Profil: +- Nom: ${profile.name} +- Titre: ${profile.title} +- Expérience: ${profile.experience_years} ans +- Compétences: ${profile.skills.join(", ")} +- Poste actuel: ${profile.experiences[0]?.title ?? profile.title} chez ${profile.experiences[0]?.company ?? "N/A"} +- Résumé: ${profile.summary} + +Réponds en JSON strict avec ce format exact: +{ + "strengths": ["force 1", "force 2", "force 3"], + "improvements": ["point d'attention 1", "point d'attention 2"] +} + +3 forces maximum, 2 points d'attention maximum. Chaque item = 1 phrase courte et factuelle. JSON pur, sans markdown.`; + + try { + const res = await fetch("https://api.mistral.ai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "mistral-small-latest", + max_tokens: 300, + messages: [{ role: "user", content: prompt }], + response_format: { type: "json_object" }, + }), + }); + + if (!res.ok) return NextResponse.json({ data: buildFallback(profile) }); + + const completion = await res.json(); + const text = completion.choices?.[0]?.message?.content ?? "{}"; + const parsed = JSON.parse(text); + + return NextResponse.json({ + data: { + strengths: Array.isArray(parsed.strengths) ? parsed.strengths.slice(0, 3) : [], + improvements: Array.isArray(parsed.improvements) ? parsed.improvements.slice(0, 2) : [], + }, + }); + } catch { + return NextResponse.json({ data: buildFallback(profile) }); + } +} + +function buildFallback(profile: SourcedProfile) { + const topSkills = profile.skills.slice(0, 3).join(", "); + return { + strengths: [ + `${profile.experience_years} ans d'expérience dans le domaine`, + topSkills ? `Maîtrise de ${topSkills}` : "Profil technique confirmé", + profile.experiences[0]?.company ? `Expérience en entreprise : ${profile.experiences[0].company}` : "Parcours solide", + ], + improvements: [ + "Informations complémentaires à vérifier en entretien", + "Motivations pour une opportunité passive à confirmer", + ], + }; +} diff --git a/claw4hr-passive-talent-intelligence/app/api/hrflow/ask/route.ts b/claw4hr-passive-talent-intelligence/app/api/hrflow/ask/route.ts index 7b9bac1..7a981ba 100644 --- a/claw4hr-passive-talent-intelligence/app/api/hrflow/ask/route.ts +++ b/claw4hr-passive-talent-intelligence/app/api/hrflow/ask/route.ts @@ -15,6 +15,20 @@ export async function GET(req: NextRequest) { ); } + if (profileKey.length > 200) { + return NextResponse.json( + { error: "profile_key exceeds maximum length of 200 characters" }, + { status: 400 }, + ); + } + + if (question.length > 2000) { + return NextResponse.json( + { error: "question exceeds maximum length of 2000 characters" }, + { status: 400 }, + ); + } + const result = await hrflow.askProfile(profileKey, [question], mode); return NextResponse.json(result, { status: result.code ?? 500 }); diff --git a/claw4hr-passive-talent-intelligence/app/api/hrflow/jobs/route.ts b/claw4hr-passive-talent-intelligence/app/api/hrflow/jobs/route.ts index 2cc9561..e596050 100644 --- a/claw4hr-passive-talent-intelligence/app/api/hrflow/jobs/route.ts +++ b/claw4hr-passive-talent-intelligence/app/api/hrflow/jobs/route.ts @@ -4,14 +4,17 @@ import { hrflow } from "@/app/lib/hrflow"; /** GET /api/hrflow/jobs?limit=...&page=... — List jobs from the board */ export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl; - const limit = searchParams.get("limit"); - const page = searchParams.get("page"); + const rawLimit = searchParams.get("limit"); + const rawPage = searchParams.get("page"); const name = searchParams.get("name"); + const limit = Math.min(100, Math.max(1, parseInt(rawLimit || "30", 10) || 30)); + const page = Math.max(1, parseInt(rawPage || "1", 10) || 1); + const result = await hrflow.searchJobs({ - limit: limit ? parseInt(limit, 10) : undefined, - page: page ? parseInt(page, 10) : undefined, + limit, + page, name: name ?? undefined, }); diff --git a/claw4hr-passive-talent-intelligence/app/api/hrflow/profiles/route.ts b/claw4hr-passive-talent-intelligence/app/api/hrflow/profiles/route.ts index 4bba492..ebfc41f 100644 --- a/claw4hr-passive-talent-intelligence/app/api/hrflow/profiles/route.ts +++ b/claw4hr-passive-talent-intelligence/app/api/hrflow/profiles/route.ts @@ -4,14 +4,17 @@ import { hrflow, type HrFlowMode } from "@/app/lib/hrflow"; /** GET /api/hrflow/profiles?limit=...&page=...&keywords=...&mode=demo|live — Search profiles */ export async function GET(req: NextRequest) { const { searchParams } = req.nextUrl; - const limit = searchParams.get("limit"); - const page = searchParams.get("page"); + const rawLimit = searchParams.get("limit"); + const rawPage = searchParams.get("page"); const keywords = searchParams.get("keywords"); const mode = (searchParams.get("mode") as HrFlowMode) || undefined; + const limit = Math.min(100, Math.max(1, parseInt(rawLimit || "30", 10) || 30)); + const page = Math.max(1, parseInt(rawPage || "1", 10) || 1); + const result = await hrflow.searchProfiles({ - limit: limit ? parseInt(limit, 10) : undefined, - page: page ? parseInt(page, 10) : undefined, + limit, + page, text_keywords: keywords ? keywords.split(",").map((k) => k.trim()) : undefined, mode, }); diff --git a/claw4hr-passive-talent-intelligence/app/api/hrflow/score/route.ts b/claw4hr-passive-talent-intelligence/app/api/hrflow/score/route.ts index 0009e68..e70e161 100644 --- a/claw4hr-passive-talent-intelligence/app/api/hrflow/score/route.ts +++ b/claw4hr-passive-talent-intelligence/app/api/hrflow/score/route.ts @@ -6,24 +6,27 @@ export async function GET(req: NextRequest) { const ALGORITHM_KEY = process.env.HRFLOW_ALGORITHM_KEY; if (!ALGORITHM_KEY) { return NextResponse.json( - { error: "Scoring non disponible : HRFLOW_ALGORITHM_KEY manquante. Creer un algorithme dans AI Studio." }, + { error: "Service temporairement indisponible" }, { status: 503 }, ); } const { searchParams } = req.nextUrl; const jobKey = searchParams.get("job_key"); - const limit = searchParams.get("limit"); - const page = searchParams.get("page"); + const rawLimit = searchParams.get("limit"); + const rawPage = searchParams.get("page"); const mode = (searchParams.get("mode") as HrFlowMode) || undefined; if (!jobKey) { return NextResponse.json({ error: "Missing job_key parameter" }, { status: 400 }); } + const limit = Math.min(100, Math.max(1, parseInt(rawLimit || "30", 10) || 30)); + const page = Math.max(1, parseInt(rawPage || "1", 10) || 1); + const result = await hrflow.scoreProfiles(jobKey, ALGORITHM_KEY, { - limit: limit ? parseInt(limit, 10) : undefined, - page: page ? parseInt(page, 10) : undefined, + limit, + page, mode, }); diff --git a/claw4hr-passive-talent-intelligence/app/api/hrflow/upskill/route.ts b/claw4hr-passive-talent-intelligence/app/api/hrflow/upskill/route.ts index 5a3a73b..efbeb03 100644 --- a/claw4hr-passive-talent-intelligence/app/api/hrflow/upskill/route.ts +++ b/claw4hr-passive-talent-intelligence/app/api/hrflow/upskill/route.ts @@ -6,7 +6,7 @@ export async function GET(req: NextRequest) { const algorithmKey = process.env.HRFLOW_ALGORITHM_KEY; if (!algorithmKey) { return NextResponse.json( - { error: "Upskilling non disponible : HRFLOW_ALGORITHM_KEY manquante." }, + { error: "Service temporairement indisponible" }, { status: 503 }, ); } diff --git a/claw4hr-passive-talent-intelligence/app/api/openclaw/events/route.ts b/claw4hr-passive-talent-intelligence/app/api/openclaw/events/route.ts deleted file mode 100644 index 23fa7dd..0000000 --- a/claw4hr-passive-talent-intelligence/app/api/openclaw/events/route.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getEventsSince } from "@/app/lib/eventStore"; - -/** - * GET /api/openclaw/events?cursor=0 — Poll for new events - * - * Returns events since the given cursor. The response includes a new cursor - * for the next poll. Dashboard should poll every 1-2 seconds. - * - * Response: - * { - * "events": [ { id, timestamp, channel, payload } ], - * "cursor": 42 - * } - */ -export async function GET(req: NextRequest) { - const cursor = parseInt(req.nextUrl.searchParams.get("cursor") ?? "0", 10); - const result = getEventsSince(cursor); - return NextResponse.json(result); -} diff --git a/claw4hr-passive-talent-intelligence/app/api/openclaw/webhook/route.ts b/claw4hr-passive-talent-intelligence/app/api/openclaw/webhook/route.ts deleted file mode 100644 index 914afb4..0000000 --- a/claw4hr-passive-talent-intelligence/app/api/openclaw/webhook/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { pushEvent, type OpenClawEvent } from "@/app/lib/eventStore"; - -/** - * POST /api/openclaw/webhook — Receive events from OpenClaw - * - * Body (single event): - * { - * "channel": "chat" | "feed" | "action", - * "payload": { - * "type": "user" | "agent", // for chat - * "text": "message text", // for chat - * "action": "Parsing CV", // for feed - * "detail": "lot 1/5", // for feed - * "status": "running", // for feed: "done" | "running" | "error" - * "feedType": "parse", // for feed: "connect" | "parse" | "score" | "source" | "analyze" | "notify" - * "command": "fetch_profiles" // for action: triggers dashboard behavior - * } - * } - * - * Body (batch): - * { "events": [ ...array of events above... ] } - * - * Examples: - * - * Chat message from recruiter via Telegram: - * curl -X POST .../api/openclaw/webhook \ - * -H "Content-Type: application/json" \ - * -d '{"channel":"chat","payload":{"type":"user","text":"Trouve-moi des devs Python"}}' - * - * Agent response: - * curl -X POST .../api/openclaw/webhook \ - * -H "Content-Type: application/json" \ - * -d '{"channel":"chat","payload":{"type":"agent","text":"Je lance l analyse..."}}' - * - * Feed event: - * curl -X POST .../api/openclaw/webhook \ - * -H "Content-Type: application/json" \ - * -d '{"channel":"feed","payload":{"action":"Parsing CV","detail":"10 CVs traites","status":"done","feedType":"parse"}}' - * - * Trigger scoring: - * curl -X POST .../api/openclaw/webhook \ - * -H "Content-Type: application/json" \ - * -d '{"channel":"action","payload":{"command":"fetch_profiles"}}' - */ -export async function POST(req: NextRequest) { - try { - const body = await req.json(); - - // Support batch mode - const items: { channel: OpenClawEvent["channel"]; payload: OpenClawEvent["payload"] }[] = - body.events ?? [body]; - - const created = items.map((item) => pushEvent(item.channel, item.payload)); - - return NextResponse.json({ code: 200, message: "OK", count: created.length }, { status: 200 }); - } catch { - return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); - } -} diff --git a/claw4hr-passive-talent-intelligence/app/api/outreach/generate/route.ts b/claw4hr-passive-talent-intelligence/app/api/outreach/generate/route.ts new file mode 100644 index 0000000..41eda89 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/outreach/generate/route.ts @@ -0,0 +1,122 @@ +import { createClient } from "@supabase/supabase-js"; +import { NextRequest } from "next/server"; +import type { SourcedProfile } from "@/app/lib/types"; + +function db() { + return createClient( + process.env.NEXT_PUBLIC_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY! + ); +} + +function buildPrompt(profile: SourcedProfile): string { + const experience = profile.experiences[0] + ? `${profile.experiences[0].title} chez ${profile.experiences[0].company}` + : profile.title; + + const github = profile.sources.find((s) => s.type === "github"); + const sourceDetail = github + ? `présent sur GitHub (${github.label})` + : `profil sourcé sur ${profile.sources[0]?.label ?? "le web"}`; + + return `Tu es un recruteur expert. Génère un message d'approche personnalisé en français (5-7 lignes maximum) pour ce candidat passif. + +Profil : +- Nom : ${profile.name} +- Titre : ${profile.title} +- Localisation : ${profile.location} +- Expérience : ${profile.experience_years} ans +- Dernière expérience : ${experience} +- Compétences clés : ${profile.skills.slice(0, 5).join(", ")} +- Source : ${sourceDetail} +- Résumé : ${profile.summary} + +Règles STRICTES : +1. Cite 1-2 détails CONCRETS et SPÉCIFIQUES du profil (projet, ancienne boîte, technologie rare) +2. Ton professionnel mais humain — pas de bullshit corporate +3. Commence par "Bonjour ${profile.name.split(" ")[0]}," +4. Termine par UNE question ouverte simple (disponibilité, intérêt) +5. Maximum 7 lignes, pas de signature`; +} + +export async function POST(req: NextRequest) { + const body = await req.json(); + const { profile, session_id } = body as { profile: SourcedProfile; session_id: string }; + + if (!profile || !session_id) { + return new Response("missing profile or session_id", { status: 400 }); + } + + const apiKey = process.env.MISTRAL_API_KEY; + if (!apiKey) { + return new Response("MISTRAL_API_KEY not configured", { status: 500 }); + } + + const mistralRes = await fetch("https://api.mistral.ai/v1/chat/completions", { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": `Bearer ${apiKey}`, + }, + body: JSON.stringify({ + model: "mistral-small-latest", + max_tokens: 400, + stream: true, + messages: [{ role: "user", content: buildPrompt(profile) }], + }), + }); + + if (!mistralRes.ok) { + const err = await mistralRes.text(); + return new Response(`Mistral error: ${err}`, { status: 500 }); + } + + let fullMessage = ""; + + const stream = new ReadableStream({ + async start(controller) { + const reader = mistralRes.body!.getReader(); + const decoder = new TextDecoder(); + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + const chunk = decoder.decode(value, { stream: true }); + const lines = chunk.split("\n").filter((l) => l.startsWith("data: ")); + + for (const line of lines) { + const json = line.slice(6); + if (json === "[DONE]") continue; + try { + const parsed = JSON.parse(json); + const text = parsed.choices?.[0]?.delta?.content; + if (text) { + fullMessage += text; + controller.enqueue(new TextEncoder().encode(text)); + } + } catch {} + } + } + + if (fullMessage) { + await db().from("outreach").insert({ + session_id, + profile_key: profile.key, + profile_name: profile.name, + message: fullMessage, + }); + } + + controller.close(); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/plain; charset=utf-8", + "Transfer-Encoding": "chunked", + "X-Content-Type-Options": "nosniff", + }, + }); +} diff --git a/claw4hr-passive-talent-intelligence/app/api/trigger/events/route.ts b/claw4hr-passive-talent-intelligence/app/api/trigger/events/route.ts new file mode 100644 index 0000000..7b49123 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/trigger/events/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { getEventsSince } from "@/app/lib/eventStore"; + +/** + * GET /api/trigger/events?cursor=0 + * Polling alternative to the SSE stream — returns events since cursor. + * + * Response: { events: [...], cursor: number } + */ +export async function GET(req: NextRequest) { + const raw = parseInt(req.nextUrl.searchParams.get("cursor") ?? "0", 10); + const cursor = Number.isFinite(raw) && raw >= 0 ? raw : 0; + const result = getEventsSince(cursor); + return NextResponse.json(result); +} diff --git a/claw4hr-passive-talent-intelligence/app/api/trigger/run/route.ts b/claw4hr-passive-talent-intelligence/app/api/trigger/run/route.ts new file mode 100644 index 0000000..8d1f9fc --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/trigger/run/route.ts @@ -0,0 +1,43 @@ +import { NextRequest, NextResponse } from "next/server"; +import { tasks } from "@trigger.dev/sdk/v3"; +import type { sourcingTask } from "@/trigger/sourcing-task"; + +/** + * POST /api/trigger/run + * Starts a sourcing run via trigger.dev. + * + * Body: { "query": "Lead Data Scientist Paris 5 ans" } + * Response: { "ok": true, "runId": "..." } or { "ok": false, "error": "..." } + * + * Returns 503 with TRIGGER_NOT_CONFIGURED if TRIGGER_SECRET_KEY is not set. + * Results arrive asynchronously via POST /api/trigger/webhook → SSE /api/trigger/stream. + */ +export async function POST(req: NextRequest) { + if (!process.env.TRIGGER_SECRET_KEY) { + return NextResponse.json( + { ok: false, error: "TRIGGER_NOT_CONFIGURED" }, + { status: 503 }, + ); + } + + try { + const { query } = await req.json(); + if (!query || typeof query !== "string") { + return NextResponse.json({ ok: false, error: "Missing query" }, { status: 400 }); + } + + const base = process.env.NEXT_PUBLIC_APP_URL + ?? (process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000"); + const webhookUrl = new URL("/api/trigger/webhook", base).toString(); + + const handle = await tasks.trigger("sourcing-task", { + query, + webhookUrl, + }); + + return NextResponse.json({ ok: true, runId: handle.id }); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 500 }); + } +} diff --git a/claw4hr-passive-talent-intelligence/app/api/trigger/stream/route.ts b/claw4hr-passive-talent-intelligence/app/api/trigger/stream/route.ts new file mode 100644 index 0000000..d5a23b6 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/trigger/stream/route.ts @@ -0,0 +1,61 @@ +import { subscribe, getEventsSince } from "@/app/lib/eventStore"; + +export const dynamic = "force-dynamic"; + +/** + * GET /api/trigger/stream + * Server-Sent Events stream — pushes sourcing events to the dashboard in real time. + * + * Query params: + * cursor (optional): last event id seen (to catch up on missed events) + */ +export async function GET(req: Request) { + const url = new URL(req.url); + const raw = parseInt(url.searchParams.get("cursor") ?? "0", 10); + const cursor = Number.isFinite(raw) && raw >= 0 ? raw : 0; + + const encoder = new TextEncoder(); + + const stream = new ReadableStream({ + start(controller) { + const { events: missed } = getEventsSince(cursor); + for (const event of missed) { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } + + const unsubscribe = subscribe((event) => { + try { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)); + } catch { + unsubscribe(); + try { controller.close(); } catch { /* already closed */ } + } + }); + + const heartbeat = setInterval(() => { + try { + controller.enqueue(encoder.encode(`: heartbeat\n\n`)); + } catch { + clearInterval(heartbeat); + unsubscribe(); + try { controller.close(); } catch { /* already closed */ } + } + }, 15_000); + + req.signal.addEventListener("abort", () => { + clearInterval(heartbeat); + unsubscribe(); + controller.close(); + }); + }, + }); + + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); +} diff --git a/claw4hr-passive-talent-intelligence/app/api/trigger/webhook/route.ts b/claw4hr-passive-talent-intelligence/app/api/trigger/webhook/route.ts new file mode 100644 index 0000000..5a5b5be --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/api/trigger/webhook/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { pushEvent, type OpenClawEvent } from "@/app/lib/eventStore"; + +/** + * POST /api/trigger/webhook + * Receives events from the trigger.dev sourcing task and forwards them + * to the SSE stream so the dashboard updates in real time. + * + * Supported channels: "chat" | "feed" | "action" | "profile" + * + * Profile event: + * { channel: "profile", payload: { profile: SourcedProfile } } + * + * Batch profiles shorthand: + * { profiles: [SourcedProfile, ...] } + * + * Feed log event: + * { channel: "feed", payload: { source, status, message, logType } } + */ +export async function POST(req: NextRequest) { + const secret = process.env.TRIGGER_WEBHOOK_SECRET; + if (secret) { + const provided = req.headers.get("x-trigger-secret"); + if (provided !== secret) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + } + + try { + const body = await req.json(); + + if (Array.isArray(body.profiles)) { + if (body.profiles.length > 50) { + return NextResponse.json( + { error: "Batch size exceeds maximum of 50 profiles" }, + { status: 400 }, + ); + } + + const validProfiles = body.profiles.filter((p: unknown) => { + if (typeof p !== "object" || p === null) return false; + const rec = p as Record; + return ( + typeof rec.key === "string" && + typeof rec.name === "string" && + typeof rec.title === "string" + ); + }); + + const created = validProfiles.map((profile: unknown) => + pushEvent("profile", { profile: profile as OpenClawEvent["payload"]["profile"] }), + ); + return NextResponse.json({ code: 200, message: "OK", count: created.length }); + } + + const VALID_CHANNELS = new Set(["chat", "feed", "action", "profile"]); + const items: { channel: OpenClawEvent["channel"]; payload: OpenClawEvent["payload"] }[] = + body.events ?? [body]; + + const validItems = items.filter( + (item) => typeof item.channel === "string" && VALID_CHANNELS.has(item.channel), + ); + + const created = validItems.map((item) => pushEvent(item.channel, item.payload)); + return NextResponse.json({ code: 200, message: "OK", count: created.length }); + } catch { + return NextResponse.json({ error: "Invalid JSON body" }, { status: 400 }); + } +} diff --git a/claw4hr-passive-talent-intelligence/app/components/AccountDropdown.tsx b/claw4hr-passive-talent-intelligence/app/components/AccountDropdown.tsx new file mode 100644 index 0000000..6a2a174 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/AccountDropdown.tsx @@ -0,0 +1,225 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { SavedSearch, ShortlistEntry, OutreachEntry, SourcedProfile } from "@/app/lib/types"; + +interface AccountDropdownProps { + sessionId: string; + shortlistKeys: Set; + onRelaunchSearch: (query: string) => void; + onOpenProfile: (profile: SourcedProfile) => void; + onClose: () => void; +} + +export default function AccountDropdown({ + sessionId, + shortlistKeys, + onRelaunchSearch, + onOpenProfile, + onClose, +}: AccountDropdownProps) { + const ref = useRef(null); + const [searches, setSearches] = useState([]); + const [shortlist, setShortlist] = useState([]); + const [outreach, setOutreach] = useState([]); + const [loading, setLoading] = useState(true); + const [selectedOutreach, setSelectedOutreach] = useState(null); + const [copied, setCopied] = useState(false); + + useEffect(() => { + async function load() { + const [s, sl, o] = await Promise.all([ + fetch(`/api/account/searches?session_id=${sessionId}`).then((r) => r.json()), + fetch(`/api/account/shortlist?session_id=${sessionId}`).then((r) => r.json()), + fetch(`/api/account/outreach?session_id=${sessionId}`).then((r) => r.json()), + ]); + setSearches(s.data ?? []); + setShortlist(sl.data ?? []); + setOutreach(o.data ?? []); + setLoading(false); + } + load(); + }, [sessionId]); + + useEffect(() => { + function handleClick(e: MouseEvent) { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + } + document.addEventListener("mousedown", handleClick); + return () => document.removeEventListener("mousedown", handleClick); + }, [onClose]); + + return ( +
+ {loading ? ( +
+ Chargement... +
+ ) : ( + <> +
+ {searches.slice(0, 5).map((s) => ( + + ))} +
+ + + +
0 ? ` (${shortlist.length})` : ""}`} + empty={shortlist.length === 0} + emptyText="Aucun profil sauvegardé" + > + {shortlist.slice(0, 5).map((entry) => ( + + ))} +
+ + + +
0 ? ` (${outreach.length})` : ""}`} + empty={outreach.length === 0} + emptyText="Aucun message envoyé" + > + {outreach.slice(0, 5).map((o) => ( + + ))} +
+ + )} + {selectedOutreach && ( +
+ {/* Header */} +
+
+

+ ✉ Message envoyé +

+

+ {selectedOutreach.profile_name} +

+
+ +
+ + {/* Message */} +
+

+ {selectedOutreach.message} +

+
+ + {/* Copy button */} +
+ +
+
+ )} +
+ ); +} + +function Section({ + icon, title, empty, emptyText, children, +}: { + icon: string; title: string; empty: boolean; emptyText: string; children?: React.ReactNode; +}) { + return ( +
+
+ {icon} + + {title} + +
+ {empty ? ( +

{emptyText}

+ ) : children} +
+ ); +} + +function Divider() { + return
; +} diff --git a/claw4hr-passive-talent-intelligence/app/components/AccountModal.tsx b/claw4hr-passive-talent-intelligence/app/components/AccountModal.tsx new file mode 100644 index 0000000..62864cb --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/AccountModal.tsx @@ -0,0 +1,187 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { supabase } from "@/app/lib/supabase"; + +interface AccountModalProps { + userProfile: { name: string; company: string; email: string } | null; + onClose: () => void; + onProfileUpdated: (profile: { name: string; company: string; email: string }) => void; +} + +export default function AccountModal({ userProfile, onClose, onProfileUpdated }: AccountModalProps) { + const router = useRouter(); + const [name, setName] = useState(userProfile?.name ?? ""); + const [company, setCompany] = useState(userProfile?.company ?? ""); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [error, setError] = useState(null); + + const initials = name.trim() + ? name.trim().split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase() + : null; + + async function handleSave() { + setSaving(true); + setError(null); + const { data: { user } } = await supabase.auth.getUser(); + if (!user) { setError("Session expirée"); setSaving(false); return; } + + const { error: upsertError } = await supabase + .from("user_profiles") + .upsert({ id: user.id, name: name.trim(), company: company.trim() }); + + if (upsertError) { + setError("Erreur lors de la sauvegarde"); + } else { + setSaved(true); + onProfileUpdated({ name: name.trim(), company: company.trim(), email: userProfile?.email ?? "" }); + setTimeout(() => setSaved(false), 2000); + } + setSaving(false); + } + + async function handleSignOut() { + await supabase.auth.signOut(); + router.replace("/login"); + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+ {/* Header */} +
+
+
+ {initials ?? ( + + + + )} +
+
+

{name || "Mon compte"}

+

{userProfile?.email}

+
+
+ +
+ + {/* Form */} +
+
+ + setName(e.target.value)} + placeholder="Jean Dupont" + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none transition-all" + style={{ + background: "#f9fafb", + border: "1px solid #e5e7eb", + color: "#111827", + }} + onFocus={(e) => { (e.target as HTMLInputElement).style.borderColor = "#4f46e5"; (e.target as HTMLInputElement).style.background = "#fff"; }} + onBlur={(e) => { (e.target as HTMLInputElement).style.borderColor = "#e5e7eb"; (e.target as HTMLInputElement).style.background = "#f9fafb"; }} + /> +
+ +
+ + setCompany(e.target.value)} + placeholder="Acme Corp" + className="w-full px-3 py-2.5 rounded-lg text-sm outline-none transition-all" + style={{ + background: "#f9fafb", + border: "1px solid #e5e7eb", + color: "#111827", + }} + onFocus={(e) => { (e.target as HTMLInputElement).style.borderColor = "#4f46e5"; (e.target as HTMLInputElement).style.background = "#fff"; }} + onBlur={(e) => { (e.target as HTMLInputElement).style.borderColor = "#e5e7eb"; (e.target as HTMLInputElement).style.background = "#f9fafb"; }} + /> +
+ + {error && ( +

{error}

+ )} + + +
+ + {/* Sign out */} +
+ +
+
+
+ ); +} diff --git a/claw4hr-passive-talent-intelligence/app/components/AgentFeed.tsx b/claw4hr-passive-talent-intelligence/app/components/AgentFeed.tsx deleted file mode 100644 index d8d169b..0000000 --- a/claw4hr-passive-talent-intelligence/app/components/AgentFeed.tsx +++ /dev/null @@ -1,151 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; -import type { FeedEvent } from "@/app/lib/types"; - -interface AgentFeedProps { - events: FeedEvent[]; - totalProfiles: number; - cvCount: number; -} - -const TYPE_ICONS: Record = { - connect: "M13 10V3L4 14h7v7l9-11h-7z", - parse: "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z M14 2v6h6", - score: "M12 20V10 M18 20V4 M6 20v-4", - source: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z", - analyze: "M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z", - notify: "M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9 M13.73 21a2 2 0 01-3.46 0", -}; - -export default function AgentFeed({ events, totalProfiles, cvCount }: AgentFeedProps) { - const scrollRef = useRef(null); - const doneCount = events.filter((e) => e.status === "done").length; - const runningCount = events.filter((e) => e.status === "running").length; - const progress = events.length > 0 ? (doneCount / events.length) * 100 : 0; - - useEffect(() => { - scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); - }, [events]); - - return ( -
- {/* Panel header */} -
-
-
-
0 ? "bg-[var(--accent-cyan)] animate-pulse-dot" : doneCount > 0 ? "bg-[var(--accent-emerald)]" : "bg-[var(--text-muted)]"}`} /> -
0 ? "bg-[var(--accent-cyan)]" : doneCount > 0 ? "bg-[var(--accent-emerald)]" : "bg-[var(--text-muted)]"}`} /> -
-

Pipeline Agent

- {runningCount > 0 && ( - - En cours - - )} -
- - {events.length > 0 && ( -
- - {doneCount}/{events.length} - -
-
-
-
- )} -
- - {/* Feed items */} -
- {events.length === 0 && ( -
-
-

En attente du recruteur...

-
- )} - - {events.map((item, i) => ( - - ))} -
- - {/* Bottom stats bar */} -
-
- 0 ? String(cvCount) : "—"} /> - 0 ? totalProfiles.toLocaleString("fr-FR") : "—"} /> - e.type === "analyze").length)} /> -
- - {events.length} ops - -
-
- ); -} - -function FeedRow({ item, index }: { item: FeedEvent; index: number }) { - const statusEl = - item.status === "done" ? ( - - - - ) : item.status === "running" ? ( -
- ) : ( - - - - - - ); - - const iconPaths = TYPE_ICONS[item.type]; - - return ( -
-
- {statusEl} -
- -
- - - -
- -
-
-

- {item.action} -

- {item.time} -
-

{item.detail}

-
-
- ); -} - -function Stat({ label, value }: { label: string; value: string }) { - return ( -
- {value} - {label} -
- ); -} diff --git a/claw4hr-passive-talent-intelligence/app/components/AnalyseView.tsx b/claw4hr-passive-talent-intelligence/app/components/AnalyseView.tsx new file mode 100644 index 0000000..90c3641 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/AnalyseView.tsx @@ -0,0 +1,274 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { ShortlistEntry, OutreachEntry, SavedSearch } from "@/app/lib/types"; + +interface AnalyseViewProps { + sessionId: string; +} + +interface Stats { + searches: SavedSearch[]; + shortlist: ShortlistEntry[]; + outreach: OutreachEntry[]; +} + +const ACCENT = "#4f46e5"; + +export default function AnalyseView({ sessionId }: AnalyseViewProps) { + const [stats, setStats] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + if (!sessionId || sessionId === "ssr") return; + Promise.all([ + fetch(`/api/account/searches?session_id=${sessionId}`).then((r) => r.json()), + fetch(`/api/account/shortlist?session_id=${sessionId}`).then((r) => r.json()), + fetch(`/api/account/outreach?session_id=${sessionId}`).then((r) => r.json()), + ]).then(([s, sl, o]) => { + setStats({ searches: s.data ?? [], shortlist: sl.data ?? [], outreach: o.data ?? [] }); + setLoading(false); + }).catch(() => setLoading(false)); + }, [sessionId]); + + if (loading || !stats) { + return ( +
+
+
+

Chargement des données…

+
+
+ ); + } + + const totalSourced = stats.searches.reduce((sum, s) => sum + (s.profile_count ?? 0), 0); + const shortlistCount = stats.shortlist.length; + const outreachCount = stats.outreach.length; + const conversionRate = totalSourced > 0 ? Math.round((shortlistCount / totalSourced) * 100) : 0; + const outreachRate = shortlistCount > 0 ? Math.round((outreachCount / shortlistCount) * 100) : 0; + + // Source breakdown from shortlist + const sourceCounts: Record = {}; + stats.shortlist.forEach((entry) => { + entry.profile_data.sources?.forEach((src) => { + sourceCounts[src.type] = (sourceCounts[src.type] ?? 0) + 1; + }); + }); + + // Avg score from shortlisted profiles + const scored = stats.shortlist.filter((e) => e.profile_data.hrflow_score > 0); + const avgScore = scored.length > 0 + ? Math.round(scored.reduce((sum, e) => sum + e.profile_data.hrflow_score, 0) / scored.length) + : null; + + // Recent activity (last 5 searches) + const recentSearches = [...stats.searches].sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime()).slice(0, 5); + + const stageCounts: Record = {}; + stats.shortlist.forEach((e) => { + const stage = (e as { pipeline_stage?: string }).pipeline_stage ?? "shortlisted"; + stageCounts[stage] = (stageCounts[stage] ?? 0) + 1; + }); + const STAGE_META: Record = { + shortlisted: { label: "Shortlistés", color: "#4f46e5" }, + contacted: { label: "Contactés", color: "#f59e0b" }, + waiting: { label: "En attente", color: "#3b82f6" }, + discussing: { label: "En discussion", color: "#10b981" }, + archived: { label: "Archivés", color: "#6b7280" }, + }; + + return ( +
+ {/* Header */} +
+
+

Analyse de campagne

+ + Powered by HrFlow + +
+

Suivi en temps réel de votre activité de sourcing

+
+ + {/* KPI Row */} +
+ } + sub={`${stats.searches.length} recherche${stats.searches.length > 1 ? "s" : ""}`} + accent={ACCENT} + /> + } + sub={`${conversionRate}% de conversion`} + accent="#10b981" + /> + } + sub={`${outreachRate}% du shortlist`} + accent="#f59e0b" + /> +
+ } + sub={scored.length > 0 ? `sur ${scored.length} profil${scored.length > 1 ? "s" : ""} scoré${scored.length > 1 ? "s" : ""}` : "Aucun score HrFlow"} + accent={ACCENT} + /> +
+
+ +
+ {/* Funnel */} +
+

Entonnoir de recrutement

+
+ + + +
+
+ + {/* Sources */} +
+

Sources de candidats

+ {Object.keys(sourceCounts).length === 0 ? ( +

Aucune donnée de source disponible

+ ) : ( +
+ {Object.entries(sourceCounts) + .sort((a, b) => b[1] - a[1]) + .map(([type, count]) => ( + + ))} +
+ )} +
+
+ + {/* Pipeline stage breakdown */} + {Object.keys(stageCounts).length > 0 && ( +
+

Répartition par étape (Pipeline)

+
+ {Object.entries(stageCounts).map(([stage, count]) => { + const meta = STAGE_META[stage] ?? { label: stage, color: "#6b7280" }; + return ( +
+
+ {meta.label} +
+
+
+ {count} +
+ ); + })} +
+
+ )} + + {/* Recent searches */} +
+

Recherches récentes

+ {recentSearches.length === 0 ? ( +

Aucune recherche effectuée

+ ) : ( +
+ {recentSearches.map((s) => ( +
+
+
+ +
+ {s.query} +
+
+ + {s.profile_count ?? 0} profil{(s.profile_count ?? 0) > 1 ? "s" : ""} + + + {new Date(s.created_at).toLocaleDateString("fr-FR", { day: "numeric", month: "short" })} + +
+
+ ))} +
+ )} +
+
+ ); +} + +function KpiCard({ label, value, icon, sub, accent }: { label: string; value: number | string; icon: React.ReactNode; sub: string; accent: string }) { + return ( +
+
+ {label} +
+ {icon} +
+
+

{value}

+

{sub}

+
+ ); +} + +function FunnelBar({ label, value, max, color }: { label: string; value: number; max: number; color: string }) { + const pct = max > 0 ? Math.max((value / max) * 100, value > 0 ? 4 : 0) : 0; + const displayPct = max > 0 && value > 0 ? Math.round((value / max) * 100) : 0; + return ( +
+ {label} +
+
+
+
+ {value} + {max > 0 && ({displayPct}%)} +
+
+ ); +} + +const SOURCE_META: Record = { + github: { label: "GitHub", color: "#1f2937" }, + linkedin: { label: "LinkedIn", color: "#0077b5" }, + reddit: { label: "Reddit", color: "#ff4500" }, + internet: { label: "Web", color: "#7c3aed" }, +}; + +function SourceRow({ type, count, total }: { type: string; count: number; total: number }) { + const meta = SOURCE_META[type] ?? { label: type, color: "#6b7280" }; + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + return ( +
+ {meta.label} +
+
0 ? 4 : 0)}%`, background: meta.color }} /> +
+ {count} ({pct}%) +
+ ); +} + +function SearchIcon({ color = "currentColor", size = 14 }: { color?: string; size?: number }) { + return ; +} +function StarIcon() { + return ; +} +function MailIcon() { + return ; +} +function ScoreIcon() { + return ; +} diff --git a/claw4hr-passive-talent-intelligence/app/components/AuthGuard.tsx b/claw4hr-passive-talent-intelligence/app/components/AuthGuard.tsx new file mode 100644 index 0000000..4c73ec0 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/AuthGuard.tsx @@ -0,0 +1,38 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { supabase } from "@/app/lib/supabase"; + +export default function AuthGuard({ children }: { children: React.ReactNode }) { + const router = useRouter(); + const [checked, setChecked] = useState(false); + + useEffect(() => { + supabase.auth.getSession().then(({ data: { session } }) => { + if (!session) { + router.replace("/login"); + } else { + setChecked(true); + } + }); + }, [router]); + + if (!checked) { + return ( +
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+ ); + } + + return <>{children}; +} diff --git a/claw4hr-passive-talent-intelligence/app/components/CandidateCard.tsx b/claw4hr-passive-talent-intelligence/app/components/CandidateCard.tsx new file mode 100644 index 0000000..9b00c74 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/CandidateCard.tsx @@ -0,0 +1,331 @@ +"use client"; + +import type { SourcedProfile, SourceType } from "@/app/lib/types"; +import ScoreRing from "./ScoreRing"; + +interface CandidateCardProps { + profile: SourcedProfile; + index: number; + isSaved: boolean; + onSelect: (profile: SourcedProfile) => void; + onSave: (profile: SourcedProfile) => void; + onContact?: (profile: SourcedProfile) => void; + viewMode?: "grid" | "list"; +} + +const SOURCE_LABELS: Record = { + github: "GitHub", + linkedin: "LinkedIn", + reddit: "Reddit", + internet: "Web", + indeed: "Indeed", + hellowork: "HelloWork", +}; + +const SOURCE_COLORS: Record = { + github: "#1a1a2e", + linkedin: "#0077b5", + reddit: "#ff4500", + internet: "#6b7280", + indeed: "#2164f3", + hellowork: "#e05c2a", +}; + +function Avatar({ name, color, size = 44 }: { name: string; color: string; size?: number }) { + const initials = name + .split(" ") + .map((w) => w[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +function ScoreBadge({ score }: { score: number }) { + const color = score >= 80 ? "#10b981" : score >= 60 ? "#f59e0b" : "#6b7280"; + if (score < 0) return null; + return ( + + {score}% + + ); +} + +export default function CandidateCard({ + profile, + index, + isSaved, + onSelect, + onSave, + onContact, + viewMode = "grid", +}: CandidateCardProps) { + if (viewMode === "list") { + return ( +
onSelect(profile)} + onMouseEnter={(e) => { + (e.currentTarget as HTMLDivElement).style.borderColor = "#FF6B6B"; + (e.currentTarget as HTMLDivElement).style.boxShadow = "0 2px 12px rgba(255,107,107,0.1)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLDivElement).style.borderColor = "#e5e7eb"; + (e.currentTarget as HTMLDivElement).style.boxShadow = "none"; + }} + > + + + {/* Name + title */} +
+

{profile.name}

+

{profile.title}

+
+ + {/* Location + XP */} +
+

+ {profile.location} +

+

+ {profile.experience_years} ans XP +

+
+ + {/* Skills */} +
+ {profile.skills.slice(0, 3).map((skill) => ( + + {skill} + + ))} + {profile.skills.length > 3 && ( + + +{profile.skills.length - 3} + + )} +
+ + {/* Sources */} +
+ {profile.sources.map((s) => ( + + {SOURCE_LABELS[s.type][0]} + + ))} +
+ + {/* Score */} +
+ +
+ + {/* Actions */} +
e.stopPropagation()}> + + {onContact && ( + + )} + +
+
+ ); + } + + // Grid mode + const MAX_SKILLS = 4; + const extra = profile.skills.length - MAX_SKILLS; + + return ( +
onSelect(profile)} + > +
{ + (e.currentTarget as HTMLDivElement).style.boxShadow = "0 6px 24px rgba(255,107,107,0.12)"; + (e.currentTarget as HTMLDivElement).style.borderColor = "#FF6B6B"; + (e.currentTarget as HTMLDivElement).style.transform = "translateY(-1px)"; + }} + onMouseLeave={(e) => { + (e.currentTarget as HTMLDivElement).style.boxShadow = "0 1px 4px rgba(0,0,0,0.05)"; + (e.currentTarget as HTMLDivElement).style.borderColor = "#e5e7eb"; + (e.currentTarget as HTMLDivElement).style.transform = "none"; + }} + > + {/* Top row */} +
+
+ +
+

+ {profile.name} +

+

+ {profile.title} +

+

+ {profile.location} · {profile.experience_years} ans +

+
+
+
+ + +
+
+ + {/* Source badges */} + + + {/* Skills */} +
+ {profile.skills.slice(0, MAX_SKILLS).map((skill) => ( + + {skill} + + ))} + {extra > 0 && ( + + +{extra} + + )} +
+ + {/* CTA */} +
+ + {onContact && ( + + )} +
+
+
+ ); +} diff --git a/claw4hr-passive-talent-intelligence/app/components/CandidatePanel.tsx b/claw4hr-passive-talent-intelligence/app/components/CandidatePanel.tsx deleted file mode 100644 index e854f63..0000000 --- a/claw4hr-passive-talent-intelligence/app/components/CandidatePanel.tsx +++ /dev/null @@ -1,783 +0,0 @@ -"use client"; - -import { useState, useRef, useEffect } from "react"; -import type { HrFlowProfile } from "@/app/lib/types"; - -/* ─── Props ───────────────────────────────────────────────── */ - -interface CandidatePanelProps { - profiles: HrFlowProfile[]; - loading: boolean; - selectedKey: string | null; - onSelect: (profile: HrFlowProfile) => void; - onAsk: (profile: HrFlowProfile) => void; - scores?: Map; - jobKey?: string | null; - mode?: "demo" | "live"; -} - -/* ─── Utilities ───────────────────────────────────────────── */ - -function formatDuration( - dateStart: { iso8601: string | null } | null, - dateEnd: { iso8601: string | null } | null, -): string { - if (!dateStart?.iso8601) return ""; - const start = new Date(dateStart.iso8601); - const end = dateEnd?.iso8601 ? new Date(dateEnd.iso8601) : new Date(); - const months = Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))); - if (months < 12) return `${months} mois`; - const years = Math.floor(months / 12); - const rem = months % 12; - return rem > 0 ? `${years}a ${rem}m` : `${years} an${years > 1 ? "s" : ""}`; -} - -function totalExperienceYears(profile: HrFlowProfile): number { - let totalMonths = 0; - for (const exp of profile.experiences ?? []) { - if (!exp.date_start?.iso8601) continue; - const start = new Date(exp.date_start.iso8601); - const end = exp.date_end?.iso8601 ? new Date(exp.date_end.iso8601) : new Date(); - totalMonths += Math.max(1, Math.round((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24 * 30.44))); - } - return Math.round(totalMonths / 12); -} - -/* ─── Panel ───────────────────────────────────────────────── */ - -export default function CandidatePanel({ - profiles, - loading, - selectedKey, - onSelect, - onAsk, - scores, - jobKey, - mode, -}: CandidatePanelProps) { - const [expandedKey, setExpandedKey] = useState(null); - const [skillsPreviewKey, setSkillsPreviewKey] = useState(null); - const [sortMode, setSortMode] = useState<"default" | "score-desc" | "score-asc" | "high-only">("default"); - const [upskillData, setUpskillData] = useState>>(new Map()); - const [upskillLoading, setUpskillLoading] = useState(null); - - const handleUpskill = async (profileKey: string) => { - if (upskillData.has(profileKey) || !jobKey) return; - setUpskillLoading(profileKey); - try { - const res = await fetch(`/api/hrflow/upskill?profile_key=${profileKey}&job_key=${jobKey}&mode=demo`); - const data = await res.json(); - if (data.code === 200) { - setUpskillData((prev) => new Map(prev).set(profileKey, data.data)); - } - } catch { /* silently fail */ } - setUpskillLoading(null); - }; - - const toggleSkillsPreview = (key: string) => { - setSkillsPreviewKey(skillsPreviewKey === key ? null : key); - }; - - /* ─── Sort / filter logic ──────────────────────────────── */ - const sortedProfiles = (() => { - let list = [...profiles]; - if (sortMode === "high-only") { - list = list.filter((p) => (scores?.get(p.key) ?? 0) >= 70); - } - if (sortMode === "score-desc" || sortMode === "high-only") { - list.sort((a, b) => (scores?.get(b.key) ?? 0) - (scores?.get(a.key) ?? 0)); - } else if (sortMode === "score-asc") { - list.sort((a, b) => (scores?.get(a.key) ?? 0) - (scores?.get(b.key) ?? 0)); - } - return list; - })(); - - return ( -
- {/* Header */} -
-
-

Candidats

- - {sortedProfiles.length}{sortMode === "high-only" ? `/${profiles.length}` : ""} charges - -
- {scores && scores.size > 0 && ( -
- {(["default", "score-desc", "score-asc", "high-only"] as const).map((mode) => { - const labels = { "default": "Ordre", "score-desc": "Score \u2193", "score-asc": "Score \u2191", "high-only": "\u2265 70%" }; - return ( - - ); - })} -
- )} -
- - {/* List */} -
- {loading && ( -
-
-
-

Analyse des profils en cours...

-
-
- )} - - {!loading && sortedProfiles.length === 0 && ( -
-

- {sortMode === "high-only" ? "Aucun profil avec un score >= 70%." : "Aucun profil trouve."} -

-
- )} - - {sortedProfiles.map((profile, i) => ( - setExpandedKey(expandedKey === profile.key ? null : profile.key)} - onToggleSkills={() => toggleSkillsPreview(profile.key)} - onSelect={() => onSelect(profile)} - onAsk={() => onAsk(profile)} - onUpskill={() => handleUpskill(profile.key)} - upskill={upskillData.get(profile.key) ?? null} - upskillLoading={upskillLoading === profile.key} - hasJobKey={!!jobKey} - delay={i * 80} - /> - ))} -
-
- ); -} - -/* ─── Score Ring ───────────────────────────────────────────── */ - -function ScoreRing({ score, size = 44 }: { score: number; size?: number }) { - const radius = (size - 6) / 2; - const circumference = 2 * Math.PI * radius; - const offset = circumference - (score / 100) * circumference; - const color = - score >= 70 ? "var(--accent-emerald)" : - score >= 40 ? "var(--accent-amber)" : - "var(--accent-rose)"; - - return ( -
- - - - - - {score}% - -
- ); -} - -/* ─── Skill Tag ───────────────────────────────────────────── */ - -function SkillTag({ name, type }: { name: string; type: string | null }) { - const isHard = type === "hard"; - return ( - - {name} - - ); -} - -/* ─── Stat Box ────────────────────────────────────────────── */ - -function StatBox({ - label, - value, - accent, - onClick, -}: { - label: string; - value: string; - accent?: boolean; - onClick?: () => void; -}) { - return ( - - ); -} - -/* ─── Candidate Card ──────────────────────────────────────── */ - -function CandidateCard({ - profile, - rank, - expanded, - selected, - score, - showSkillsPreview, - onToggle, - onToggleSkills, - onSelect, - onAsk, - onUpskill, - upskill, - upskillLoading, - hasJobKey, - delay, -}: { - profile: HrFlowProfile; - rank: number; - expanded: boolean; - selected: boolean; - score: number | null; - showSkillsPreview: boolean; - onToggle: () => void; - onToggleSkills: () => void; - onSelect: () => void; - onAsk: () => void; - onUpskill: () => void; - upskill: Record | null; - upskillLoading: boolean; - hasJobKey: boolean; - delay: number; -}) { - const name = profile.info.full_name || "Sans nom"; - const latestExp = profile.experiences?.[0]; - const title = latestExp?.title ?? "Pas de titre"; - const company = latestExp?.company ?? null; - const location = profile.info.location?.text ?? "Localisation inconnue"; - const allSkills = profile.skills ?? []; - const hardSkills = allSkills.filter((s) => s.type === "hard"); - const softSkills = allSkills.filter((s) => s.type !== "hard"); - const expCount = profile.experiences?.length ?? 0; - const eduCount = profile.educations?.length ?? 0; - const langCount = profile.languages?.length ?? 0; - const totalYears = totalExperienceYears(profile); - const email = profile.info.email; - const cardRef = useRef(null); - - useEffect(() => { - if (expanded && cardRef.current) { - setTimeout(() => cardRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }), 50); - } - }, [expanded]); - - return ( -
- {/* ── Compact row ──────────────────────────────────── */} -
- {/* Avatar + rank */} -
- - {name.split(" ").map((n) => n[0]).join("").slice(0, 2).toUpperCase()} - -
- {rank} -
-
- - {/* Info */} -
-

{name}

-

- {title}{company ? ` — ${company}` : ""} -

-
- - - - - - {location} - - {totalYears > 0 && ( - {totalYears} an{totalYears > 1 ? "s" : ""} exp. - )} - {/* Clickable skills badge */} - {allSkills.length > 0 && ( - - )} -
-
- - {/* Score + expand */} -
- {score !== null && } - - - -
-
- - {/* ── Skills preview (toggleable without full expand) ── */} - {showSkillsPreview && !expanded && ( -
-
- {allSkills.slice(0, 12).map((skill, i) => ( - - ))} - {allSkills.length > 12 && ( - - )} -
-
- )} - - {/* ── Expanded detail ──────────────────────────────── */} - {expanded && ( -
- - {/* Quick stats */} -
- - - - - {totalYears > 0 && } -
- - {/* Contact */} - {email && ( -
- - - - - {email} -
- )} - - {/* Hard skills */} - {hardSkills.length > 0 && ( - - )} - - {/* Soft skills */} - {softSkills.length > 0 && ( - - )} - - {/* All skills fallback */} - {hardSkills.length === 0 && softSkills.length === 0 && allSkills.length > 0 && ( - - )} - - {/* Languages */} - {langCount > 0 && ( -
- -
- {profile.languages.map((lang) => ( - - {lang.name}{lang.value ? ` — ${lang.value}` : ""} - - ))} -
-
- )} - - {/* Experience timeline */} - {expCount > 0 && ( -
- -
-
- {profile.experiences.map((exp, i) => ( - - ))} -
-
- )} - - {/* Education */} - {eduCount > 0 && ( -
- -
- {profile.educations.map((edu, i) => ( - - ))} -
-
- )} - - {/* Summary */} - {profile.info.summary && ( -
- -

{profile.info.summary}

-
- )} - - {/* Upskilling — AI matching explanation */} - {hasJobKey && ( -
- {!upskill && ( - - )} - {upskill && } -
- )} - - {/* Actions */} -
- - -
-
- )} -
- ); -} - -/* ─── Reusable sub-components ─────────────────────────────── */ - -function SectionTitle({ text }: { text: string }) { - return ( -

{text}

- ); -} - -function SkillsSection({ - title, - count, - skills, - limit, - accent, -}: { - title: string; - count: number; - skills: { name: string; type: string | null }[]; - limit?: number; - accent?: boolean; -}) { - const displayed = limit ? skills.slice(0, limit) : skills; - const remaining = limit ? skills.length - limit : 0; - - return ( -
-

- {title} ({count}) -

-
- {displayed.map((skill, i) => ( - - ))} - {remaining > 0 && ( - +{remaining} - )} -
-
- ); -} - -function ExperienceRow({ - exp, - isFirst, -}: { - exp: HrFlowProfile["experiences"][number]; - isFirst: boolean; -}) { - const duration = formatDuration(exp.date_start, exp.date_end); - const startYear = exp.date_start?.iso8601?.slice(0, 4) ?? ""; - const endYear = exp.date_end?.iso8601?.slice(0, 4) ?? "Actuel"; - - return ( -
-
-
-
-

{exp.title ?? "Sans titre"}

- {duration && ( - - {duration} - - )} -
-

- {exp.company ?? ""}{exp.location?.text ? ` · ${exp.location.text}` : ""} -

- {startYear && ( -

{startYear} — {endYear}

- )} - {exp.description && ( -

{exp.description}

- )} -
-
- ); -} - -/* ─── Upskill SWOT display ───────────────────────────────── */ - -interface SwotItem { - name: string; - description: string; -} - -function UpskillSection({ data }: { data: Record }) { - const strengths = (data.strengths ?? []) as SwotItem[]; - const weaknesses = (data.weaknesses ?? []) as SwotItem[]; - - if (strengths.length === 0 && weaknesses.length === 0) { - return ( -
-

Aucune analyse disponible pour ce profil.

-
- ); - } - - return ( -
- {/* Header */} -
- - - -

Analyse SWOT du matching

- - {strengths.length} forces · {weaknesses.length} gaps - -
- -
- {/* Strengths */} - {strengths.length > 0 && ( -
-
-
-

- Points forts ({strengths.length}) -

-
-
- {strengths.map((s, i) => ( -
- - - -
-

{s.name}

-

{s.description}

-
-
- ))} -
-
- )} - - {/* Weaknesses */} - {weaknesses.length > 0 && ( -
-
-
-

- Gaps a combler ({weaknesses.length}) -

-
-
- {weaknesses.map((w, i) => ( -
- - - - - -
-

{w.name}

-

{w.description}

-
-
- ))} -
-
- )} -
-
- ); -} - -function EducationRow({ edu }: { edu: HrFlowProfile["educations"][number] }) { - const startYear = edu.date_start?.iso8601?.slice(0, 4) ?? ""; - const endYear = edu.date_end?.iso8601?.slice(0, 4) ?? ""; - - return ( -
-
- - - - - - - -
-
-

{edu.title ?? "Sans titre"}

-

{edu.school ?? ""}

- {(startYear || endYear) && ( -

- {startYear}{endYear ? ` — ${endYear}` : ""} -

- )} -
-
- ); -} diff --git a/claw4hr-passive-talent-intelligence/app/components/Dashboard.tsx b/claw4hr-passive-talent-intelligence/app/components/Dashboard.tsx index f08b3c9..1f650f4 100644 --- a/claw4hr-passive-talent-intelligence/app/components/Dashboard.tsx +++ b/claw4hr-passive-talent-intelligence/app/components/Dashboard.tsx @@ -1,29 +1,29 @@ "use client"; import { useCallback, useEffect, useRef, useState } from "react"; -import type { HrFlowProfile, FeedEvent, ChatMessage } from "@/app/lib/types"; -import TopBar from "./TopBar"; -import WhatsAppPanel from "./WhatsAppPanel"; -import AgentFeed from "./AgentFeed"; -import CandidatePanel from "./CandidatePanel"; - -/* ─── Helpers ─────────────────────────────────────────────── */ - -function feedEvent( - action: string, - detail: string, - type: FeedEvent["type"], - status: FeedEvent["status"] = "done", -): FeedEvent { - return { - id: crypto.randomUUID(), - time: new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" }), - action, - detail, - status, - type, - }; -} +import type { SourcedProfile, ChatMessage } from "@/app/lib/types"; +import type { AgentSource, AgentState } from "./PixelAgent"; +import SearchView from "./SearchView"; +import LoadingView from "./LoadingView"; +import ResultsView from "./ResultsView"; +import ProfileDetailView from "./ProfileDetailView"; +import Sidebar from "./Sidebar"; +import type { NavSection } from "./Sidebar"; +import ShortlistView from "./ShortlistView"; +import OutreachView from "./OutreachView"; +import HistoryView from "./HistoryView"; +import OutreachModal from "./OutreachModal"; +import AccountModal from "./AccountModal"; +import AnalyseView from "./AnalyseView"; +import PipelineView from "./PipelineView"; +import TeamView from "./TeamView"; +import type { FeedLog } from "@/app/lib/types"; +import { getSessionId } from "@/app/lib/session"; +import { supabase } from "@/app/lib/supabase"; + +// ─── Types ──────────────────────────────────────────────────── + +type DashboardView = "search" | "loading" | "results" | "profile" | "shortlist" | "outreach" | "history" | "analyse" | "pipeline" | "team"; function chatMsg(type: "user" | "agent", text: string): ChatMessage { return { @@ -34,686 +34,437 @@ function chatMsg(type: "user" | "agent", text: string): ChatMessage { }; } -/* ─── Keyword extraction from natural language query ──────── */ - -const STOP_WORDS = new Set([ - "je", "tu", "il", "nous", "vous", "ils", "me", "te", "se", "le", "la", "les", "un", "une", "des", - "de", "du", "au", "aux", "a", "en", "et", "ou", "mais", "donc", "car", "ni", "que", "qui", "quoi", - "ce", "cette", "ces", "mon", "ton", "son", "notre", "votre", "leur", "pour", "par", "sur", "dans", - "avec", "sans", "sous", "entre", "vers", "chez", "pas", "ne", "plus", "moins", "tres", "trop", - "est", "sont", "suis", "es", "etre", "avoir", "fait", "faire", "dit", "dire", - "trouve", "trouve-moi", "trouvez", "cherche", "cherche-moi", "cherchez", "recherche", - "source", "source-moi", "sourcez", "donne", "donne-moi", "donnez", "montre", "montre-moi", - "moi", "veux", "voudrais", "besoin", "faut", "peux", "peut", "fais", - "profils", "profil", "candidats", "candidat", "talents", "talent", "personnes", "gens", - "meilleurs", "meilleur", "bons", "bon", "top", -]); - -function extractKeywords(query: string): string { - const words = query - .toLowerCase() - .normalize("NFD").replace(/[\u0300-\u036f]/g, "") // remove accents - .replace(/[^a-z0-9+#\s-]/g, " ") // keep alphanumeric, +, #, - - .split(/\s+/) - .filter((w) => w.length > 1 && !STOP_WORDS.has(w)); - // HrFlow does AND on keywords — too many = 0 results. Keep max 3. - // Progressive retry in fetchAndRevealProfiles handles the case where 3 gives 0 results. - return words.slice(0, 3).join(","); -} - -/* ─── Pipeline demo script ────────────────────────────────── */ - -const DEMO_JOB = "Full-Stack Software Engineer Python / React — Paris"; - -const PIPELINE_SCRIPT: { delay: number; run: (ctx: PipelineContext) => void }[] = [ - /* ── 0. Recruiter request ─────────────────────────────────── */ - { - delay: 500, - run: (ctx) => { - ctx.setSearchQuery(DEMO_JOB); - ctx.addMessage(chatMsg("user", - `Source-moi des profils pour : ${DEMO_JOB}\n\nJe veux des talents passifs — pas que des gens qui ont postule.`, - )); - }, - }, - /* ── 1. Agent acknowledges ────────────────────────────────── */ - { - delay: 2500, - run: (ctx) => { - ctx.addMessage(chatMsg("agent", - `Compris ! Je lance le sourcing sur le web ouvert pour "${DEMO_JOB}".\n\n` + - `Sources ciblees :\n` + - `• GitHub — contributeurs Python actifs a Paris\n` + - `• LinkedIn — profils publics Full-Stack\n` + - `• CV-theques publiques (Indeed, HelloWork)\n\n` + - `C'est parti...`, - )); - ctx.addFeed(feedEvent("Initialisation agent", "Analyse de la requete recruteur — extraction criteres", "connect", "running")); - }, - }, - /* ── 2. Criteria extracted ────────────────────────────────── */ - { - delay: 4500, - run: (ctx) => { - ctx.updateLastFeed("done", "Criteres : Python, React, Full-Stack, CDI, Paris, 3+ ans XP"); - ctx.addFeed(feedEvent("Sourcing GitHub", "Recherche contributeurs Python — repos 500+ stars — localisation Paris", "source", "running")); - }, - }, - /* ── 3. GitHub sourcing ───────────────────────────────────── */ - { - delay: 7000, - run: (ctx) => { - ctx.updateLastFeed("done", "14 profils developeurs trouves sur GitHub"); - ctx.setCvCount(14); - ctx.addMessage(chatMsg("agent", "GitHub : 14 profils de contributeurs Python actifs trouves a Paris. Passage a LinkedIn...")); - ctx.addFeed(feedEvent("Sourcing LinkedIn", "Scan profils publics — Dev Python / React — region Ile-de-France", "source", "running")); - }, - }, - /* ── 4. LinkedIn sourcing ─────────────────────────────────── */ - { - delay: 10000, - run: (ctx) => { - ctx.updateLastFeed("done", "11 profils publics identifies sur LinkedIn"); - ctx.setCvCount(25); - ctx.addMessage(chatMsg("agent", "LinkedIn : 11 profils publics supplementaires. Scan des CV-theques en cours...")); - ctx.addFeed(feedEvent("Sourcing CV-theques", "Scan Indeed + HelloWork — profils publics Python/React Paris", "source", "running")); - }, - }, - /* ── 5. CV databases sourcing ─────────────────────────────── */ - { - delay: 12500, - run: (ctx) => { - ctx.updateLastFeed("done", "13 profils extraits des CV-theques publiques"); - ctx.setCvCount(38); - ctx.addMessage(chatMsg("agent", - "38 profils sources au total sur 3 plateformes.\n\n" + - "Lancement du pipeline HrFlow — parsing + analyse...", - )); - }, - }, - /* ── 6. HrFlow Parsing ────────────────────────────────────── */ - { - delay: 14000, - run: (ctx) => { - ctx.addFeed(feedEvent("Parsing HrFlow", "POST /profile/parsing — structuration des 38 profils web", "parse", "running")); - }, - }, - { - delay: 16000, - run: (ctx) => { - ctx.updateLastFeed("done", "38 profils structures — competences, experiences, formations extraites"); - ctx.addFeed(feedEvent("Indexation HrFlow", "POST /profile/indexing — 38 profils indexes", "parse")); - }, - }, - /* ── 7. HrFlow Scoring ────────────────────────────────────── */ - { - delay: 17000, - run: (ctx) => { - ctx.addFeed(feedEvent("Scoring IA", `GET /profiles/scoring — matching vs "${DEMO_JOB}"`, "score", "running")); - ctx.addMessage(chatMsg("agent", "Profils indexes. Scoring IA en cours — classement par pertinence vs le poste...")); - }, - }, - /* ── 8. Scoring done — fetch real HrFlow profiles ─────────── */ - { - delay: 20000, - run: (ctx) => { - ctx.updateLastFeed("done", "Scoring termine — top 20 classe par pertinence"); - ctx.addFeed(feedEvent("Analyse profils", "Chargement des profils scores", "analyze", "running")); - ctx.fetchAndRevealProfiles(); - }, - }, - /* ── 9. Profiles loaded ───────────────────────────────────── */ - { - delay: 23000, - run: (ctx) => { - ctx.updateLastFeed("done", "Profils charges avec scores et details"); - ctx.addFeed(feedEvent("Upskilling IA", "GET /profile/upskilling — gap analysis sur le top 5", "analyze", "running")); - }, - }, - /* ── 10. Upskilling done ──────────────────────────────────── */ - { - delay: 25500, - run: (ctx) => { - ctx.updateLastFeed("done", "Gap analysis generee — plans de montee en competences"); - }, - }, - /* ── 11. Summary to recruiter ─────────────────────────────── */ - { - delay: 27000, - run: (ctx) => { - ctx.addFeed(feedEvent("Rapport Telegram", "Envoi du top 3 au recruteur", "notify")); - ctx.sendTopSummary(); - }, - }, - /* ── 12. Pipeline complete ────────────────────────────────── */ - { - delay: 30000, - run: (ctx) => { - ctx.addMessage(chatMsg("agent", - "Sourcing termine ! 38 candidats passifs trouves sur le web ouvert, analyses et classes.\n\n" + - "Cliquez sur un profil pour voir le detail complet, le gap analysis, ou posez-moi une question sur n'importe quel candidat.", - )); - ctx.addFeed(feedEvent("Agent pret", "En attente — Q&A, upskilling, sourcing additionnel", "connect")); - ctx.setPipelineDone(true); - }, - }, -]; - -/* ─── Live pipeline script (triggered by first OpenClaw message) ── */ - -const TECH_KEYWORDS = new Set([ - "dev", "developer", "developpeur", "engineer", "ingenieur", "software", "backend", "frontend", - "full-stack", "fullstack", "devops", "sre", "data", "ml", "machine", "learning", "python", - "java", "javascript", "typescript", "react", "node", "go", "rust", "c++", "kotlin", "swift", - "ios", "android", "mobile", "cloud", "aws", "azure", "gcp", "infra", "security", "cyber", - "blockchain", "web3", "ai", "ia", "tech", "cto", "vp", "architect", "qa", "test", "sdet", -]); - -function isTechQuery(query: string): boolean { - const words = query.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, "").split(/\s+/); - return words.some((w) => TECH_KEYWORDS.has(w)); +function viewToSection(view: DashboardView): NavSection { + if (view === "shortlist") return "shortlist"; + if (view === "outreach") return "outreach"; + if (view === "history") return "history"; + if (view === "analyse") return "analyse"; + if (view === "pipeline") return "pipeline"; + if (view === "team") return "team"; + return "search"; } -type PipelineStep = { delay: number; run: (ctx: PipelineContext) => void }; - -function buildLivePipeline(query: string): PipelineStep[] { - const tech = isTechQuery(query); - const steps: PipelineStep[] = []; - let t = 0; // running clock - - // 1. Agent acknowledges - t += 2000; - steps.push({ delay: t, run: (ctx) => { - ctx.setSearchQuery(query); - ctx.addMessage(chatMsg("agent", `Bien recu ! Je lance le sourcing pour "${query}".\n\nRecherche dans la base HrFlow (10 000+ profils indexes)...`)); - ctx.addFeed(feedEvent("Connexion OpenClaw", "Agent connecte via Telegram — requete recue", "connect")); - }}); - - // 2. Criteria extraction - t += 1500; - steps.push({ delay: t, run: (ctx) => { - ctx.addFeed(feedEvent("Extraction criteres", "NLP — analyse de la requete recruteur", "connect", "running")); - }}); - t += 2000; - steps.push({ delay: t, run: (ctx) => { - ctx.updateLastFeed("done", "Criteres extraits : competences, localisation, seniorite"); - }}); - - // 3. GitHub (tech only — fails gracefully) - if (tech) { - t += 1500; - steps.push({ delay: t, run: (ctx) => { - ctx.addFeed(feedEvent("Sourcing GitHub", "API GitHub Search — tentative de connexion", "source", "running")); - }}); - t += 2500; - steps.push({ delay: t, run: (ctx) => { - ctx.updateLastFeed("error", "GitHub API — token non configure, source ignoree"); - }}); - } - - // 4. LinkedIn (fails gracefully) - t += 1500; - steps.push({ delay: t, run: (ctx) => { - ctx.addFeed(feedEvent("Sourcing LinkedIn", "Proxycurl — tentative de connexion", "source", "running")); - }}); - t += 2500; - steps.push({ delay: t, run: (ctx) => { - ctx.updateLastFeed("error", "LinkedIn API — cle Proxycurl non configuree, source ignoree"); - ctx.addMessage(chatMsg("agent", "Sources externes non disponibles (GitHub, LinkedIn). Recherche dans la base HrFlow indexee...")); - }}); - - // 5. HrFlow keyword search + scoring - t += 1500; - steps.push({ delay: t, run: (ctx) => { - ctx.addFeed(feedEvent("Recherche HrFlow", `GET /profiles/searching — mots-cles : "${query}"`, "score", "running")); - ctx.addMessage(chatMsg("agent", "Recherche dans la base HrFlow en cours...")); - }}); - t += 3000; - steps.push({ delay: t, run: (ctx) => { - ctx.updateLastFeed("done", "Profils correspondants trouves dans la base"); - }}); - - // 6. Load real profiles from HrFlow - t += 1000; - steps.push({ delay: t, run: (ctx) => { - ctx.addFeed(feedEvent("Chargement profils", "Recuperation et scoring des profils", "analyze", "running")); - ctx.fetchAndRevealProfiles(); - }}); - t += 4000; - steps.push({ delay: t, run: (ctx) => { - ctx.updateLastFeed("done", "Profils charges avec scores et details complets"); - }}); - - // 7. Summary to recruiter - t += 2000; - steps.push({ delay: t, run: (ctx) => { - ctx.addFeed(feedEvent("Rapport Telegram", "Envoi du classement au recruteur via OpenClaw", "notify", "running")); - }}); - t += 2000; - steps.push({ delay: t, run: (ctx) => { - ctx.updateLastFeed("done", "Top 3 envoye au recruteur"); - ctx.sendTopSummary(); - }}); - - // 8. Done - t += 3000; - steps.push({ delay: t, run: (ctx) => { - ctx.addMessage(chatMsg("agent", - "Analyse terminee ! Les profils sont classes par pertinence.\n\n" + - "Cliquez sur un profil pour voir le detail, l'analyse SWOT, ou posez-moi une question.", - )); - ctx.addFeed(feedEvent("Agent pret", "En attente — Q&A, upskilling, sourcing additionnel", "connect")); - ctx.setPipelineDone(true); - }}); - - return steps; -} - -/* ─── Pipeline context interface ──────────────────────────── */ - -interface PipelineContext { - addFeed: (event: FeedEvent) => void; - updateLastFeed: (status: FeedEvent["status"], detail?: string) => void; - addMessage: (msg: ChatMessage) => void; - setCvCount: (n: number) => void; - fetchAndRevealProfiles: () => void; - sendTopSummary: () => void; - setPipelineDone: (done: boolean) => void; - setSearchQuery: (q: string) => void; -} - -/* ─── Dashboard component ─────────────────────────────────── */ +// ─── Dashboard ──────────────────────────────────────────────── export default function Dashboard() { - const [mode, setMode] = useState<"demo" | "live">("demo"); - const [profiles, setProfiles] = useState([]); - const [visibleProfiles, setVisibleProfiles] = useState([]); - const [scores, setScores] = useState>(new Map()); - const [totalProfiles, setTotalProfiles] = useState(0); - const [cvCount, setCvCount] = useState(0); - const [selectedProfile, setSelectedProfile] = useState(null); - const [feed, setFeed] = useState([]); - const [messages, setMessages] = useState([]); + const [view, setView] = useState("search"); + const [query, setQuery] = useState(""); + const [profiles, setProfiles] = useState([]); + const [agentStatuses, setAgentStatuses] = useState>({ + github: "idle", linkedin: "idle", reddit: "idle", internet: "idle", + }); + const [isStreaming, setIsStreaming] = useState(false); + const [selectedProfile, setSelectedProfile] = useState(null); + const [qaMessages, setQaMessages] = useState([]); const [asking, setAsking] = useState(false); - const [pipelineDone, setPipelineDone] = useState(false); - const [profilesLoading, setProfilesLoading] = useState(false); - const [jobKey, setJobKey] = useState(null); - const [searchQuery, setSearchQuery] = useState(""); - const pipelineStarted = useRef(false); - const searchQueryRef = useRef(""); - const modeRef = useRef(mode); - modeRef.current = mode; - - const switchMode = useCallback((newMode: "demo" | "live") => { - setMode(newMode); - setProfiles([]); - setVisibleProfiles([]); - setScores(new Map()); - setTotalProfiles(0); - setCvCount(0); - setSelectedProfile(null); - setFeed([]); - setMessages([]); - setPipelineDone(false); - setJobKey(null); - pipelineStarted.current = false; - if (newMode === "live") { - addMessage(chatMsg("agent", "Mode live active. En attente des events OpenClaw...")); - addFeed(feedEvent("Mode live", "Dashboard connecte — en attente des events OpenClaw", "connect")); - } - }, []); - const handleReset = useCallback(() => { - setProfiles([]); - setVisibleProfiles([]); - setScores(new Map()); - setTotalProfiles(0); - setCvCount(0); - setSelectedProfile(null); - setPipelineDone(false); - setJobKey(null); - pipelineStarted.current = false; - liveTimeoutsRef.current.forEach(clearTimeout); - liveTimeoutsRef.current = []; - searchQueryRef.current = ""; - setSearchQuery(""); - cursorRef.current = 0; - if (mode === "live") { - setMessages([chatMsg("agent", "Dashboard reset. En attente d'une nouvelle recherche...")]); - setFeed([feedEvent("Reset", "Pret pour une nouvelle recherche", "connect")]); - } else { - setMessages([]); - setFeed([]); - } - }, [mode]); - - /* ─── State updaters ──────────────────────────────────── */ + // ─── Account state ───────────────────────────────────────── + const [sessionId, setSessionId] = useState("ssr"); + const [userProfile, setUserProfile] = useState<{ name: string; company: string; email: string } | null>(null); + const [accountModalOpen, setAccountModalOpen] = useState(false); - const addFeed = useCallback((event: FeedEvent) => { - setFeed((prev) => [...prev, event]); - }, []); + const [feedLogs, setFeedLogs] = useState([]); + const [sourcingError, setSourcingError] = useState(null); - const updateLastFeed = useCallback((status: FeedEvent["status"], detail?: string) => { - setFeed((prev) => { - if (prev.length === 0) return prev; - const updated = [...prev]; - const last = { ...updated[updated.length - 1], status }; - if (detail) last.detail = detail; - updated[updated.length - 1] = last; - return updated; + useEffect(() => { + supabase.auth.getUser().then(async ({ data: { user } }) => { + const id = user?.id ?? getSessionId(); + setSessionId(id); + if (user?.id) { + const { data } = await supabase + .from("user_profiles") + .select("name, company") + .eq("id", user.id) + .single(); + setUserProfile({ + name: data?.name ?? "", + company: data?.company ?? "", + email: user.email ?? "", + }); + } }); }, []); + const [savedProfiles, setSavedProfiles] = useState>(new Set()); + const [shortlistCount, setShortlistCount] = useState(0); + const [outreachCount, setOutreachCount] = useState(0); + const [outreachTarget, setOutreachTarget] = useState(null); + const [pendingQuery, setPendingQuery] = useState(""); - const addMessage = useCallback((msg: ChatMessage) => { - setMessages((prev) => [...prev, msg]); - }, []); - - /* ─── Fetch profiles (with scoring when available) ────── */ + const esRef = useRef(null); + const retriesRef = useRef(0); + const streamTimerRef = useRef | null>(null); + const searchIdRef = useRef(0); - const fetchAndRevealProfiles = useCallback(async () => { - setProfilesLoading(true); - try { - const modeParam = `mode=demo`; - const keywords = extractKeywords(searchQueryRef.current); - const scoreMap = new Map(); - let fetched: HrFlowProfile[] = []; + useEffect(() => { + connectSSE(0); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); - // 1. Find a job matching the query from the board - let matchedJobKey: string | null = null; + useEffect(() => { + if (!sessionId || sessionId === "ssr") return; + fetch(`/api/account/shortlist?session_id=${sessionId}`) + .then((r) => r.json()) + .then((data) => { + const keys = new Set((data.data ?? []).map((e: { profile_key: string }) => e.profile_key)); + setSavedProfiles(keys); + setShortlistCount(keys.size); + }) + .catch(() => {}); + + fetch(`/api/account/outreach?session_id=${sessionId}`) + .then((r) => r.json()) + .then((data) => setOutreachCount((data.data ?? []).length)) + .catch(() => {}); + }, [sessionId]); + + const connectSSE = useCallback((cursor = 0) => { + if (esRef.current) esRef.current.close(); + const thisSearch = searchIdRef.current; + const es = new EventSource(`/api/trigger/stream?cursor=${cursor}`); + esRef.current = es; + + es.onmessage = (e) => { + if (searchIdRef.current !== thisSearch) return; try { - const jobsRes = await fetch(`/api/hrflow/jobs?limit=100`); - const jobsData = await jobsRes.json(); - const jobs: { key: string; name?: string }[] = jobsData?.data?.jobs ?? []; - if (keywords && jobs.length > 0) { - const kwList = keywords.split(","); - // Score each job: count how many keywords appear in the name - let bestScore = 0; - for (const j of jobs) { - const name = (j.name ?? "").toLowerCase(); - const score = kwList.filter((kw: string) => name.includes(kw)).length; - if (score > bestScore) { - bestScore = score; - matchedJobKey = j.key; - } - } - } - // Fallback to first job if no keyword match - if (!matchedJobKey && jobs.length > 0) { - matchedJobKey = jobs[0].key; + const event = JSON.parse(e.data); + if (event.channel === "profile" && event.payload?.profile) { + const p = event.payload.profile as SourcedProfile; + setProfiles((prev) => { + if (prev.some((x) => x.key === p.key)) return prev; + return [...prev, p]; + }); + setView((v) => (v === "loading" ? "results" : v)); } - } catch { - // Job fetch failed - } - - if (matchedJobKey) { - setJobKey(matchedJobKey); - } - - // 2. Score profiles against the matched job (AI scoring = best results) - if (matchedJobKey) { - try { - const scoreRes = await fetch(`/api/hrflow/score?job_key=${matchedJobKey}&limit=20&${modeParam}`); - const scoreData = await scoreRes.json(); - if (scoreData.code === 200 && scoreData.data?.profiles?.length > 0) { - fetched = scoreData.data.profiles; - const predictions: [number, number][] = scoreData.data.predictions ?? []; - fetched.forEach((p, i) => { - const pred = predictions[i]; - if (pred) { - scoreMap.set(p.key, Math.round(pred[1] * 100)); - } - }); + if (event.channel === "feed" && event.payload?.source) { + const source = event.payload.source as AgentSource; + if (event.payload.status) { + const status = event.payload.status as "running" | "done" | "error"; + setAgentStatuses((prev) => ({ ...prev, [source]: status })); } - } catch { - // Scoring failed — fall through to keyword search - } - } - - // 3. Fallback: keyword search if scoring gave no results - if (fetched.length === 0 && keywords) { - const kwList = keywords.split(","); - for (let tryCount = kwList.length; tryCount >= 1 && fetched.length === 0; tryCount--) { - const tryKw = kwList.slice(0, tryCount).join(","); - const res = await fetch(`/api/hrflow/profiles?limit=20&${modeParam}&keywords=${encodeURIComponent(tryKw)}`); - const data = await res.json(); - if (data.code === 200 && data.data?.profiles?.length > 0) { - fetched = data.data.profiles; + if (event.payload.message) { + setFeedLogs((prev) => [...prev, { + id: event.id ?? crypto.randomUUID(), + source, + message: event.payload.message as string, + type: (event.payload.logType ?? "info") as FeedLog["type"], + }]); } } - if (fetched.length > 0) { - fetched.forEach((p, i) => { - scoreMap.set(p.key, Math.max(52, 96 - i * 4 - Math.floor(Math.random() * 5))); - }); + if (event.channel === "chat" && event.payload?.query) { + setPendingQuery(event.payload.query as string); } - } + } catch {} + }; - // Last fallback: plain search - if (fetched.length === 0) { - const res = await fetch(`/api/hrflow/profiles?limit=20&${modeParam}`); - const data = await res.json(); - if (data.code === 200) { - fetched = data.data.profiles; - } + es.onerror = () => { + es.close(); + if (searchIdRef.current !== thisSearch) return; + if (retriesRef.current < 3) { + retriesRef.current++; + setTimeout(() => { + if (searchIdRef.current === thisSearch) connectSSE(cursor); + }, 2000); + } else { + setView("search"); + setIsStreaming(false); } + }; + }, []); - if (fetched.length === 0) return; + useEffect(() => { + return () => { + esRef.current?.close(); + if (streamTimerRef.current) clearTimeout(streamTimerRef.current); + }; + }, []); - setProfiles(fetched); - setScores(scoreMap); - setTotalProfiles(fetched.length); - setVisibleProfiles([]); + const handleSearch = useCallback((q: string, count: number = 10) => { + const thisSearch = ++searchIdRef.current; + retriesRef.current = 0; + if (streamTimerRef.current) clearTimeout(streamTimerRef.current); - // Reveal profiles progressively - const toReveal = fetched.slice(0, 10); - toReveal.forEach((profile, i) => { - setTimeout(() => { - setVisibleProfiles((prev) => { - if (prev.some((p) => p.key === profile.key)) return prev; - return [...prev, profile]; - }); - }, i * 600); - }); - } catch { - addFeed(feedEvent("Erreur", "Impossible de charger les profils", "connect", "error")); - } finally { - setProfilesLoading(false); + setQuery(q); + setProfiles([]); + setFeedLogs([]); + setAgentStatuses({ github: "idle", linkedin: "idle", reddit: "idle", internet: "idle" }); + setView("loading"); + setIsStreaming(true); + + // Save search to Supabase + if (sessionId && sessionId !== "ssr") { + fetch("/api/account/searches", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, query: q }), + }).catch(() => {}); } - }, [addFeed]); - - /* ─── WhatsApp/Telegram summary ───────────────────────── */ - const sendTopSummary = useCallback(() => { - const top = profiles.slice(0, 3); - if (top.length === 0) return; - - const lines = top.map((p, i) => { - const exp = p.experiences?.[0]; - const skills = (p.skills ?? []).slice(0, 4).map((s) => s.name).join(", "); - const score = scores.get(p.key); - const scoreTxt = score != null ? ` (${score}% match)` : ""; - return `${i + 1}. ${p.info.full_name}${scoreTxt}\n ${exp?.title ?? "N/A"} — ${p.info.location?.text ?? "?"}\n ${skills || "N/A"}`; + setSourcingError(null); + connectSSE(0); + setAgentStatuses({ github: "running", linkedin: "running", reddit: "running", internet: "running" }); + fetch("/api/trigger/run", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ query: q }), + }).then(async (res) => { + if (searchIdRef.current !== thisSearch) return; + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setIsStreaming(false); + setView("search"); + setSourcingError( + data.error === "TRIGGER_NOT_CONFIGURED" + ? "Sourcing not configured — set TRIGGER_SECRET_KEY to enable." + : "Failed to start sourcing pipeline.", + ); + } + }).catch(() => { + if (searchIdRef.current !== thisSearch) return; + setIsStreaming(false); + setView("search"); + setSourcingError("Could not reach the sourcing service."); }); - addMessage(chatMsg("agent", - `Voici le top 3 pour "${DEMO_JOB}" :\n\n${lines.join("\n\n")}\n\nCliquez sur un profil au centre pour voir le detail complet.`, - )); - }, [profiles, scores, addMessage]); - - /* ─── Pipeline orchestration ─────────────────────────────── */ - - const liveTimeoutsRef = useRef[]>([]); - - const startPipeline = useCallback((script: typeof PIPELINE_SCRIPT) => { - if (pipelineStarted.current) return; - pipelineStarted.current = true; - - const ctx: PipelineContext = { - addFeed, - updateLastFeed, - addMessage, - setCvCount, - fetchAndRevealProfiles, - sendTopSummary, - setPipelineDone, - setSearchQuery: (q: string) => { searchQueryRef.current = q; setSearchQuery(q); }, - }; - - const timeouts: ReturnType[] = []; - for (const step of script) { - timeouts.push(setTimeout(() => step.run(ctx), step.delay)); - } - liveTimeoutsRef.current = timeouts; - }, [addFeed, updateLastFeed, addMessage, fetchAndRevealProfiles, sendTopSummary]); - - // Demo mode: auto-start - useEffect(() => { - if (modeRef.current !== "demo") return; - startPipeline(PIPELINE_SCRIPT); - return () => liveTimeoutsRef.current.forEach(clearTimeout); - }, [startPipeline]); - - /* ─── OpenClaw event polling ──────────────────────────── */ - - const cursorRef = useRef(0); - - useEffect(() => { - const poll = async () => { - try { - const res = await fetch(`/api/openclaw/events?cursor=${cursorRef.current}`); - const data = await res.json(); - cursorRef.current = data.cursor; - - for (const evt of data.events) { - if (evt.channel === "chat" && evt.payload.text) { - const isUser = evt.payload.type === "user"; - addMessage(chatMsg(isUser ? "user" : "agent", evt.payload.text)); - // In live mode, first user message triggers the pipeline script - if (isUser && modeRef.current === "live" && !pipelineStarted.current) { - searchQueryRef.current = evt.payload.text; - setSearchQuery(evt.payload.text); - startPipeline(buildLivePipeline(evt.payload.text)); - } - } else if (evt.channel === "feed" && evt.payload.action) { - const status = evt.payload.status ?? "done"; - if (status === "done") { - // Try to update the last running event with same action, otherwise add new - setFeed((prev) => { - const idx = prev.findLastIndex((e) => e.action === evt.payload.action && e.status === "running"); - if (idx >= 0) { - const updated = [...prev]; - updated[idx] = { ...updated[idx], status: "done", detail: evt.payload.detail ?? updated[idx].detail }; - return updated; - } - return [...prev, feedEvent(evt.payload.action!, evt.payload.detail ?? "", evt.payload.feedType as FeedEvent["type"] ?? "connect", "done")]; - }); - } else { - addFeed(feedEvent( - evt.payload.action, - evt.payload.detail ?? "", - evt.payload.feedType as FeedEvent["type"] ?? "connect", - status as FeedEvent["status"], - )); - } - } else if (evt.channel === "action" && evt.payload.command === "fetch_profiles") { - fetchAndRevealProfiles(); - } else if (evt.channel === "action" && evt.payload.command === "send_summary") { - sendTopSummary(); - } else if (evt.channel === "action" && evt.payload.command === "set_cv_count") { - setCvCount(Number(evt.payload.text) || 0); - } else if (evt.channel === "action" && evt.payload.command === "pipeline_done") { - setPipelineDone(true); - } - } - } catch { /* polling failure — retry next tick */ } - }; + streamTimerRef.current = setTimeout(() => setIsStreaming(false), 30_000); + }, [sessionId]); - const interval = setInterval(poll, 2000); - return () => clearInterval(interval); - }, [addMessage, addFeed, fetchAndRevealProfiles, sendTopSummary, startPipeline]); + const handleSelectProfile = useCallback((profile: SourcedProfile) => { + setSelectedProfile(profile); + setQaMessages([]); + setView("profile"); - /* ─── Q&A on selected profile ─────────────────────────── */ + setAsking(true); + const question = "Donne-moi une synthèse de ce profil en 3 points clés pour un recruteur."; + const fetchPromise = profile.hrflow_key + ? fetch(`/api/hrflow/ask?profile_key=${profile.hrflow_key}&question=${encodeURIComponent(question)}`).then((r) => r.json()) + : fetch("/api/demo/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile, question }), + }).then((r) => r.json()); + + fetchPromise + .then((data) => { + const answer = data?.data?.response ?? profile.summary; + setQaMessages([chatMsg("agent", answer)]); + }) + .catch(() => { + setQaMessages([chatMsg("agent", profile.summary)]); + }) + .finally(() => setAsking(false)); + }, []); - const handleAskQuestion = useCallback(async (question: string) => { + const handleAsk = useCallback(async (question: string) => { if (!selectedProfile || asking) return; - - const profileName = selectedProfile.info.full_name; - const profileKey = selectedProfile.key; - - addMessage(chatMsg("user", question)); - addFeed(feedEvent("Q&A profil", `GET /profile/asking — "${question.slice(0, 50)}..."`, "analyze", "running")); - + setQaMessages((prev) => [...prev, chatMsg("user", question)]); setAsking(true); try { - const params = new URLSearchParams({ profile_key: profileKey, question, mode: "demo" }); - const res = await fetch(`/api/hrflow/ask?${params}`); - const data = await res.json(); - - const answer = data.code === 200 - ? `A propos de ${profileName} :\n\n${Array.isArray(data.data) ? data.data.join("\n") : data.data}` - : `Erreur HrFlow: ${data.message}`; - - addMessage(chatMsg("agent", answer)); - updateLastFeed("done", `Reponse recue pour ${profileName}`); + let data; + if (selectedProfile.hrflow_key) { + const res = await fetch(`/api/hrflow/ask?profile_key=${encodeURIComponent(selectedProfile.hrflow_key)}&question=${encodeURIComponent(question)}`); + data = await res.json(); + } else { + const res = await fetch("/api/demo/ask", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: selectedProfile, question }), + }); + data = await res.json(); + } + const answer = data?.data?.response ?? "Je n'ai pas pu obtenir de réponse."; + setQaMessages((prev) => [...prev, chatMsg("agent", answer)]); } catch { - addMessage(chatMsg("agent", "Erreur : impossible de contacter HrFlow. Reessayez.")); - updateLastFeed("error", "Echec de la requete"); + setQaMessages((prev) => [...prev, chatMsg("agent", "Erreur lors de la question. Veuillez réessayer.")]); } finally { setAsking(false); } - }, [selectedProfile, asking, addFeed, updateLastFeed, addMessage]); + }, [selectedProfile, asking]); - /* ─── Profile selection ───────────────────────────────── */ + const handleBack = useCallback(() => { + setView(profiles.length > 0 ? "results" : "search"); + setSelectedProfile(null); + setQaMessages([]); + }, [profiles.length]); - const handleSelectProfile = useCallback((profile: HrFlowProfile) => { - setSelectedProfile(profile); - const score = scores.get(profile.key); - const scoreTxt = score != null ? `\nScore de matching : ${score}%` : ""; - addMessage(chatMsg("agent", - `Profil selectionne : ${profile.info.full_name}\n${profile.experiences?.[0]?.title ?? "Pas de titre"} — ${profile.info.location?.text ?? "?"}${scoreTxt}\n\nPosez une question sur ce candidat.`, - )); - }, [scores, addMessage]); - - const handleAskFromCard = useCallback((profile: HrFlowProfile) => { - setSelectedProfile(profile); - addMessage(chatMsg("agent", `Profil selectionne : ${profile.info.full_name}\nPosez votre question dans le chat.`)); - }, [addMessage]); + const handleNewSearch = useCallback(() => { + esRef.current?.close(); + if (streamTimerRef.current) clearTimeout(streamTimerRef.current); + setView("search"); + setProfiles([]); + setQuery(""); + setIsStreaming(false); + setSelectedProfile(null); + setQaMessages([]); + }, []); + + const handleSave = useCallback((profile: SourcedProfile) => { + const isCurrentlySaved = savedProfiles.has(profile.key); + setSavedProfiles((prev) => { + const next = new Set(prev); + if (isCurrentlySaved) { + next.delete(profile.key); + setShortlistCount((c) => Math.max(0, c - 1)); + } else { + next.add(profile.key); + setShortlistCount((c) => c + 1); + } + return next; + }); + if (isCurrentlySaved) { + fetch("/api/account/shortlist", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, profile_key: profile.key }), + }).catch(() => {}); + } else { + fetch("/api/account/shortlist", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, profile_key: profile.key, profile_data: profile }), + }).catch(() => {}); + } + }, [savedProfiles, sessionId]); - /* ─── Render ──────────────────────────────────────────── */ + const handleContact = useCallback((profile: SourcedProfile) => { + setOutreachTarget(profile); + }, []); + + const handleOutreachClose = useCallback(() => { + setOutreachTarget(null); + fetch(`/api/account/outreach?session_id=${sessionId}`) + .then((r) => r.json()) + .then((data) => setOutreachCount((data.data ?? []).length)) + .catch(() => {}); + }, [sessionId]); + + const handleNavigate = useCallback((section: NavSection) => { + if (section === "search") { + // If we have results, go back to results; otherwise search + if (profiles.length > 0 && view !== "profile") { + setView("results"); + } else if (view === "profile") { + handleBack(); + } else { + setView("search"); + } + } else { + setView(section); + } + }, [profiles.length, view, handleBack]); + + // ─── Render ──────────────────────────────────────────────── return ( -
- - -
- {/* LEFT — Chat Telegram / OpenClaw */} -
- + {/* Sidebar */} + setAccountModalOpen(true)} + /> + + {/* Main content */} +
+ {view === "search" && ( + <> + {sourcingError && ( +
+ {sourcingError} + +
+ )} + + + )} + + {view === "loading" && ( + + )} + + {view === "results" && ( + + )} + + {view === "profile" && selectedProfile && ( + -
- - {/* CENTER — Candidates (detailed) */} -
- + )} + + {view === "outreach" && ( + + )} + + {view === "history" && ( + { handleSearch(q); }} /> -
+ )} + + {view === "analyse" && ( + + )} - {/* RIGHT — Agent Pipeline Feed */} -
- -
-
+ {view === "pipeline" && ( + + )} + + {view === "team" && ( + + )} + + + {/* Outreach modal */} + {outreachTarget && ( + + )} + + {/* Account modal */} + {accountModalOpen && ( + setAccountModalOpen(false)} + onProfileUpdated={(updated) => setUserProfile(updated)} + /> + )}
); } diff --git a/claw4hr-passive-talent-intelligence/app/components/Header.tsx b/claw4hr-passive-talent-intelligence/app/components/Header.tsx new file mode 100644 index 0000000..05005fb --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/Header.tsx @@ -0,0 +1,89 @@ +"use client"; + +import { useState } from "react"; +import type { SourcedProfile } from "@/app/lib/types"; +import AccountDropdown from "./AccountDropdown"; + +function ClawIcon({ size = 20 }: { size?: number }) { + return ( + + + + + + + ); +} + +interface HeaderProps { + sessionId: string; + shortlistKeys: Set; + onRelaunchSearch: (query: string) => void; + onOpenProfile: (profile: SourcedProfile) => void; + shortlistCount: number; + outreachCount: number; +} + +export default function Header({ + sessionId, + shortlistKeys, + onRelaunchSearch, + onOpenProfile, + shortlistCount, + outreachCount, +}: HeaderProps) { + const [open, setOpen] = useState(false); + const badgeCount = shortlistCount + outreachCount; + + return ( +
+
+ + + Claw4HR + +
+ +
+ + + {open && ( + setOpen(false)} + /> + )} +
+
+ ); +} diff --git a/claw4hr-passive-talent-intelligence/app/components/HistoryView.tsx b/claw4hr-passive-talent-intelligence/app/components/HistoryView.tsx new file mode 100644 index 0000000..7ab4196 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/HistoryView.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { SavedSearch, SourcedProfile, SourceType } from "@/app/lib/types"; + +interface HistoryViewProps { + sessionId: string; + onRelaunch: (query: string) => void; +} + +const SOURCE_COLORS: Record = { + github: "#1a1a2e", + linkedin: "#0077b5", + reddit: "#ff4500", + internet: "#6b7280", + indeed: "#2164f3", + hellowork: "#e05c2a", +}; + +const SOURCE_LABELS: Record = { + github: "GitHub", + linkedin: "LinkedIn", + reddit: "Reddit", + internet: "Web", + indeed: "Indeed", + hellowork: "HelloWork", +}; + +function Avatar({ name, color }: { name: string; color: string }) { + const initials = name.split(" ").map((w) => w[0]).join("").slice(0, 2).toUpperCase(); + return ( +
+ {initials} +
+ ); +} + +function ScoreBadge({ score }: { score: number }) { + const color = score >= 85 ? "#10b981" : score >= 75 ? "#f59e0b" : "#6b7280"; + return ( + + {score}% + + ); +} + +function ProfileMiniCard({ profile }: { profile: SourcedProfile }) { + const primarySource = profile.sources[0]; + const sourceColor = primarySource ? SOURCE_COLORS[primarySource.type] : "#6b7280"; + const sourceLabel = primarySource ? SOURCE_LABELS[primarySource.type] : ""; + + return ( +
+ +
+
+

{profile.name}

+ +
+

{profile.title}

+
+ + {sourceLabel} + + {profile.location} +
+
+
+ ); +} + +export default function HistoryView({ sessionId, onRelaunch }: HistoryViewProps) { + const [searches, setSearches] = useState([]); + const [loading, setLoading] = useState(true); + const [expanded, setExpanded] = useState(null); + + useEffect(() => { + if (!sessionId || sessionId === "ssr") return; + fetch(`/api/account/searches?session_id=${sessionId}`) + .then((r) => r.json()) + .then((data) => setSearches(data.data ?? [])) + .catch(() => {}) + .finally(() => setLoading(false)); + }, [sessionId]); + + function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("fr-FR", { day: "numeric", month: "short", hour: "2-digit", minute: "2-digit" }); + } + + function handleDelete(id: string) { + setSearches((prev) => prev.filter((s) => s.id !== id)); + if (expanded === id) setExpanded(null); + fetch("/api/account/searches", { + method: "DELETE", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ session_id: sessionId, id }), + }).catch(() => {}); + } + + function toggleExpand(id: string) { + setExpanded((prev) => (prev === id ? null : id)); + } + + return ( +
+ {/* Header */} +
+
+ + + +

Historique

+
+

+ {loading ? "Chargement…" : `${searches.length} recherche${searches.length !== 1 ? "s" : ""} effectuée${searches.length !== 1 ? "s" : ""}`} +

+
+ + {loading ? ( +
+
+ {[0, 1, 2].map((i) => ( +
+ ))} +
+
+ ) : searches.length === 0 ? ( +
+
+ + + +
+
+

Aucune recherche

+

Votre historique de recherches apparaîtra ici.

+
+
+ ) : ( +
+ {searches.map((search, i) => { + const hasResults = search.results && search.results.length > 0; + const isExpanded = expanded === search.id; + + return ( +
+ {/* Row */} +
+
hasResults && toggleExpand(search.id)} + > +
+ + + +
+
+

{search.query}

+
+ {search.created_at && ( + + {formatDate(search.created_at)} + + )} + {search.profile_count > 0 && ( + + {search.profile_count} profils + + )} +
+
+
+ +
+ {hasResults && ( + + )} + + +
+
+ + {/* Expanded profiles */} + {isExpanded && hasResults && ( +
+

+ Profils trouvés — triés par score HrFlow +

+
+ {search.results!.map((profile) => ( + + ))} +
+
+ )} +
+ ); + })} +
+ )} +
+ ); +} diff --git a/claw4hr-passive-talent-intelligence/app/components/HrFlowSWOT.tsx b/claw4hr-passive-talent-intelligence/app/components/HrFlowSWOT.tsx new file mode 100644 index 0000000..cae9ea4 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/HrFlowSWOT.tsx @@ -0,0 +1,143 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { SourcedProfile } from "@/app/lib/types"; + +interface SWOTData { + strengths: string[]; + improvements: string[]; +} + +interface HrFlowSWOTProps { + profile: SourcedProfile; + jobKey?: string; +} + +export default function HrFlowSWOT({ profile, jobKey }: HrFlowSWOTProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(false); + + useEffect(() => { + const controller = new AbortController(); + + setLoading(true); + setError(false); + setData(null); + + const defaultJobKey = process.env.NEXT_PUBLIC_HRFLOW_DEFAULT_JOB_KEY ?? ""; + const effectiveJobKey = jobKey ?? defaultJobKey; + + const fetchSwot = profile.hrflow_key && effectiveJobKey + ? fetch(`/api/hrflow/upskill?profile_key=${encodeURIComponent(profile.hrflow_key)}&job_key=${encodeURIComponent(effectiveJobKey)}`, { signal: controller.signal }) + .then((r) => r.json()) + .then((res) => { + const raw = res?.data ?? {}; + const strengths: string[] = extractBullets(raw, "positive") ?? []; + const improvements: string[] = extractBullets(raw, "negative") ?? []; + return { strengths, improvements }; + }) + : fetch("/api/demo/swot", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile }), + signal: controller.signal, + }) + .then((r) => r.json()) + .then((res) => res?.data ?? { strengths: [], improvements: [] }); + + fetchSwot + .then((swot) => setData(swot)) + .catch(() => { + if (controller.signal.aborted) return; + setError(true); + }) + .finally(() => setLoading(false)); + + return () => controller.abort(); + }, [profile.key, profile.hrflow_key, jobKey]); + + if (loading) { + return ( +
+
+
+ Analyse HrFlow en cours… +
+ {[1, 2, 3].map((i) => ( +
+ ))} +
+ ); + } + + if (error || !data || (data.strengths.length === 0 && data.improvements.length === 0)) { + return ( +
+

Analyse HrFlow non disponible pour ce profil

+
+ ); + } + + return ( +
+ {/* Powered by HrFlow badge */} +
+

+ Analyse HrFlow +

+ + Powered by HrFlow + +
+ + {/* Strengths */} + {data.strengths.length > 0 && ( +
+ {data.strengths.map((s, i) => ( +
+ +

{s}

+
+ ))} +
+ )} + + {/* Improvements */} + {data.improvements.length > 0 && ( +
+ {data.improvements.map((s, i) => ( +
+ +

{s}

+
+ ))} +
+ )} +
+ ); +} + +function extractBullets(raw: Record, polarity: "positive" | "negative"): string[] | null { + if (polarity === "positive" && Array.isArray(raw.strengths)) return raw.strengths as string[]; + if (polarity === "negative" && Array.isArray(raw.improvements)) return raw.improvements as string[]; + + if (Array.isArray(raw.predictions)) { + const predictions = raw.predictions as Array<{ name?: string; value?: number; type?: string }>; + return predictions + .filter((p) => polarity === "positive" ? (p.value ?? 0) > 0 : (p.value ?? 0) < 0) + .slice(0, 3) + .map((p) => p.name ?? "") + .filter(Boolean); + } + + if (typeof raw.explanation === "string") { + const lines = raw.explanation.split("\n").filter((l) => l.trim().length > 0); + return polarity === "positive" ? lines.slice(0, 3) : lines.slice(3, 5); + } + + return null; +} diff --git a/claw4hr-passive-talent-intelligence/app/components/LoadingView.tsx b/claw4hr-passive-talent-intelligence/app/components/LoadingView.tsx new file mode 100644 index 0000000..56db88a --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/LoadingView.tsx @@ -0,0 +1,532 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import { PixelSprite, SOURCE_CONFIG, type AgentSource, type AgentState } from "./PixelAgent"; +import type { FeedLog } from "@/app/lib/types"; + +interface LoadingViewProps { + query: string; + profileCount: number; + agentStatuses: Record; + feedLogs: FeedLog[]; +} + +const LOG_TYPE_COLORS: Record = { + info: "#6b7280", + found: "#059669", + score: "#4f46e5", + done: "#9ca3af", +}; + +const BADGE_COLORS: Record = { + github: { bg: "#1f2937", text: "#e5e7eb", label: "GitHub" }, + linkedin: { bg: "#0077b5", text: "#ffffff", label: "LinkedIn" }, + reddit: { bg: "#ff4500", text: "#ffffff", label: "Reddit" }, + internet: { bg: "#4f46e5", text: "#ffffff", label: "Web" }, +}; + +const AGENT_NAMES: Record = { + github: "Agent GitHub", + linkedin: "Agent LinkedIn", + reddit: "Agent Reddit", + internet: "Agent Web", +}; + +const AGENT_SUBTITLES: Record = { + github: "Open source · Repos · Commits", + linkedin: "Profils · Expériences · Skills", + reddit: "Posts · Karma · Contributeurs", + internet: "Blogs · Portfolios · Confs", +}; + +const AGENT_SCAN_TOTALS: Record = { + github: 2847, + linkedin: 1240, + reddit: 847, + internet: 3200, +}; + +function AgentCard({ + source, + state, + foundCount, +}: { + source: AgentSource; + state: AgentState; + foundCount: number; +}) { + const config = SOURCE_CONFIG[source]; + const [frame, setFrame] = useState(0); + const [scanCount, setScanCount] = useState(0); + + useEffect(() => { + if (state !== "running") return; + const t = setInterval(() => setFrame((f) => (f + 1) % 3), 150); + return () => clearInterval(t); + }, [state]); + + useEffect(() => { + if (state === "idle") return; + const total = AGENT_SCAN_TOTALS[source]; + const steps = 60; + const increment = total / steps; + let current = 0; + const t = setInterval(() => { + current = Math.min(current + increment, total); + setScanCount(Math.floor(current)); + if (current >= total) clearInterval(t); + }, 4000 / steps); + return () => clearInterval(t); + }, [state, source]); + + const isRunning = state === "running"; + const isDone = state === "done"; + const isIdle = state === "idle"; + + return ( +
+ {/* Top accent bar */} +
+ + {/* Scan shimmer when running */} + {isRunning && ( +
+
+
+ )} + + {/* Sprite */} +
+ +
+ + {/* Name */} +
+ {AGENT_NAMES[source]} +
+ + {/* Subtitle */} +
+ {AGENT_SUBTITLES[source]} +
+ + {/* Divider */} +
+ + {/* Stats */} +
+ + {isIdle ? "—" : `${scanCount.toLocaleString("fr-FR")}`} + + {isDone ? ( + + ✓ Terminé + + ) : isRunning ? ( + + + LIVE + + ) : null} +
+ + {foundCount > 0 && ( +
+ {foundCount} trouvé{foundCount > 1 ? "s" : ""} +
+ )} +
+ ); +} + +export default function LoadingView({ + query, + profileCount, + agentStatuses, + feedLogs, +}: LoadingViewProps) { + const logsEndRef = useRef(null); + const [elapsed, setElapsed] = useState(0); + const [dots, setDots] = useState("."); + + useEffect(() => { + const t = setInterval(() => setElapsed((s) => s + 1), 1000); + return () => clearInterval(t); + }, []); + + useEffect(() => { + const t = setInterval(() => setDots((d) => (d.length >= 3 ? "." : d + ".")), 500); + return () => clearInterval(t); + }, []); + + useEffect(() => { + logsEndRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [feedLogs]); + + const agents: { source: AgentSource; state: AgentState }[] = ( + ["linkedin", "github", "reddit", "internet"] as AgentSource[] + ).map((source) => ({ source, state: agentStatuses[source] ?? "idle" })); + + const doneCount = agents.filter((a) => a.state === "done").length; + const runningCount = agents.filter((a) => a.state === "running").length; + const progress = (doneCount / agents.length) * 100; + + const foundPerSource: Record = { linkedin: 0, github: 0, reddit: 0, internet: 0 }; + feedLogs.forEach((log) => { if (log.type === "found") foundPerSource[log.source]++; }); + + const elapsedStr = `${String(Math.floor(elapsed / 60)).padStart(2, "0")}:${String(elapsed % 60).padStart(2, "0")}`; + + return ( +
+ {/* Header */} +
+
+
+ {runningCount > 0 ? `${runningCount} agent${runningCount > 1 ? "s" : ""} actif${runningCount > 1 ? "s" : ""}` : "Analyse en cours"} + {elapsedStr} +
+

+ Nos agents partent chasser pour vous{dots} +

+
+ + + + {query} +
+
+ + {/* Agent cards */} +
+ {agents.map((a) => ( + + ))} +
+ + {/* Progress bar + profile counter */} +
+
+
+
+ {/* Progress ring */} +
+ + + + + + {Math.round(progress)}% + +
+ + {doneCount}/{agents.length} agents terminés + +
+ + {profileCount > 0 && ( +
+ + + {profileCount} profil{profileCount > 1 ? "s" : ""} trouvé{profileCount > 1 ? "s" : ""} + +
+ )} +
+ + {/* Progress bar */} +
+
+
+
+
+ + {/* Terminal */} + {feedLogs.length > 0 && ( +
+ {/* Terminal header */} +
+
+
+
+
+
+ + claw4hr — agent feed + +
+ + LIVE +
+
+ + {/* Log lines */} +
+ {feedLogs.slice(-20).map((log, i) => { + const src = BADGE_COLORS[log.source]; + const textColor = LOG_TYPE_COLORS[log.type]; + const totalShown = Math.min(feedLogs.length, 20); + const opacity = Math.min(1, (i + 2) / Math.min(totalShown, 5) + 0.3); + return ( +
+ + {new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })} + + + {src.label} + + + {log.type === "found" && } + {log.type === "score" && } + {log.message} + +
+ ); + })} +
+ + {new Date().toLocaleTimeString("fr-FR", { hour: "2-digit", minute: "2-digit", second: "2-digit" })} + + +
+
+
+
+ )} +
+ ); +} diff --git a/claw4hr-passive-talent-intelligence/app/components/OutreachModal.tsx b/claw4hr-passive-talent-intelligence/app/components/OutreachModal.tsx new file mode 100644 index 0000000..5e4dfd3 --- /dev/null +++ b/claw4hr-passive-talent-intelligence/app/components/OutreachModal.tsx @@ -0,0 +1,134 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; +import type { SourcedProfile } from "@/app/lib/types"; + +interface OutreachModalProps { + profile: SourcedProfile; + sessionId: string; + onClose: () => void; +} + +export default function OutreachModal({ profile, sessionId, onClose }: OutreachModalProps) { + const [message, setMessage] = useState(""); + const [streaming, setStreaming] = useState(true); + const [copied, setCopied] = useState(false); + + useEffect(() => { + async function generate() { + try { + const res = await fetch("/api/outreach/generate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile, session_id: sessionId }), + }); + + if (!res.ok || !res.body) { + setMessage("Erreur lors de la génération. Veuillez réessayer."); + setStreaming(false); + return; + } + + const reader = res.body.getReader(); + const decoder = new TextDecoder(); + let accumulated = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + const chunk = decoder.decode(value, { stream: true }); + accumulated += chunk; + setMessage(accumulated); + } + } catch { + setMessage("Erreur de connexion. Veuillez réessayer."); + } finally { + setStreaming(false); + } + } + generate(); + }, [profile, sessionId]); + + function handleCopy() { + navigator.clipboard.writeText(message); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
+
+
+

+ ✉ Message d'approche +

+

+ Pour {profile.name} · {profile.title} +

+
+ +
+ +
+