Saandraahh commited on
Commit
35709fb
·
1 Parent(s): 4e3e1fc

Added AI score and Insights

Browse files
backend/api.py CHANGED
@@ -12,6 +12,8 @@ from fastapi.middleware.cors import CORSMiddleware
12
  from supabase_ingest import process_resume
13
  from src.extraction.job_extractor import process_single_job
14
  from src.services.ats_service import analyze_ats_compatibility
 
 
15
 
16
 
17
  app = FastAPI()
@@ -56,8 +58,6 @@ async def process_resume_endpoint(request: ResumeRequest):
56
  # WEBHOOK ENDPOINT (Called by Supabase)
57
  # ---------------------------------------------------------------------
58
 
59
- from typing import Dict, Any, Optional
60
-
61
  class StorageEventRequest(BaseModel):
62
  type: str
63
  table: str
@@ -153,5 +153,92 @@ async def analyze_ats_endpoint(
153
  print(f"❌ ATS Analysis failed: {e}")
154
  raise HTTPException(status_code=500, detail=str(e))
155
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
 
157
  # Run with: uvicorn api:app --reload
 
12
  from supabase_ingest import process_resume
13
  from src.extraction.job_extractor import process_single_job
14
  from src.services.ats_service import analyze_ats_compatibility
15
+ from src.services.analysis import identify_missing_skills, generate_ai_analysis
16
+ from typing import Dict, Any, Optional, List
17
 
18
 
19
  app = FastAPI()
 
58
  # WEBHOOK ENDPOINT (Called by Supabase)
59
  # ---------------------------------------------------------------------
60
 
 
 
61
  class StorageEventRequest(BaseModel):
62
  type: str
63
  table: str
 
153
  print(f"❌ ATS Analysis failed: {e}")
154
  raise HTTPException(status_code=500, detail=str(e))
155
 
156
+ # ---------------------------------------------------------------------
157
+ # CANDIDATE ANALYSIS ENDPOINT
158
+ # ---------------------------------------------------------------------
159
+
160
+ class AnalysisRequest(BaseModel):
161
+ candidate_id: str
162
+ job_id: Optional[str] = None
163
+
164
+ @app.post("/analyze-candidate")
165
+ async def analyze_candidate_endpoint(request: AnalysisRequest):
166
+ """
167
+ Detailed candidate analysis: Summary + Missing Skills.
168
+ """
169
+ print(f"🔬 Analyzing candidate {request.candidate_id} for job {request.job_id}")
170
+
171
+ try:
172
+ # 1. Fetch Candidate Data
173
+ prof_resp = client.table("profiles").select("*").eq("id", request.candidate_id).execute()
174
+ if not prof_resp.data:
175
+ raise HTTPException(status_code=404, detail="Candidate not found")
176
+
177
+ profile = prof_resp.data[0]
178
+
179
+ # 2. Fetch Job Data
180
+ job_description = ""
181
+ job_skills = []
182
+
183
+ if request.job_id:
184
+ job_resp = client.table("jobs").select("*").eq("id", request.job_id).execute()
185
+ if job_resp.data:
186
+ job = job_resp.data[0]
187
+ job_description = job.get("description") or ""
188
+ # Assume job.skills is a list or comma-separated string
189
+ raw_job_skills = job.get("skills") if job.get("skills") else job.get("technical_skills")
190
+ if isinstance(raw_job_skills, str):
191
+ job_skills = [s.strip() for s in raw_job_skills.split(",") if s.strip()]
192
+ elif isinstance(raw_job_skills, list):
193
+ job_skills = raw_job_skills
194
+ else:
195
+ job_skills = []
196
+
197
+ # 3. Prepare Profile Skills
198
+ profile_skills = []
199
+ raw_skills = profile.get("skills") or []
200
+ if isinstance(raw_skills, str):
201
+ profile_skills = [s.strip() for s in raw_skills.split(",") if s.strip()]
202
+ else:
203
+ profile_skills = raw_skills
204
+
205
+ raw_tech_skills = profile.get("technical_skills") or []
206
+ if isinstance(raw_tech_skills, str):
207
+ profile_skills.extend([s.strip() for s in raw_tech_skills.split(",") if s.strip()])
208
+ else:
209
+ profile_skills.extend(raw_tech_skills)
210
+
211
+ # 4. Generate AI Insights (Summary, Strengths, Weaknesses)
212
+ profile_summary = f"""
213
+ Role: {profile.get('role')}
214
+ Headline: {profile.get('headline')}
215
+ Summary: {profile.get('summary')}
216
+ Experience: {profile.get('experience_years')} years
217
+ Work Experience: {profile.get('work_experience')}
218
+ Education: {profile.get('education')}
219
+ Skills: {", ".join(profile_skills)}
220
+ """
221
+
222
+ ai_insights = generate_ai_analysis(profile_summary, job_description)
223
+
224
+ # 5. Identify Missing Skills (Semantic)
225
+ missing = []
226
+ if job_skills:
227
+ missing = identify_missing_skills(job_skills, profile_skills)
228
+
229
+ return {
230
+ "status": "success",
231
+ "data": {
232
+ "summary": ai_insights.get("summary"),
233
+ "strengths": ai_insights.get("strengths"),
234
+ "weaknesses": ai_insights.get("weaknesses"),
235
+ "score": ai_insights.get("score") or 0,
236
+ "missing_skills": missing
237
+ }
238
+ }
239
+
240
+ except Exception as e:
241
+ print(f"❌ Analysis failed: {e}")
242
+ raise HTTPException(status_code=500, detail=str(e))
243
 
244
  # Run with: uvicorn api:app --reload
backend/get_test_ids.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from supabase import create_client
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ url = os.environ.get("SUPABASE_URL")
8
+ key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
9
+ supabase = create_client(url, key)
10
+
11
+ try:
12
+ # Get an application with user and job relations
13
+ res = supabase.table("applications").select("user_id, job_id").limit(1).execute()
14
+ if res.data:
15
+ app = res.data[0]
16
+ # In this project, user_id might be the profile id
17
+ print(f"Candidate ID (profile): {app['user_id']}")
18
+ print(f"Job ID: {app['job_id']}")
19
+ else:
20
+ print("No applications found.")
21
+ except Exception as e:
22
+ print(f"Error: {e}")
backend/get_valid_ids.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from supabase import create_client
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ url = os.environ.get("SUPABASE_URL")
8
+ key = os.environ.get("SUPABASE_SERVICE_ROLE_KEY")
9
+ supabase = create_client(url, key)
10
+
11
+ try:
12
+ # Get one profile
13
+ p_res = supabase.table("profiles").select("id").limit(1).execute()
14
+ # Get one job
15
+ j_res = supabase.table("jobs").select("id").limit(1).execute()
16
+
17
+ if p_res.data and j_res.data:
18
+ print(f"CANDIDATE_ID={p_res.data[0]['id']}")
19
+ print(f"JOB_ID={j_res.data[0]['id']}")
20
+ except Exception as e:
21
+ print(f"Error: {e}")
backend/list_models.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from google import genai
3
+ from dotenv import load_dotenv
4
+
5
+ load_dotenv()
6
+
7
+ client = genai.Client(api_key=os.getenv("GEMINI_API_KEY"))
8
+
9
+ try:
10
+ print("Listing available models:")
11
+ for m in client.models.list():
12
+ print(f"Model ID: {m.name}, Display Name: {m.display_name}")
13
+ except Exception as e:
14
+ print(f"Error listing models: {e}")
backend/src/services/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ # Init file for services package
backend/src/services/analysis.py ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import os
3
+ import json
4
+ import numpy as np
5
+ from typing import List, Dict, Any
6
+ from google import genai
7
+ import google.genai.types as types
8
+ from src.embeddings.local_embedder import generate_embedding, generate_list_embedding, get_model
9
+
10
+ # Initialize Gemini Client
11
+ client = genai.Client(api_key="AIzaSyB2Dw-nep3SwQav5S_1qJ2FoVc4I83a2yk")
12
+
13
+ def cosine_similarity(v1, v2):
14
+ v1 = np.array(v1)
15
+ v2 = np.array(v2)
16
+ return np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2))
17
+
18
+ def identify_missing_skills(job_skills: List[str], profile_skills: List[str], threshold: float = 0.7) -> List[str]:
19
+ """
20
+ Identifies skills required by the job but missing (semantically) in the profile.
21
+ """
22
+ if not job_skills:
23
+ return []
24
+ if not profile_skills:
25
+ return job_skills
26
+
27
+ # Generate embeddings for profile skills
28
+ model = get_model()
29
+ profile_embeddings = model.encode(profile_skills, normalize_embeddings=True)
30
+ job_embeddings = model.encode(job_skills, normalize_embeddings=True)
31
+
32
+ missing_skills = []
33
+
34
+ for i, job_skill in enumerate(job_skills):
35
+ job_vec = job_embeddings[i]
36
+ # Find max similarity with any profile skill
37
+ similarities = [np.dot(job_vec, prof_vec) for prof_vec in profile_embeddings]
38
+ max_sim = max(similarities) if similarities else 0
39
+
40
+ if max_sim < threshold:
41
+ missing_skills.append(job_skill)
42
+
43
+ return missing_skills
44
+
45
+ def generate_ai_analysis(profile_text: str, job_description: str) -> Dict[str, Any]:
46
+ """
47
+ Uses Gemini to generate a professional summary and evaluation.
48
+ """
49
+ system_prompt = """
50
+ You are an expert HR Analyst. Analyze the provided candidate resume text against the job description.
51
+
52
+ Return a JSON object with:
53
+ - "summary": A 2-3 sentence professional summary of why the candidate is or isn't a good fit.
54
+ - "strengths": A list of top 3 core strengths matching the job.
55
+ - "weaknesses": A list of top 2-3 areas for improvement or missing qualifications.
56
+ - "score": An overall suitability score from 0 to 100 based on their experience and skills relative to the job requirements.
57
+
58
+ Be objective, professional, and concise.
59
+ """
60
+
61
+ user_content = f"JOB DESCRIPTION:\n{job_description}\n\nCANDIDATE RESUME:\n{profile_text}"
62
+
63
+ try:
64
+ response = client.models.generate_content(
65
+ model="gemini-2.5-flash-lite", # Updated to confirmed model name
66
+ contents=system_prompt + "\n\n" + user_content,
67
+ config=types.GenerateContentConfig(
68
+ temperature=0.2,
69
+ response_mime_type="application/json"
70
+ )
71
+ )
72
+ return json.loads(response.text)
73
+ except Exception as e:
74
+ print(f"❌ AI Analysis failed: {e}")
75
+ return {
76
+ "summary": "Analysis currently unavailable.",
77
+ "strengths": [],
78
+ "weaknesses": [],
79
+ "score": 0
80
+ }
backend/test_analysis.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import requests
2
+ import json
3
+
4
+ url = "http://localhost:8000/analyze-candidate"
5
+ payload = {
6
+ "candidate_id": "92f155f8-8422-4819-ade1-697624640103",
7
+ "job_id": "76259020-f560-4927-ba21-4ea671e62c16"
8
+ }
9
+ headers = {"Content-Type": "application/json"}
10
+
11
+ try:
12
+ response = requests.post(url, data=json.dumps(payload), headers=headers)
13
+ print(f"Status: {response.status_code}")
14
+ print(json.dumps(response.json(), indent=2))
15
+ except Exception as e:
16
+ print(f"Error: {e}")
package-lock.json CHANGED
@@ -10,6 +10,7 @@
10
  "dependencies": {
11
  "@supabase/supabase-js": "^2.53.0",
12
  "date-fns": "^4.1.0",
 
13
  "framer-motion": "^12.23.11",
14
  "lucide-react": "^0.562.0",
15
  "react": "^19.1.0",
@@ -324,25 +325,6 @@
324
  "node": ">=6.9.0"
325
  }
326
  },
327
- "node_modules/@emotion/is-prop-valid": {
328
- "version": "0.8.8",
329
- "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-0.8.8.tgz",
330
- "integrity": "sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==",
331
- "license": "MIT",
332
- "optional": true,
333
- "peer": true,
334
- "dependencies": {
335
- "@emotion/memoize": "0.7.4"
336
- }
337
- },
338
- "node_modules/@emotion/memoize": {
339
- "version": "0.7.4",
340
- "resolved": "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.7.4.tgz",
341
- "integrity": "sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==",
342
- "license": "MIT",
343
- "optional": true,
344
- "peer": true
345
- },
346
  "node_modules/@esbuild/aix-ppc64": {
347
  "version": "0.27.2",
348
  "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
@@ -1612,7 +1594,7 @@
1612
  "version": "19.2.7",
1613
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
1614
  "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
1615
- "devOptional": true,
1616
  "license": "MIT",
1617
  "dependencies": {
1618
  "csstype": "^3.2.2"
@@ -1888,7 +1870,7 @@
1888
  "version": "3.2.3",
1889
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1890
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1891
- "devOptional": true,
1892
  "license": "MIT"
1893
  },
1894
  "node_modules/d3-array": {
@@ -2370,6 +2352,12 @@
2370
  "node": ">=16.0.0"
2371
  }
2372
  },
 
 
 
 
 
 
2373
  "node_modules/find-up": {
2374
  "version": "5.0.0",
2375
  "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
@@ -2971,13 +2959,6 @@
2971
  "react": "^19.2.3"
2972
  }
2973
  },
2974
- "node_modules/react-is": {
2975
- "version": "19.2.3",
2976
- "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.3.tgz",
2977
- "integrity": "sha512-qJNJfu81ByyabuG7hPFEbXqNcWSU3+eVus+KJs+0ncpGfMyYdvSmxiJxbWR65lYi1I+/0HBcliO029gc4F+PnA==",
2978
- "license": "MIT",
2979
- "peer": true
2980
- },
2981
  "node_modules/react-redux": {
2982
  "version": "9.2.0",
2983
  "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
 
10
  "dependencies": {
11
  "@supabase/supabase-js": "^2.53.0",
12
  "date-fns": "^4.1.0",
13
+ "file-saver": "^2.0.5",
14
  "framer-motion": "^12.23.11",
15
  "lucide-react": "^0.562.0",
16
  "react": "^19.1.0",
 
325
  "node": ">=6.9.0"
326
  }
327
  },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
328
  "node_modules/@esbuild/aix-ppc64": {
329
  "version": "0.27.2",
330
  "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz",
 
1594
  "version": "19.2.7",
1595
  "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz",
1596
  "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==",
1597
+ "dev": true,
1598
  "license": "MIT",
1599
  "dependencies": {
1600
  "csstype": "^3.2.2"
 
1870
  "version": "3.2.3",
1871
  "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
1872
  "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
1873
+ "dev": true,
1874
  "license": "MIT"
1875
  },
1876
  "node_modules/d3-array": {
 
2352
  "node": ">=16.0.0"
2353
  }
2354
  },
2355
+ "node_modules/file-saver": {
2356
+ "version": "2.0.5",
2357
+ "resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
2358
+ "integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
2359
+ "license": "MIT"
2360
+ },
2361
  "node_modules/find-up": {
2362
  "version": "5.0.0",
2363
  "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz",
 
2959
  "react": "^19.2.3"
2960
  }
2961
  },
 
 
 
 
 
 
 
2962
  "node_modules/react-redux": {
2963
  "version": "9.2.0",
2964
  "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
package.json CHANGED
@@ -12,6 +12,7 @@
12
  "dependencies": {
13
  "@supabase/supabase-js": "^2.53.0",
14
  "date-fns": "^4.1.0",
 
15
  "framer-motion": "^12.23.11",
16
  "lucide-react": "^0.562.0",
17
  "react": "^19.1.0",
 
12
  "dependencies": {
13
  "@supabase/supabase-js": "^2.53.0",
14
  "date-fns": "^4.1.0",
15
+ "file-saver": "^2.0.5",
16
  "framer-motion": "^12.23.11",
17
  "lucide-react": "^0.562.0",
18
  "react": "^19.1.0",
src/components/Admin/AdminSortingPage.jsx CHANGED
@@ -1,14 +1,14 @@
1
  import React, { useState, useMemo, useEffect } from 'react';
2
  import { motion, AnimatePresence } from 'framer-motion';
3
- import { supabase } from '../../supabaseClient';
4
- import CandidateDrawer from '../CandidateDrawer';
5
 
6
  // ✅ IMPORT ICONS FROM YOUR SEPARATE FILE
7
- import {
8
- FilterIcon, ScoringIcon, ClearIcon, ViewIcon,
9
- ChevronDownIcon, SearchIcon, ChevronLeftIcon,
10
- ChevronRightIcon, CheckSquareIcon, MailIcon, LoaderIcon
11
- } from '../../components/Icons';
12
 
13
  // --- REUSABLE BUTTON COMPONENT ---
14
  const BulkActionButton = ({ Icon, label, color, onClick }) => {
@@ -31,7 +31,7 @@ const BulkActionButton = ({ Icon, label, color, onClick }) => {
31
  }}
32
  transition={{ type: 'spring', stiffness: 500, damping: 30 }}
33
  >
34
- <Icon />
35
  <AnimatePresence>
36
  {hover && (
37
  <motion.span
@@ -62,7 +62,7 @@ const FilterPanel = ({ filters, setFilters }) => {
62
  return (
63
  <div style={{ padding: '0 1.5rem 1.5rem 1.5rem', color: '#e2e8f0' }}>
64
  <div style={{ height: '1px', backgroundColor: 'rgba(239, 68, 68, 0.2)', marginBottom: '1.5rem' }}></div>
65
-
66
  {/* Status & Score Group */}
67
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem', marginBottom: '2rem' }}>
68
  <div>
@@ -103,7 +103,7 @@ const FilterPanel = ({ filters, setFilters }) => {
103
  <div>
104
  <h4 style={{ fontSize: '0.85rem', color: '#94a3b8', marginBottom: '0.75rem', fontWeight: '600' }}>Job Positions</h4>
105
  <div className="hide-scrollbar" style={{ height: '150px', overflowY: 'auto', backgroundColor: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '0.5rem', border: '1px solid rgba(255,255,255,0.05)' }}>
106
- <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.5rem' }}>
107
  {positions.map(pos => (
108
  <label key={pos} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', cursor: 'pointer', padding: '4px', borderRadius: '4px', backgroundColor: (filters.positions || []).includes(pos) ? 'rgba(239, 68, 68, 0.1)' : 'transparent' }}>
109
  <input type="checkbox" checked={(filters.positions || []).includes(pos)} onChange={() => toggleItem('positions', pos)} style={{ accentColor: '#EF4444' }} />
@@ -121,7 +121,7 @@ const FilterPanel = ({ filters, setFilters }) => {
121
  // --- SCORING PANEL COMPONENT ---
122
  const ScoringPanel = ({ config, setConfig, onReset, onClose }) => {
123
  const handleChange = (key, value) => setConfig({ ...config, [key]: parseInt(value) });
124
-
125
  // Internal slider for this component
126
  const ConfigSlider = ({ label, value, min, max, onChangeKey }) => (
127
  <div style={{ marginBottom: '1rem' }}>
@@ -147,8 +147,8 @@ const ScoringPanel = ({ config, setConfig, onReset, onClose }) => {
147
  </div>
148
  </div>
149
  <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
150
- <button onClick={onReset} style={{ background: 'transparent', border: '1px solid rgba(255,255,255,0.2)', color: '#94a3b8', padding: '0.4rem 0.8rem', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' }}>Reset Default</button>
151
- <button onClick={onClose} style={{ backgroundColor: '#EF4444', border: 'none', color: 'white', padding: '0.4rem 1.2rem', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 'bold' }}>Apply</button>
152
  </div>
153
  </div>
154
  );
@@ -158,11 +158,11 @@ const ScoringPanel = ({ config, setConfig, onReset, onClose }) => {
158
  export default function AdminSortingPage() {
159
  const [applicants, setApplicants] = useState([]);
160
  const [isLoading, setIsLoading] = useState(true);
161
- const [openPanel, setOpenPanel] = useState(null);
162
  const [searchQuery, setSearchQuery] = useState('');
163
  const [isDrawerOpen, setIsDrawerOpen] = useState(false);
164
  const [drawerCandidate, setDrawerCandidate] = useState(null);
165
-
166
  // Pagination & Selection
167
  const [currentPage, setCurrentPage] = useState(1);
168
  const itemsPerPage = 5;
@@ -195,14 +195,16 @@ export default function AdminSortingPage() {
195
  status,
196
  score: match_score,
197
  skills,
198
- profiles ( full_name, email, avatar_url,experience_years ),
199
- jobs ( title )
200
  `);
201
-
202
  if (error) throw error;
203
 
204
  const formattedData = data.map(app => ({
205
  id: app.id,
 
 
206
  name: app.profiles?.full_name || 'Unknown Candidate',
207
  email: app.profiles?.email || 'No Email',
208
  img: app.profiles?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(app.profiles?.full_name || 'User')}&background=random`,
@@ -212,7 +214,7 @@ export default function AdminSortingPage() {
212
  status: app.status,
213
  score: app.score || 0
214
  }));
215
-
216
  setApplicants(formattedData);
217
  } catch (error) {
218
  console.error('Error fetching applicants:', error.message);
@@ -227,21 +229,21 @@ export default function AdminSortingPage() {
227
  const handleBulkReject = async () => {
228
  if (!confirm(`Are you sure you want to REJECT ${selectedIds.length} candidates?`)) return;
229
 
230
- try {
231
  const { error } = await supabase
232
  .from('applications')
233
  .update({ status: 'Rejected' }) // Update status to Rejected
234
  .in('id', selectedIds);
235
- if (error) throw error;
236
-
237
- // Update UI instantly
238
- setApplicants(prev => prev.map(app =>
239
- selectedIds.includes(app.id) ? { ...app, status: 'Rejected' } : app
240
- ));
241
-
242
- setSelectedIds([]); // Clear selection
243
- alert('Candidates Rejected.');
244
- } catch (error) {
245
  console.error('Error rejecting:', error.message);
246
  alert('Failed to reject.');
247
  }
@@ -259,7 +261,7 @@ export default function AdminSortingPage() {
259
 
260
  if (error) throw error;
261
 
262
- setApplicants(prev => prev.map(app =>
263
  selectedIds.includes(app.id) ? { ...app, status: 'Accepted' } : app
264
  ));
265
  setSelectedIds([]);
@@ -281,7 +283,7 @@ export default function AdminSortingPage() {
281
  const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) || (app.jobTitle || '').toLowerCase().includes(searchQuery.toLowerCase());
282
  const matchesStatus = filters.status === 'All' || app.status === filters.status;
283
  const matchesScore = (app.score || 0) >= filters.minScore;
284
-
285
  const matchesLang = (filters.languages || []).length === 0 || (filters.languages || []).some(l => (app.skills || []).includes(l));
286
  const matchesPos = (filters.positions || []).length === 0 || (filters.positions || []).includes(app.jobTitle);
287
 
@@ -290,12 +292,12 @@ export default function AdminSortingPage() {
290
  if (filters.sortBy === 'Match Score') return (b.score || 0) - (a.score || 0);
291
  if (filters.sortBy === 'Experience') return (b.experience || 0) - (a.experience || 0);
292
  if (filters.sortBy === 'Name') return a.name.localeCompare(b.name);
293
- return 0;
294
  });
295
  }, [searchQuery, filters, applicants]);
296
 
297
  // Check if every single selected person is already 'Accepted'
298
- const allSelectedAreApproved = selectedIds.length > 0 && selectedIds.every(id => {
299
  const applicant = applicants.find(app => app.id === id);
300
  return applicant?.status === 'Accepted'; // Make sure this matches your DB value ('Accepted' or 'Approved')
301
  });
@@ -306,9 +308,9 @@ export default function AdminSortingPage() {
306
 
307
  const toggleSelectAll = () => {
308
  if (selectedIds.length === paginatedApplicants.length && paginatedApplicants.length > 0) {
309
- setSelectedIds([]);
310
  } else {
311
- setSelectedIds(paginatedApplicants.map(a => a.id));
312
  }
313
  };
314
 
@@ -324,7 +326,7 @@ export default function AdminSortingPage() {
324
 
325
  return (
326
  <div style={{ paddingBottom: '4rem' }}>
327
- <style>{`.hide-scrollbar::-webkit-scrollbar { display: none; } .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } @keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
328
 
329
  <header style={{ marginBottom: '2rem' }}>
330
  <h1 style={{ fontSize: '1.875rem', fontWeight: 'bold' }}>CV Sorting</h1>
@@ -343,7 +345,7 @@ export default function AdminSortingPage() {
343
  <motion.button onClick={() => togglePanel('scoring')} whileHover={{ scale: 1.02 }} style={{ backgroundColor: openPanel === 'scoring' ? '#EF4444' : 'rgba(255,255,255,0.1)', color: 'white', padding: '0.75rem 1.2rem', borderRadius: '0.5rem', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: '600' }}>
344
  <ScoringIcon /> Scoring <ChevronDownIcon isOpen={openPanel === 'scoring'} />
345
  </motion.button>
346
- <motion.button onClick={() => { setSearchQuery(''); setFilters({ sortBy: 'Match Score', status: 'All', minScore: 0, languages: [], positions: [] }); }} whileHover={{ scale: 1.02 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', padding: '0.75rem 1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px' }}>
347
  <ClearIcon /> Clear
348
  </motion.button>
349
  </div>
@@ -369,13 +371,13 @@ export default function AdminSortingPage() {
369
  {selectedIds.length > 0 && (
370
  <motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} style={{ display: 'flex', gap: '1rem', alignItems: 'center', backgroundColor: 'rgba(239, 68, 68, 0.1)', padding: '0.5rem 1rem', borderRadius: '12px', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
371
  <span style={{ fontSize: '0.85rem', color: '#fff', fontWeight: 'bold' }}>{selectedIds.length} Selected</span>
372
- <BulkActionButton
373
  // IF everyone is approved, show 'Reject' icon/label. ELSE show 'Accept'.
374
  // ✅ FIXED: Uses the component names directly
375
- Icon={allSelectedAreApproved ? ClearIcon : CheckSquareIcon}
376
- label={allSelectedAreApproved ? "Reject" : "Accept"}
377
  color={allSelectedAreApproved ? "#ef4444" : "#10b981"} // Red if rejecting, Green if accepting
378
- onClick={allSelectedAreApproved ? handleBulkReject : handleBulkApprove}
379
  />
380
  <BulkActionButton Icon={MailIcon} label="Email" color="#3b82f6" onClick={handleBulkEmail} />
381
  </motion.div>
@@ -389,70 +391,70 @@ export default function AdminSortingPage() {
389
  </div>
390
  ) : (
391
  <>
392
- <div className="hide-scrollbar" style={{ overflowX: 'auto' }}>
393
- <table style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 8px', minWidth: '800px' }}>
394
- <thead>
395
- <tr style={{ color: '#9ca3af', textAlign: 'left', fontSize: '0.9rem' }}>
396
- <th style={{ padding: '0 0 1rem 1rem', width: '40px' }}><input type="checkbox" checked={paginatedApplicants.length > 0 && selectedIds.length === paginatedApplicants.length} onChange={toggleSelectAll} style={{ accentColor: '#EF4444', cursor: 'pointer', width: '16px', height: '16px' }} /></th>
397
- <th style={{ padding: '0 1rem 1rem 0' }}>Applicant</th>
398
- <th style={{ padding: '0 1rem 1rem 1rem' }}>Experience</th>
399
- <th style={{ padding: '0 1rem 1rem 1rem' }}>Job Title</th>
400
- <th style={{ padding: '0 1rem 1rem 1rem' }}>Score</th>
401
- <th style={{ padding: '0 1rem 1rem 1rem' }}>Status</th>
402
- <th style={{ padding: '0 1rem 1rem 1rem', textAlign: 'right' }}>Action</th>
403
- </tr>
404
- </thead>
405
- <tbody>
406
- <AnimatePresence>
407
- {paginatedApplicants.map((app) => {
408
- const isSelected = selectedIds.includes(app.id);
409
- return (
410
- <motion.tr
411
- key={app.id}
412
- initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
413
- whileHover={{ scale: 1.005, backgroundColor: 'rgba(255,255,255,0.05)' }}
414
- style={{ backgroundColor: isSelected ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255,255,255,0.02)', borderRadius: '8px', cursor: 'pointer', border: isSelected ? '1px solid rgba(239, 68, 68, 0.3)' : '1px solid transparent' }}
415
- onClick={() => toggleSelectRow(app.id)}
416
- >
417
- <td style={{ padding: '1rem', borderTopLeftRadius: '8px', borderBottomLeftRadius: '8px' }}>
418
- <input type="checkbox" checked={isSelected} onChange={(e) => { e.stopPropagation(); toggleSelectRow(app.id); }} style={{ accentColor: '#EF4444', cursor: 'pointer', width: '16px', height: '16px' }} />
419
- </td>
420
- <td style={{ padding: '1rem 1rem 1rem 0' }}>
421
- <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
422
- <img src={app.img} alt={app.name} style={{ width: '40px', height: '40px', borderRadius: '50%', objectFit: 'cover' }} />
423
- <div><p style={{ fontWeight: 'bold', color: 'white' }}>{app.name}</p><p style={{ fontSize: '0.8rem', color: '#9ca3af' }}>{app.email}</p></div>
424
- </div>
425
- </td>
426
- <td style={{ padding: '1rem', color: '#d1d5db' }}>{app.experience} years</td>
427
- <td style={{ padding: '1rem', color: '#d1d5db' }}>{app.jobTitle}</td>
428
- <td style={{ padding: '1rem' }}><span style={{ fontWeight: 'bold', color: (app.score || 0) > 80 ? '#34d399' : '#fbbf24' }}>{app.score || 0}</span></td>
429
- <td style={{ padding: '1rem' }}><span style={{ fontSize: '0.75rem', padding: '4px 8px', borderRadius: '4px', backgroundColor: app.status === 'Accepted' ? 'rgba(52, 211, 153, 0.2)' : app.status === 'Rejected' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(251, 191, 36, 0.2)', color: app.status === 'Accepted' ? '#34d399' : app.status === 'Rejected' ? '#ef4444' : '#fbbf24' }}>{app.status}</span></td>
430
- <td style={{ padding: '1rem', textAlign: 'right', borderTopRightRadius: '8px', borderBottomRightRadius: '8px' }}>
431
- <button
432
- onClick={(e) => { e.stopPropagation(); handleViewCandidate(app); }}
433
- style={{ background: 'none', border: 'none', color: '#6b7280', cursor: 'pointer' }}
434
- title="View Details"
435
- >
436
- <ViewIcon />
437
- </button>
438
- </td>
439
- </motion.tr>
440
- );
441
- })}
442
- </AnimatePresence>
443
- </tbody>
444
- </table>
445
- </div>
446
- {totalPages > 1 && (
447
- <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
448
- <span style={{ fontSize: '0.85rem', color: '#94a3b8' }}>Showing {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, filteredApplicants.length)} of {filteredApplicants.length}</span>
449
- <div style={{ display: 'flex', gap: '0.5rem' }}>
450
- <button disabled={currentPage === 1} onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} style={{ padding: '0.5rem', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', border: 'none', color: currentPage === 1 ? '#525252' : 'white', cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}><ChevronLeftIcon /></button>
451
- <span style={{ padding: '0.5rem 1rem', background: '#EF4444', borderRadius: '6px', fontSize: '0.85rem', color: 'white', fontWeight: 'bold' }}>{currentPage}</span>
452
- <button disabled={currentPage === totalPages} onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} style={{ padding: '0.5rem', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', border: 'none', color: currentPage === totalPages ? '#525252' : 'white', cursor: currentPage === totalPages ? 'not-allowed' : 'pointer' }}><ChevronRightIcon /></button>
453
- </div>
454
  </div>
455
- )}
 
 
 
 
 
 
 
 
 
456
  </>
457
  )}
458
  </div>
@@ -460,10 +462,10 @@ export default function AdminSortingPage() {
460
  {/* Render the Drawer specifically for the sorting page */}
461
  <AnimatePresence>
462
  {isDrawerOpen && (
463
- <CandidateDrawer
464
- isOpen={isDrawerOpen}
465
- onClose={() => setIsDrawerOpen(false)}
466
- candidate={drawerCandidate}
467
  />
468
  )}
469
  </AnimatePresence>
 
1
  import React, { useState, useMemo, useEffect } from 'react';
2
  import { motion, AnimatePresence } from 'framer-motion';
3
+ import { supabase } from '../../supabaseClient';
4
+ import CandidateDrawer from '../CandidateDrawer';
5
 
6
  // ✅ IMPORT ICONS FROM YOUR SEPARATE FILE
7
+ import {
8
+ FilterIcon, ScoringIcon, ClearIcon, ViewIcon,
9
+ ChevronDownIcon, SearchIcon, ChevronLeftIcon,
10
+ ChevronRightIcon, CheckSquareIcon, MailIcon, LoaderIcon
11
+ } from '../../components/Icons';
12
 
13
  // --- REUSABLE BUTTON COMPONENT ---
14
  const BulkActionButton = ({ Icon, label, color, onClick }) => {
 
31
  }}
32
  transition={{ type: 'spring', stiffness: 500, damping: 30 }}
33
  >
34
+ <Icon />
35
  <AnimatePresence>
36
  {hover && (
37
  <motion.span
 
62
  return (
63
  <div style={{ padding: '0 1.5rem 1.5rem 1.5rem', color: '#e2e8f0' }}>
64
  <div style={{ height: '1px', backgroundColor: 'rgba(239, 68, 68, 0.2)', marginBottom: '1.5rem' }}></div>
65
+
66
  {/* Status & Score Group */}
67
  <div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '2rem', marginBottom: '2rem' }}>
68
  <div>
 
103
  <div>
104
  <h4 style={{ fontSize: '0.85rem', color: '#94a3b8', marginBottom: '0.75rem', fontWeight: '600' }}>Job Positions</h4>
105
  <div className="hide-scrollbar" style={{ height: '150px', overflowY: 'auto', backgroundColor: 'rgba(0,0,0,0.2)', borderRadius: '8px', padding: '0.5rem', border: '1px solid rgba(255,255,255,0.05)' }}>
106
+ <div style={{ display: 'grid', gridTemplateColumns: '1fr', gap: '0.5rem' }}>
107
  {positions.map(pos => (
108
  <label key={pos} style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', fontSize: '0.85rem', cursor: 'pointer', padding: '4px', borderRadius: '4px', backgroundColor: (filters.positions || []).includes(pos) ? 'rgba(239, 68, 68, 0.1)' : 'transparent' }}>
109
  <input type="checkbox" checked={(filters.positions || []).includes(pos)} onChange={() => toggleItem('positions', pos)} style={{ accentColor: '#EF4444' }} />
 
121
  // --- SCORING PANEL COMPONENT ---
122
  const ScoringPanel = ({ config, setConfig, onReset, onClose }) => {
123
  const handleChange = (key, value) => setConfig({ ...config, [key]: parseInt(value) });
124
+
125
  // Internal slider for this component
126
  const ConfigSlider = ({ label, value, min, max, onChangeKey }) => (
127
  <div style={{ marginBottom: '1rem' }}>
 
147
  </div>
148
  </div>
149
  <div style={{ display: 'flex', justifyContent: 'space-between', marginTop: '1rem' }}>
150
+ <button onClick={onReset} style={{ background: 'transparent', border: '1px solid rgba(255,255,255,0.2)', color: '#94a3b8', padding: '0.4rem 0.8rem', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem' }}>Reset Default</button>
151
+ <button onClick={onClose} style={{ backgroundColor: '#EF4444', border: 'none', color: 'white', padding: '0.4rem 1.2rem', borderRadius: '4px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 'bold' }}>Apply</button>
152
  </div>
153
  </div>
154
  );
 
158
  export default function AdminSortingPage() {
159
  const [applicants, setApplicants] = useState([]);
160
  const [isLoading, setIsLoading] = useState(true);
161
+ const [openPanel, setOpenPanel] = useState(null);
162
  const [searchQuery, setSearchQuery] = useState('');
163
  const [isDrawerOpen, setIsDrawerOpen] = useState(false);
164
  const [drawerCandidate, setDrawerCandidate] = useState(null);
165
+
166
  // Pagination & Selection
167
  const [currentPage, setCurrentPage] = useState(1);
168
  const itemsPerPage = 5;
 
195
  status,
196
  score: match_score,
197
  skills,
198
+ profiles ( id, full_name, email, avatar_url, experience_years ),
199
+ jobs ( id, title )
200
  `);
201
+
202
  if (error) throw error;
203
 
204
  const formattedData = data.map(app => ({
205
  id: app.id,
206
+ userId: app.profiles?.id,
207
+ jobId: app.jobs?.id,
208
  name: app.profiles?.full_name || 'Unknown Candidate',
209
  email: app.profiles?.email || 'No Email',
210
  img: app.profiles?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(app.profiles?.full_name || 'User')}&background=random`,
 
214
  status: app.status,
215
  score: app.score || 0
216
  }));
217
+
218
  setApplicants(formattedData);
219
  } catch (error) {
220
  console.error('Error fetching applicants:', error.message);
 
229
  const handleBulkReject = async () => {
230
  if (!confirm(`Are you sure you want to REJECT ${selectedIds.length} candidates?`)) return;
231
 
232
+ try {
233
  const { error } = await supabase
234
  .from('applications')
235
  .update({ status: 'Rejected' }) // Update status to Rejected
236
  .in('id', selectedIds);
237
+ if (error) throw error;
238
+
239
+ // Update UI instantly
240
+ setApplicants(prev => prev.map(app =>
241
+ selectedIds.includes(app.id) ? { ...app, status: 'Rejected' } : app
242
+ ));
243
+
244
+ setSelectedIds([]); // Clear selection
245
+ alert('Candidates Rejected.');
246
+ } catch (error) {
247
  console.error('Error rejecting:', error.message);
248
  alert('Failed to reject.');
249
  }
 
261
 
262
  if (error) throw error;
263
 
264
+ setApplicants(prev => prev.map(app =>
265
  selectedIds.includes(app.id) ? { ...app, status: 'Accepted' } : app
266
  ));
267
  setSelectedIds([]);
 
283
  const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) || (app.jobTitle || '').toLowerCase().includes(searchQuery.toLowerCase());
284
  const matchesStatus = filters.status === 'All' || app.status === filters.status;
285
  const matchesScore = (app.score || 0) >= filters.minScore;
286
+
287
  const matchesLang = (filters.languages || []).length === 0 || (filters.languages || []).some(l => (app.skills || []).includes(l));
288
  const matchesPos = (filters.positions || []).length === 0 || (filters.positions || []).includes(app.jobTitle);
289
 
 
292
  if (filters.sortBy === 'Match Score') return (b.score || 0) - (a.score || 0);
293
  if (filters.sortBy === 'Experience') return (b.experience || 0) - (a.experience || 0);
294
  if (filters.sortBy === 'Name') return a.name.localeCompare(b.name);
295
+ return 0;
296
  });
297
  }, [searchQuery, filters, applicants]);
298
 
299
  // Check if every single selected person is already 'Accepted'
300
+ const allSelectedAreApproved = selectedIds.length > 0 && selectedIds.every(id => {
301
  const applicant = applicants.find(app => app.id === id);
302
  return applicant?.status === 'Accepted'; // Make sure this matches your DB value ('Accepted' or 'Approved')
303
  });
 
308
 
309
  const toggleSelectAll = () => {
310
  if (selectedIds.length === paginatedApplicants.length && paginatedApplicants.length > 0) {
311
+ setSelectedIds([]);
312
  } else {
313
+ setSelectedIds(paginatedApplicants.map(a => a.id));
314
  }
315
  };
316
 
 
326
 
327
  return (
328
  <div style={{ paddingBottom: '4rem' }}>
329
+ <style>{`.hide-scrollbar::-webkit-scrollbar { display: none; } .hide-scrollbar { -ms-overflow-style: none; scrollbar-width: none; } @keyframes spin { 100% { transform: rotate(360deg); } }`}</style>
330
 
331
  <header style={{ marginBottom: '2rem' }}>
332
  <h1 style={{ fontSize: '1.875rem', fontWeight: 'bold' }}>CV Sorting</h1>
 
345
  <motion.button onClick={() => togglePanel('scoring')} whileHover={{ scale: 1.02 }} style={{ backgroundColor: openPanel === 'scoring' ? '#EF4444' : 'rgba(255,255,255,0.1)', color: 'white', padding: '0.75rem 1.2rem', borderRadius: '0.5rem', border: 'none', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '8px', fontWeight: '600' }}>
346
  <ScoringIcon /> Scoring <ChevronDownIcon isOpen={openPanel === 'scoring'} />
347
  </motion.button>
348
+ <motion.button onClick={() => { setSearchQuery(''); setFilters({ sortBy: 'Match Score', status: 'All', minScore: 0, languages: [], positions: [] }); }} whileHover={{ scale: 1.02 }} style={{ backgroundColor: 'rgba(255,255,255,0.1)', color: 'white', padding: '0.75rem 1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.2)', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px' }}>
349
  <ClearIcon /> Clear
350
  </motion.button>
351
  </div>
 
371
  {selectedIds.length > 0 && (
372
  <motion.div initial={{ opacity: 0, x: 20 }} animate={{ opacity: 1, x: 0 }} exit={{ opacity: 0, x: 20 }} style={{ display: 'flex', gap: '1rem', alignItems: 'center', backgroundColor: 'rgba(239, 68, 68, 0.1)', padding: '0.5rem 1rem', borderRadius: '12px', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
373
  <span style={{ fontSize: '0.85rem', color: '#fff', fontWeight: 'bold' }}>{selectedIds.length} Selected</span>
374
+ <BulkActionButton
375
  // IF everyone is approved, show 'Reject' icon/label. ELSE show 'Accept'.
376
  // ✅ FIXED: Uses the component names directly
377
+ Icon={allSelectedAreApproved ? ClearIcon : CheckSquareIcon}
378
+ label={allSelectedAreApproved ? "Reject" : "Accept"}
379
  color={allSelectedAreApproved ? "#ef4444" : "#10b981"} // Red if rejecting, Green if accepting
380
+ onClick={allSelectedAreApproved ? handleBulkReject : handleBulkApprove}
381
  />
382
  <BulkActionButton Icon={MailIcon} label="Email" color="#3b82f6" onClick={handleBulkEmail} />
383
  </motion.div>
 
391
  </div>
392
  ) : (
393
  <>
394
+ <div className="hide-scrollbar" style={{ overflowX: 'auto' }}>
395
+ <table style={{ width: '100%', borderCollapse: 'separate', borderSpacing: '0 8px', minWidth: '800px' }}>
396
+ <thead>
397
+ <tr style={{ color: '#9ca3af', textAlign: 'left', fontSize: '0.9rem' }}>
398
+ <th style={{ padding: '0 0 1rem 1rem', width: '40px' }}><input type="checkbox" checked={paginatedApplicants.length > 0 && selectedIds.length === paginatedApplicants.length} onChange={toggleSelectAll} style={{ accentColor: '#EF4444', cursor: 'pointer', width: '16px', height: '16px' }} /></th>
399
+ <th style={{ padding: '0 1rem 1rem 0' }}>Applicant</th>
400
+ <th style={{ padding: '0 1rem 1rem 1rem' }}>Experience</th>
401
+ <th style={{ padding: '0 1rem 1rem 1rem' }}>Job Title</th>
402
+ <th style={{ padding: '0 1rem 1rem 1rem' }}>Score</th>
403
+ <th style={{ padding: '0 1rem 1rem 1rem' }}>Status</th>
404
+ <th style={{ padding: '0 1rem 1rem 1rem', textAlign: 'right' }}>Action</th>
405
+ </tr>
406
+ </thead>
407
+ <tbody>
408
+ <AnimatePresence>
409
+ {paginatedApplicants.map((app) => {
410
+ const isSelected = selectedIds.includes(app.id);
411
+ return (
412
+ <motion.tr
413
+ key={app.id}
414
+ initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }}
415
+ whileHover={{ scale: 1.005, backgroundColor: 'rgba(255,255,255,0.05)' }}
416
+ style={{ backgroundColor: isSelected ? 'rgba(239, 68, 68, 0.1)' : 'rgba(255,255,255,0.02)', borderRadius: '8px', cursor: 'pointer', border: isSelected ? '1px solid rgba(239, 68, 68, 0.3)' : '1px solid transparent' }}
417
+ onClick={() => toggleSelectRow(app.id)}
418
+ >
419
+ <td style={{ padding: '1rem', borderTopLeftRadius: '8px', borderBottomLeftRadius: '8px' }}>
420
+ <input type="checkbox" checked={isSelected} onChange={(e) => { e.stopPropagation(); toggleSelectRow(app.id); }} style={{ accentColor: '#EF4444', cursor: 'pointer', width: '16px', height: '16px' }} />
421
+ </td>
422
+ <td style={{ padding: '1rem 1rem 1rem 0' }}>
423
+ <div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
424
+ <img src={app.img} alt={app.name} style={{ width: '40px', height: '40px', borderRadius: '50%', objectFit: 'cover' }} />
425
+ <div><p style={{ fontWeight: 'bold', color: 'white' }}>{app.name}</p><p style={{ fontSize: '0.8rem', color: '#9ca3af' }}>{app.email}</p></div>
426
+ </div>
427
+ </td>
428
+ <td style={{ padding: '1rem', color: '#d1d5db' }}>{app.experience} years</td>
429
+ <td style={{ padding: '1rem', color: '#d1d5db' }}>{app.jobTitle}</td>
430
+ <td style={{ padding: '1rem' }}><span style={{ fontWeight: 'bold', color: (app.score || 0) > 80 ? '#34d399' : '#fbbf24' }}>{app.score || 0}</span></td>
431
+ <td style={{ padding: '1rem' }}><span style={{ fontSize: '0.75rem', padding: '4px 8px', borderRadius: '4px', backgroundColor: app.status === 'Accepted' ? 'rgba(52, 211, 153, 0.2)' : app.status === 'Rejected' ? 'rgba(239, 68, 68, 0.2)' : 'rgba(251, 191, 36, 0.2)', color: app.status === 'Accepted' ? '#34d399' : app.status === 'Rejected' ? '#ef4444' : '#fbbf24' }}>{app.status}</span></td>
432
+ <td style={{ padding: '1rem', textAlign: 'right', borderTopRightRadius: '8px', borderBottomRightRadius: '8px' }}>
433
+ <button
434
+ onClick={(e) => { e.stopPropagation(); handleViewCandidate(app); }}
435
+ style={{ background: 'none', border: 'none', color: '#6b7280', cursor: 'pointer' }}
436
+ title="View Details"
437
+ >
438
+ <ViewIcon />
439
+ </button>
440
+ </td>
441
+ </motion.tr>
442
+ );
443
+ })}
444
+ </AnimatePresence>
445
+ </tbody>
446
+ </table>
 
 
 
 
 
 
 
 
 
447
  </div>
448
+ {totalPages > 1 && (
449
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginTop: '1.5rem', paddingTop: '1rem', borderTop: '1px solid rgba(255,255,255,0.1)' }}>
450
+ <span style={{ fontSize: '0.85rem', color: '#94a3b8' }}>Showing {((currentPage - 1) * itemsPerPage) + 1}-{Math.min(currentPage * itemsPerPage, filteredApplicants.length)} of {filteredApplicants.length}</span>
451
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
452
+ <button disabled={currentPage === 1} onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))} style={{ padding: '0.5rem', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', border: 'none', color: currentPage === 1 ? '#525252' : 'white', cursor: currentPage === 1 ? 'not-allowed' : 'pointer' }}><ChevronLeftIcon /></button>
453
+ <span style={{ padding: '0.5rem 1rem', background: '#EF4444', borderRadius: '6px', fontSize: '0.85rem', color: 'white', fontWeight: 'bold' }}>{currentPage}</span>
454
+ <button disabled={currentPage === totalPages} onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))} style={{ padding: '0.5rem', borderRadius: '6px', background: 'rgba(255,255,255,0.1)', border: 'none', color: currentPage === totalPages ? '#525252' : 'white', cursor: currentPage === totalPages ? 'not-allowed' : 'pointer' }}><ChevronRightIcon /></button>
455
+ </div>
456
+ </div>
457
+ )}
458
  </>
459
  )}
460
  </div>
 
462
  {/* Render the Drawer specifically for the sorting page */}
463
  <AnimatePresence>
464
  {isDrawerOpen && (
465
+ <CandidateDrawer
466
+ isOpen={isDrawerOpen}
467
+ onClose={() => setIsDrawerOpen(false)}
468
+ candidate={drawerCandidate}
469
  />
470
  )}
471
  </AnimatePresence>
src/components/Adminfront/TopPerformers.jsx CHANGED
@@ -1,13 +1,16 @@
1
  import React, { useEffect, useState } from 'react';
2
- import { supabase } from '../../supabaseClient';
3
  import { motion, AnimatePresence } from 'framer-motion';
 
4
 
5
  export default function TopPerformers() {
6
  // --- STATE ---
7
  const [candidates, setCandidates] = useState([]);
8
  const [loading, setLoading] = useState(true);
9
  const [showConfig, setShowConfig] = useState(false);
10
-
 
 
11
  // Slider Config State
12
  const [config, setConfig] = useState({
13
  skillsWeight: 2,
@@ -22,30 +25,17 @@ export default function TopPerformers() {
22
 
23
  // --- DUMMY DATA (Fallback) ---
24
  const dummyCandidates = [
25
- {
26
- id: 'd1', experience: 8,
27
- profiles: { full_name: 'Elena Martinez', avatar_url: 'https://i.pravatar.cc/150?u=elena' },
28
- jobs: { title: 'Data Scientist' }
29
- },
30
- {
31
- id: 'd2', experience: 5,
32
- profiles: { full_name: 'Sarah Johnson', avatar_url: 'https://i.pravatar.cc/150?u=sarah' },
33
- jobs: { title: 'UX/UI Designer' }
34
- },
35
- {
36
- id: 'd3', experience: 12,
37
- profiles: { full_name: 'Rayyan Ali', avatar_url: 'https://i.pravatar.cc/150?u=rayyan' },
38
- jobs: { title: 'Senior Developer' }
39
  },
40
- {
41
- id: 'd4', experience: 3,
42
- profiles: { full_name: 'Iffah Fathima', avatar_url: 'https://i.pravatar.cc/150?u=iffah' },
43
- jobs: { title: 'Frontend Intern' }
44
  },
45
- {
46
- id: 'd5', experience: 6,
47
- profiles: { full_name: 'Varun Nair', avatar_url: null }, // Test no avatar
48
- jobs: { title: 'Product Manager' }
49
  }
50
  ];
51
 
@@ -56,19 +46,32 @@ export default function TopPerformers() {
56
  const { data, error } = await supabase
57
  .from('applications')
58
  .select(`
59
- id, experience,
60
- profiles ( full_name, avatar_url ),
61
- jobs ( title )
 
 
62
  `)
63
- .limit(7);
64
 
65
  if (error) {
66
  console.error("Supabase error, using dummy data:", error);
67
- setCandidates(dummyCandidates); // Fallback on error
68
  } else if (data && data.length > 0) {
69
- setCandidates(data);
 
 
 
 
 
 
 
 
 
 
 
70
  } else {
71
- setCandidates(dummyCandidates); // Fallback if empty
72
  }
73
  } catch (error) {
74
  console.error('Fetch error, using dummy data:', error);
@@ -80,10 +83,19 @@ export default function TopPerformers() {
80
  fetchCandidates();
81
  }, []);
82
 
 
 
 
 
 
 
 
 
 
83
  // --- STYLES ---
84
  const containerStyle = {
85
- backgroundColor: 'rgba(239, 68, 68, 0.05)',
86
- border: '1px solid rgba(239, 68, 68, 0.2)',
87
  borderRadius: '1rem',
88
  padding: '1.5rem',
89
  color: 'white',
@@ -92,7 +104,7 @@ export default function TopPerformers() {
92
  };
93
 
94
  const configBoxStyle = {
95
- backgroundColor: 'rgba(0, 0, 0, 0.3)',
96
  border: '1px solid rgba(255, 255, 255, 0.1)',
97
  borderRadius: '0.75rem',
98
  padding: '1.25rem',
@@ -116,16 +128,16 @@ export default function TopPerformers() {
116
 
117
  return (
118
  <div style={containerStyle}>
119
-
120
  {/* --- HEADER --- */}
121
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
122
  <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>Top Performers</h2>
123
-
124
- <button
125
  onClick={() => setShowConfig(!showConfig)}
126
- style={{
127
- background: 'none', border: 'none',
128
- color: showConfig ? '#EF4444' : '#9CA3AF',
129
  cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
130
  fontSize: '0.9rem', fontWeight: '500'
131
  }}
@@ -134,7 +146,7 @@ export default function TopPerformers() {
134
  {showConfig ? "Hide" : "Config"}
135
  </button>
136
  </div>
137
-
138
  <p style={{ color: '#9CA3AF', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '0.25rem' }}>
139
  Outstanding candidates by match score
140
  </p>
@@ -180,23 +192,23 @@ export default function TopPerformers() {
180
  {loading ? (
181
  <p style={{ color: '#6B7280', textAlign: 'center' }}>Loading...</p>
182
  ) : candidates.map((item, index) => {
183
- const name = item.profiles?.full_name || 'Candidate';
184
- const role = item.jobs?.title || 'Applicant';
185
  const exp = item.experience ? `${item.experience} yrs` : 'N/A';
186
- const score = 90 - (index * 5);
187
 
188
  return (
189
  <div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
190
  <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
191
  {/* Avatar */}
192
- {item.profiles?.avatar_url ? (
193
- <img src={item.profiles.avatar_url} alt={name} style={{ width: '45px', height: '45px', borderRadius: '50%', objectFit: 'cover', border: '2px solid #374151' }} />
194
  ) : (
195
  <div style={{ width: '45px', height: '45px', borderRadius: '50%', backgroundColor: '#374151', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold' }}>
196
  {name.charAt(0)}
197
  </div>
198
  )}
199
-
200
  {/* Text Info */}
201
  <div>
202
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
@@ -208,15 +220,24 @@ export default function TopPerformers() {
208
  <div style={{ fontSize: '0.8rem', color: '#9CA3AF', marginTop: '2px' }}>
209
  {role} • {exp} • <span style={{ color: '#34D399' }}>Certified</span>
210
  </div>
 
 
 
 
 
 
211
  </div>
212
  </div>
213
-
214
- <button style={{
215
- padding: '0.4rem 1rem', borderRadius: '2rem',
216
- border: '1px solid #EF4444', background: 'transparent',
217
- color: 'white', fontSize: '0.75rem', cursor: 'pointer', fontWeight: '500',
218
- transition: '0.2s'
219
- }}>
 
 
 
220
  View
221
  </button>
222
  </div>
@@ -224,6 +245,13 @@ export default function TopPerformers() {
224
  })}
225
  </div>
226
 
 
 
 
 
 
 
 
227
  {/* Slider CSS Injection */}
228
  <style>{`
229
  .custom-range::-webkit-slider-thumb {
 
1
  import React, { useEffect, useState } from 'react';
2
+ import { supabase } from '../../supabaseClient';
3
  import { motion, AnimatePresence } from 'framer-motion';
4
+ import CandidateDrawer from '../CandidateDrawer';
5
 
6
  export default function TopPerformers() {
7
  // --- STATE ---
8
  const [candidates, setCandidates] = useState([]);
9
  const [loading, setLoading] = useState(true);
10
  const [showConfig, setShowConfig] = useState(false);
11
+ const [selectedCandidate, setSelectedCandidate] = useState(null);
12
+ const [isDrawerOpen, setIsDrawerOpen] = useState(false);
13
+
14
  // Slider Config State
15
  const [config, setConfig] = useState({
16
  skillsWeight: 2,
 
25
 
26
  // --- DUMMY DATA (Fallback) ---
27
  const dummyCandidates = [
28
+ {
29
+ id: 'd1', userId: 'u1', name: 'Elena Martinez', avatar: 'https://i.pravatar.cc/150?u=elena',
30
+ jobTitle: 'Data Scientist', experience: 8, score: 95
 
 
 
 
 
 
 
 
 
 
 
31
  },
32
+ {
33
+ id: 'd2', userId: 'u2', name: 'Sarah Johnson', avatar: 'https://i.pravatar.cc/150?u=sarah',
34
+ jobTitle: 'UX/UI Designer', experience: 5, score: 90
 
35
  },
36
+ {
37
+ id: 'd3', userId: 'u3', name: 'Rayyan Ali', avatar: 'https://i.pravatar.cc/150?u=rayyan',
38
+ jobTitle: 'Senior Developer', experience: 12, score: 85
 
39
  }
40
  ];
41
 
 
46
  const { data, error } = await supabase
47
  .from('applications')
48
  .select(`
49
+ id,
50
+ experience,
51
+ user_id:profiles(id, full_name, avatar_url, summary),
52
+ job_id:jobs(id, title),
53
+ match_score
54
  `)
55
+ .limit(7);
56
 
57
  if (error) {
58
  console.error("Supabase error, using dummy data:", error);
59
+ setCandidates(dummyCandidates);
60
  } else if (data && data.length > 0) {
61
+ const normalized = data.map(item => ({
62
+ id: item.id,
63
+ userId: item.user_id?.id,
64
+ jobId: item.job_id?.id,
65
+ name: item.user_id?.full_name || 'Candidate',
66
+ avatar: item.user_id?.avatar_url,
67
+ jobTitle: item.job_id?.title || 'Applicant',
68
+ experience: item.experience,
69
+ summary: item.user_id?.summary,
70
+ score: item.match_score || (90 - data.indexOf(item) * 5)
71
+ }));
72
+ setCandidates(normalized);
73
  } else {
74
+ setCandidates(dummyCandidates);
75
  }
76
  } catch (error) {
77
  console.error('Fetch error, using dummy data:', error);
 
83
  fetchCandidates();
84
  }, []);
85
 
86
+ // --- HANDLERS ---
87
+ const handleView = (candidate) => {
88
+ setSelectedCandidate({
89
+ ...candidate,
90
+ img: candidate.avatar
91
+ });
92
+ setIsDrawerOpen(true);
93
+ };
94
+
95
  // --- STYLES ---
96
  const containerStyle = {
97
+ backgroundColor: 'rgba(239, 68, 68, 0.05)',
98
+ border: '1px solid rgba(239, 68, 68, 0.2)',
99
  borderRadius: '1rem',
100
  padding: '1.5rem',
101
  color: 'white',
 
104
  };
105
 
106
  const configBoxStyle = {
107
+ backgroundColor: 'rgba(0, 0, 0, 0.3)',
108
  border: '1px solid rgba(255, 255, 255, 0.1)',
109
  borderRadius: '0.75rem',
110
  padding: '1.25rem',
 
128
 
129
  return (
130
  <div style={containerStyle}>
131
+
132
  {/* --- HEADER --- */}
133
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
134
  <h2 style={{ fontSize: '1.25rem', fontWeight: 'bold', margin: 0 }}>Top Performers</h2>
135
+
136
+ <button
137
  onClick={() => setShowConfig(!showConfig)}
138
+ style={{
139
+ background: 'none', border: 'none',
140
+ color: showConfig ? '#EF4444' : '#9CA3AF',
141
  cursor: 'pointer', display: 'flex', alignItems: 'center', gap: '6px',
142
  fontSize: '0.9rem', fontWeight: '500'
143
  }}
 
146
  {showConfig ? "Hide" : "Config"}
147
  </button>
148
  </div>
149
+
150
  <p style={{ color: '#9CA3AF', fontSize: '0.85rem', marginBottom: '1.5rem', marginTop: '0.25rem' }}>
151
  Outstanding candidates by match score
152
  </p>
 
192
  {loading ? (
193
  <p style={{ color: '#6B7280', textAlign: 'center' }}>Loading...</p>
194
  ) : candidates.map((item, index) => {
195
+ const name = item.name;
196
+ const role = item.jobTitle;
197
  const exp = item.experience ? `${item.experience} yrs` : 'N/A';
198
+ const score = item.score;
199
 
200
  return (
201
  <div key={item.id} style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
202
  <div style={{ display: 'flex', gap: '1rem', alignItems: 'center' }}>
203
  {/* Avatar */}
204
+ {item.avatar ? (
205
+ <img src={item.avatar} alt={name} style={{ width: '45px', height: '45px', borderRadius: '50%', objectFit: 'cover', border: '2px solid #374151' }} />
206
  ) : (
207
  <div style={{ width: '45px', height: '45px', borderRadius: '50%', backgroundColor: '#374151', display: 'flex', alignItems: 'center', justifyContent: 'center', color: 'white', fontWeight: 'bold' }}>
208
  {name.charAt(0)}
209
  </div>
210
  )}
211
+
212
  {/* Text Info */}
213
  <div>
214
  <div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
 
220
  <div style={{ fontSize: '0.8rem', color: '#9CA3AF', marginTop: '2px' }}>
221
  {role} • {exp} • <span style={{ color: '#34D399' }}>Certified</span>
222
  </div>
223
+ {/* AI Summary Preview */}
224
+ {item.summary && (
225
+ <div style={{ fontSize: '0.75rem', color: '#94a3b8', marginTop: '4px', fontStyle: 'italic', maxWidth: '250px', whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
226
+ AI: {item.summary}
227
+ </div>
228
+ )}
229
  </div>
230
  </div>
231
+
232
+ <button
233
+ onClick={() => handleView(item)}
234
+ style={{
235
+ padding: '0.4rem 1rem', borderRadius: '2rem',
236
+ border: '1px solid #EF4444', background: 'transparent',
237
+ color: 'white', fontSize: '0.75rem', cursor: 'pointer', fontWeight: '500',
238
+ transition: '0.2s'
239
+ }}
240
+ >
241
  View
242
  </button>
243
  </div>
 
245
  })}
246
  </div>
247
 
248
+ {/* Candidate Drawer Integration */}
249
+ <CandidateDrawer
250
+ isOpen={isDrawerOpen}
251
+ onClose={() => setIsDrawerOpen(false)}
252
+ candidate={selectedCandidate}
253
+ />
254
+
255
  {/* Slider CSS Injection */}
256
  <style>{`
257
  .custom-range::-webkit-slider-thumb {
src/components/CandidateDrawer.jsx CHANGED
@@ -2,12 +2,47 @@ import React, { useState } from 'react';
2
  import { motion, AnimatePresence } from 'framer-motion';
3
  import { X, ExternalLink, ThumbsUp, ThumbsDown, BrainCircuit } from 'lucide-react';
4
  import FullProfileOverlay from './FullProfileOverlay';
5
-
6
  import { supabase } from '../supabaseClient';
7
 
8
 
9
  const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
10
  const [showFullProfile, setShowFullProfile] = useState(false);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
  const handleDownloadCV = async () => {
13
  if (!candidate.resumeUrl) {
@@ -16,18 +51,15 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
16
  }
17
 
18
  try {
19
- // Check if it's a full URL or a storage path
20
  if (candidate.resumeUrl.startsWith('http')) {
21
  saveAs(candidate.resumeUrl, `${candidate.name}_Resume.pdf`);
22
  } else {
23
- // Assume it's a Supabase Storage path
24
  const { data, error } = await supabase
25
  .storage
26
- .from('resume') // Ensure this bucket name matches your setup
27
  .download(candidate.resumeUrl);
28
 
29
  if (error) throw error;
30
-
31
  saveAs(data, `${candidate.name}_Resume.pdf`);
32
  }
33
  } catch (error) {
@@ -38,11 +70,15 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
38
 
39
  if (!candidate) return null;
40
 
 
 
 
 
 
41
  return (
42
  <AnimatePresence>
43
  {isOpen && (
44
  <>
45
- {/* Backdrop Overlay */}
46
  <motion.div
47
  initial={{ opacity: 0 }}
48
  animate={{ opacity: 1 }}
@@ -57,7 +93,6 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
57
  }}
58
  />
59
 
60
- {/* Drawer Panel */}
61
  <motion.div
62
  initial={{ x: '100%' }}
63
  animate={{ x: 0 }}
@@ -70,54 +105,52 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
70
  height: '100%',
71
  width: '100%',
72
  maxWidth: '500px',
73
-
74
- // === 🎨 CSS-ONLY BACKGROUND (No Image Needed) ===
75
- backgroundColor: '#0f172a', // Base Dark Slate Color
76
  backgroundImage: `
77
  radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.25) 0px, transparent 50%),
78
  radial-gradient(at 100% 100%, rgba(239, 68, 68, 0.25) 0px, transparent 50%),
79
  linear-gradient(135deg, rgba(255,255,255,0.03) 0%, transparent 100%)
80
  `,
81
- // 1. Blue Glow (Top Left)
82
- // 2. Red Glow (Bottom Right)
83
- // 3. Subtle Diagonal Sheen
84
-
85
  borderLeft: '1px solid rgba(255,255,255,0.1)',
86
  zIndex: 50,
87
  overflowY: 'auto',
88
  boxShadow: '-10px 0 25px rgba(0,0,0,0.5)'
89
  }}
90
  >
91
- {/* Content Container */}
92
  <div style={{ padding: '2rem' }}>
93
 
94
  {/* Header */}
95
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '2rem' }}>
96
  <div>
97
  <h2 style={{ fontSize: '1.75rem', fontWeight: 'bold', color: 'white' }}>{candidate.name}</h2>
98
- <p style={{ color: '#94a3b8' }}>{candidate.role} • {candidate.experience}</p>
99
  </div>
100
  <button onClick={onClose} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
101
  <X size={24} />
102
  </button>
103
  </div>
104
 
105
- {/* Match Score Bar */}
106
  <div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: 'rgba(239, 68, 68, 0.05)', borderRadius: '0.75rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
107
  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
108
- <span style={{ fontWeight: 'bold', color: '#EF4444' }}>AI Match Score</span>
109
- <span style={{ fontWeight: 'bold', color: 'white' }}>{candidate.matchScore || 0}%</span>
110
  </div>
111
  <div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}>
112
  <motion.div
113
  initial={{ width: 0 }}
114
- animate={{ width: `${candidate.matchScore || 0}%` }}
115
  transition={{ delay: 0.2, duration: 1 }}
116
  style={{ height: '100%', backgroundColor: '#EF4444', borderRadius: '3px', boxShadow: '0 0 10px rgba(239,68,68,0.5)' }}
117
  />
118
  </div>
119
  </div>
120
 
 
 
 
 
 
 
121
  {/* AI Insights Section */}
122
  <div style={{ marginBottom: '2rem' }}>
123
  <h3 style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '1rem' }}>
@@ -125,13 +158,13 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
125
  </h3>
126
 
127
  <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
128
- {candidate.insights?.strengths?.map((str, i) => (
129
  <div key={`str-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}>
130
  <ThumbsUp size={16} color="#34d399" style={{ marginTop: '3px', flexShrink: 0 }} />
131
  <span>{str}</span>
132
  </div>
133
  ))}
134
- {candidate.insights?.weaknesses?.map((wk, i) => (
135
  <div key={`wk-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}>
136
  <ThumbsDown size={16} color="#fb7185" style={{ marginTop: '3px', flexShrink: 0 }} />
137
  <span>{wk}</span>
@@ -140,11 +173,25 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
140
  </div>
141
  </div>
142
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
143
  {/* Resume Summary */}
144
  <div style={{ marginBottom: '2rem' }}>
145
- <h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>Resume Summary</h3>
146
  <p style={{ fontSize: '0.9rem', lineHeight: '1.6', color: '#94a3b8', backgroundColor: 'rgba(255,255,255,0.05)', padding: '1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.05)' }}>
147
- {candidate.summary || "No summary available."}
148
  </p>
149
  </div>
150
 
@@ -179,18 +226,18 @@ const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
179
  </div>
180
  </motion.div>
181
 
182
- <AnimatePresence>
183
- {showFullProfile && (
184
  <FullProfileOverlay
185
  candidate={candidate}
186
  onClose={() => setShowFullProfile(false)}
187
  />
188
- )}
189
- </AnimatePresence>
190
  </>
191
  )}
192
  </AnimatePresence>
193
  );
194
  };
195
 
196
- export default CandidateDrawer
 
2
  import { motion, AnimatePresence } from 'framer-motion';
3
  import { X, ExternalLink, ThumbsUp, ThumbsDown, BrainCircuit } from 'lucide-react';
4
  import FullProfileOverlay from './FullProfileOverlay';
5
+ import { saveAs } from 'file-saver';
6
  import { supabase } from '../supabaseClient';
7
 
8
 
9
  const CandidateDrawer = ({ isOpen, onClose, candidate }) => {
10
  const [showFullProfile, setShowFullProfile] = useState(false);
11
+ const [analysis, setAnalysis] = useState(null);
12
+ const [loadingAnalysis, setLoadingAnalysis] = useState(false);
13
+
14
+ React.useEffect(() => {
15
+ if (isOpen && candidate?.userId && !analysis) {
16
+ fetchAnalysis();
17
+ }
18
+ }, [isOpen, candidate, analysis]);
19
+
20
+ // Reset analysis when candidate changes
21
+ React.useEffect(() => {
22
+ setAnalysis(null);
23
+ }, [candidate?.id]);
24
+
25
+ const fetchAnalysis = async () => {
26
+ setLoadingAnalysis(true);
27
+ try {
28
+ const response = await fetch('http://localhost:8000/analyze-candidate', {
29
+ method: 'POST',
30
+ headers: { 'Content-Type': 'application/json' },
31
+ body: JSON.stringify({
32
+ candidate_id: candidate.userId,
33
+ job_id: candidate.jobId
34
+ })
35
+ });
36
+ const result = await response.json();
37
+ if (result.status === 'success') {
38
+ setAnalysis(result.data);
39
+ }
40
+ } catch (error) {
41
+ console.error("Failed to fetch analysis:", error);
42
+ } finally {
43
+ setLoadingAnalysis(false);
44
+ }
45
+ };
46
 
47
  const handleDownloadCV = async () => {
48
  if (!candidate.resumeUrl) {
 
51
  }
52
 
53
  try {
 
54
  if (candidate.resumeUrl.startsWith('http')) {
55
  saveAs(candidate.resumeUrl, `${candidate.name}_Resume.pdf`);
56
  } else {
 
57
  const { data, error } = await supabase
58
  .storage
59
+ .from('resume')
60
  .download(candidate.resumeUrl);
61
 
62
  if (error) throw error;
 
63
  saveAs(data, `${candidate.name}_Resume.pdf`);
64
  }
65
  } catch (error) {
 
70
 
71
  if (!candidate) return null;
72
 
73
+ const summary = analysis?.summary || candidate.summary || "No summary available.";
74
+ const strengths = analysis?.strengths || candidate.insights?.strengths || [];
75
+ const weaknesses = analysis?.weaknesses || candidate.insights?.weaknesses || [];
76
+ const missingSkills = analysis?.missing_skills || [];
77
+
78
  return (
79
  <AnimatePresence>
80
  {isOpen && (
81
  <>
 
82
  <motion.div
83
  initial={{ opacity: 0 }}
84
  animate={{ opacity: 1 }}
 
93
  }}
94
  />
95
 
 
96
  <motion.div
97
  initial={{ x: '100%' }}
98
  animate={{ x: 0 }}
 
105
  height: '100%',
106
  width: '100%',
107
  maxWidth: '500px',
108
+ backgroundColor: '#0f172a',
 
 
109
  backgroundImage: `
110
  radial-gradient(at 0% 0%, rgba(56, 189, 248, 0.25) 0px, transparent 50%),
111
  radial-gradient(at 100% 100%, rgba(239, 68, 68, 0.25) 0px, transparent 50%),
112
  linear-gradient(135deg, rgba(255,255,255,0.03) 0%, transparent 100%)
113
  `,
 
 
 
 
114
  borderLeft: '1px solid rgba(255,255,255,0.1)',
115
  zIndex: 50,
116
  overflowY: 'auto',
117
  boxShadow: '-10px 0 25px rgba(0,0,0,0.5)'
118
  }}
119
  >
 
120
  <div style={{ padding: '2rem' }}>
121
 
122
  {/* Header */}
123
  <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'start', marginBottom: '2rem' }}>
124
  <div>
125
  <h2 style={{ fontSize: '1.75rem', fontWeight: 'bold', color: 'white' }}>{candidate.name}</h2>
126
+ <p style={{ color: '#94a3b8' }}>{candidate.jobTitle || candidate.role} • {candidate.experience} yrs</p>
127
  </div>
128
  <button onClick={onClose} style={{ background: 'none', border: 'none', color: '#94a3b8', cursor: 'pointer' }}>
129
  <X size={24} />
130
  </button>
131
  </div>
132
 
 
133
  <div style={{ marginBottom: '2rem', padding: '1rem', backgroundColor: 'rgba(239, 68, 68, 0.05)', borderRadius: '0.75rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
134
  <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '0.5rem' }}>
135
+ <span style={{ fontWeight: 'bold', color: '#EF4444' }}>{analysis?.score !== undefined ? 'Gemini AI Score' : 'Semantic Match Score'}</span>
136
+ <span style={{ fontWeight: 'bold', color: 'white' }}>{analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%</span>
137
  </div>
138
  <div style={{ width: '100%', height: '6px', backgroundColor: 'rgba(255,255,255,0.1)', borderRadius: '3px' }}>
139
  <motion.div
140
  initial={{ width: 0 }}
141
+ animate={{ width: `${analysis?.score ?? (candidate.score || candidate.matchScore || 0)}%` }}
142
  transition={{ delay: 0.2, duration: 1 }}
143
  style={{ height: '100%', backgroundColor: '#EF4444', borderRadius: '3px', boxShadow: '0 0 10px rgba(239,68,68,0.5)' }}
144
  />
145
  </div>
146
  </div>
147
 
148
+ {loadingAnalysis && (
149
+ <div style={{ padding: '1rem', textAlign: 'center', color: '#EF4444', fontSize: '0.9rem' }}>
150
+ Analyzing candidate with AI...
151
+ </div>
152
+ )}
153
+
154
  {/* AI Insights Section */}
155
  <div style={{ marginBottom: '2rem' }}>
156
  <h3 style={{ display: 'flex', alignItems: 'center', gap: '8px', fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '1rem' }}>
 
158
  </h3>
159
 
160
  <div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
161
+ {strengths.map((str, i) => (
162
  <div key={`str-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}>
163
  <ThumbsUp size={16} color="#34d399" style={{ marginTop: '3px', flexShrink: 0 }} />
164
  <span>{str}</span>
165
  </div>
166
  ))}
167
+ {weaknesses.map((wk, i) => (
168
  <div key={`wk-${i}`} style={{ display: 'flex', gap: '10px', fontSize: '0.9rem', color: '#cbd5e1' }}>
169
  <ThumbsDown size={16} color="#fb7185" style={{ marginTop: '3px', flexShrink: 0 }} />
170
  <span>{wk}</span>
 
173
  </div>
174
  </div>
175
 
176
+ {/* Missing Skills Section */}
177
+ {missingSkills.length > 0 && (
178
+ <div style={{ marginBottom: '2rem' }}>
179
+ <h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>Missing Skills (Gap Analysis)</h3>
180
+ <div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
181
+ {missingSkills.map((skill, i) => (
182
+ <span key={i} style={{ backgroundColor: 'rgba(239, 68, 68, 0.1)', color: '#EF4444', padding: '4px 10px', borderRadius: '4px', fontSize: '0.8rem', border: '1px solid rgba(239, 68, 68, 0.2)' }}>
183
+ {skill}
184
+ </span>
185
+ ))}
186
+ </div>
187
+ </div>
188
+ )}
189
+
190
  {/* Resume Summary */}
191
  <div style={{ marginBottom: '2rem' }}>
192
+ <h3 style={{ fontSize: '1.1rem', fontWeight: '600', color: 'white', marginBottom: '0.75rem' }}>AI Professional Summary</h3>
193
  <p style={{ fontSize: '0.9rem', lineHeight: '1.6', color: '#94a3b8', backgroundColor: 'rgba(255,255,255,0.05)', padding: '1rem', borderRadius: '0.5rem', border: '1px solid rgba(255,255,255,0.05)' }}>
194
+ {summary}
195
  </p>
196
  </div>
197
 
 
226
  </div>
227
  </motion.div>
228
 
229
+ {showFullProfile && (
230
+ <AnimatePresence>
231
  <FullProfileOverlay
232
  candidate={candidate}
233
  onClose={() => setShowFullProfile(false)}
234
  />
235
+ </AnimatePresence>
236
+ )}
237
  </>
238
  )}
239
  </AnimatePresence>
240
  );
241
  };
242
 
243
+ export default CandidateDrawer;
src/components/FullProfileOverlay.jsx CHANGED
@@ -1,10 +1,53 @@
1
  import React from 'react';
2
  import { motion, AnimatePresence } from 'framer-motion';
3
- import { X, Mail, Phone, MapPin, Briefcase, GraduationCap, Award, User } from 'lucide-react';
 
4
 
5
  const FullProfileOverlay = ({ candidate, onClose }) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
  if (!candidate) return null;
7
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  // Helper to safely parse skills (array or CSV string)
9
  const parseSkills = (data) => {
10
  if (Array.isArray(data)) return data;
@@ -12,50 +55,24 @@ const FullProfileOverlay = ({ candidate, onClose }) => {
12
  return [];
13
  };
14
 
15
- // Robustly gather skills from multiple possible sources
16
- const rawSkills = parseSkills(candidate.skills);
17
- const rawTechSkills = parseSkills(candidate.technical_skills);
18
- const profileSkills = parseSkills(candidate.profiles?.skills);
19
- const profileTechSkills = parseSkills(candidate.profiles?.technical_skills);
20
-
21
- // Merge unique skills
22
  const allSkills = [...new Set([
23
- ...rawSkills,
24
- ...rawTechSkills,
25
- ...profileSkills,
26
- ...profileTechSkills
27
  ])];
28
 
29
- // Mapping Real PostgreSQL Data (profiles table) to UI variables
30
  const fullCandidate = {
31
- ...candidate, // Keep any existing IDs or metadata
32
-
33
- // Basic Info from Database
34
- name: candidate.full_name || candidate.name || 'No Name Provided',
35
- role: candidate.current_position || candidate.role || 'Applicant',
36
- email: candidate.email || 'No email available',
37
- phone: candidate.phone || 'No phone provided',
38
- location: candidate.location || 'Remote',
39
- avatar: candidate.avatar_url || candidate.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(candidate.full_name || 'User')}`,
40
-
41
- // Long-form Text
42
- about: candidate.summary || candidate.headline || "No summary available for this profile.",
43
-
44
- // Skills (Consolidated)
45
  skills: allSkills,
46
-
47
- // JSONB Mapping (ensures .map() won't crash if data is empty)
48
- education: Array.isArray(candidate.education)
49
- ? candidate.education
50
- : [],
51
-
52
- experience_details: Array.isArray(candidate.work_experience)
53
- ? candidate.work_experience
54
- : [],
55
-
56
- projects: Array.isArray(candidate.projects)
57
- ? candidate.projects
58
- : []
59
  };
60
 
61
  return (
 
1
  import React from 'react';
2
  import { motion, AnimatePresence } from 'framer-motion';
3
+ import { X, Mail, Phone, MapPin, Briefcase, GraduationCap, Award, User, Loader2 } from 'lucide-react';
4
+ import { supabase } from '../supabaseClient';
5
 
6
  const FullProfileOverlay = ({ candidate, onClose }) => {
7
+ const [profile, setProfile] = React.useState(null);
8
+ const [loading, setLoading] = React.useState(true);
9
+
10
+ React.useEffect(() => {
11
+ const fetchFullProfile = async () => {
12
+ if (!candidate?.userId && !candidate?.id) return;
13
+
14
+ setLoading(true);
15
+ try {
16
+ const targetId = candidate.userId || candidate.id;
17
+
18
+ const { data, error } = await supabase
19
+ .from('profiles')
20
+ .select('*')
21
+ .eq('id', targetId)
22
+ .single();
23
+
24
+ if (error) throw error;
25
+ setProfile(data);
26
+ } catch (error) {
27
+ console.error("Error fetching full profile:", error);
28
+ setProfile(candidate);
29
+ } finally {
30
+ setLoading(false);
31
+ }
32
+ };
33
+
34
+ fetchFullProfile();
35
+ }, [candidate]);
36
+
37
  if (!candidate) return null;
38
 
39
+ if (loading) {
40
+ return (
41
+ <div style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', backgroundColor: 'rgba(0, 0, 0, 0.7)', backdropFilter: 'blur(8px)' }}>
42
+ <motion.div animate={{ rotate: 360 }} transition={{ repeat: Infinity, duration: 1, ease: 'linear' }}>
43
+ <Loader2 size={48} color="#EF4444" />
44
+ </motion.div>
45
+ </div>
46
+ );
47
+ }
48
+
49
+ const displayData = profile || candidate;
50
+
51
  // Helper to safely parse skills (array or CSV string)
52
  const parseSkills = (data) => {
53
  if (Array.isArray(data)) return data;
 
55
  return [];
56
  };
57
 
 
 
 
 
 
 
 
58
  const allSkills = [...new Set([
59
+ ...parseSkills(displayData.skills),
60
+ ...parseSkills(displayData.technical_skills)
 
 
61
  ])];
62
 
 
63
  const fullCandidate = {
64
+ ...displayData,
65
+ name: displayData.full_name || displayData.name || 'No Name Provided',
66
+ role: displayData.current_position || displayData.role || 'Applicant',
67
+ email: displayData.email || 'No email available',
68
+ phone: displayData.phone || 'No phone provided',
69
+ location: displayData.location || 'Remote',
70
+ avatar: displayData.avatar_url || displayData.avatar || `https://ui-avatars.com/api/?name=${encodeURIComponent(displayData.full_name || 'User')}`,
71
+ about: displayData.summary || displayData.headline || "No summary available.",
 
 
 
 
 
 
72
  skills: allSkills,
73
+ education: Array.isArray(displayData.education) ? displayData.education : [],
74
+ experience_details: Array.isArray(displayData.work_experience) ? displayData.work_experience : [],
75
+ projects: Array.isArray(displayData.projects) ? displayData.projects : []
 
 
 
 
 
 
 
 
 
 
76
  };
77
 
78
  return (