Spaces:
Sleeping
Sleeping
team99tech commited on
Commit ·
de21b45
1
Parent(s): 26714b4
added minor1 changes2
Browse files- backend/agents/__pycache__/interviewer.cpython-312.pyc +0 -0
- backend/agents/__pycache__/roadmap_builder.cpython-312.pyc +0 -0
- backend/agents/interviewer.py +2 -2
- backend/agents/roadmap_builder.py +71 -0
- backend/output/__pycache__/roadmap_generator.cpython-312.pyc +0 -0
- backend/output/roadmap_generator.py +29 -52
- backend/routers/__pycache__/roadmap.cpython-312.pyc +0 -0
- backend/routers/roadmap.py +2 -1
- frontend/app/candidate/job/[id]/page.tsx +0 -140
- frontend/app/candidate/page.tsx +0 -145
- frontend/app/employer/job/[id]/page.tsx +0 -121
- frontend/app/employer/page.tsx +0 -222
- frontend/app/login/page.tsx +0 -148
- frontend/app/page.tsx +71 -27
- frontend/app/results/[id]/page.tsx +15 -78
backend/agents/__pycache__/interviewer.cpython-312.pyc
CHANGED
|
Binary files a/backend/agents/__pycache__/interviewer.cpython-312.pyc and b/backend/agents/__pycache__/interviewer.cpython-312.pyc differ
|
|
|
backend/agents/__pycache__/roadmap_builder.cpython-312.pyc
ADDED
|
Binary file (3.15 kB). View file
|
|
|
backend/agents/interviewer.py
CHANGED
|
@@ -31,9 +31,9 @@ def get_difficulty(question_number: int, current_score: float) -> str:
|
|
| 31 |
if question_number == 1:
|
| 32 |
return "conceptual"
|
| 33 |
if question_number == 2:
|
| 34 |
-
return "applied"
|
| 35 |
if question_number >= 3:
|
| 36 |
-
return "
|
| 37 |
return "conceptual"
|
| 38 |
|
| 39 |
def generate_question(
|
|
|
|
| 31 |
if question_number == 1:
|
| 32 |
return "conceptual"
|
| 33 |
if question_number == 2:
|
| 34 |
+
return "applied"
|
| 35 |
if question_number >= 3:
|
| 36 |
+
return "debugging"
|
| 37 |
return "conceptual"
|
| 38 |
|
| 39 |
def generate_question(
|
backend/agents/roadmap_builder.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
from langchain_groq import ChatGroq
|
| 4 |
+
from langchain_core.messages import SystemMessage, HumanMessage
|
| 5 |
+
from models.schemas import SkillScore
|
| 6 |
+
|
| 7 |
+
GROQ_MODEL = "llama-3.3-70b-versatile"
|
| 8 |
+
|
| 9 |
+
ROADMAP_SYSTEM = """You are a senior technical mentor.
|
| 10 |
+
Given a list of skills where a candidate has a gap, and their target domain, generate a personalized 3-tier roadmap for EACH skill.
|
| 11 |
+
|
| 12 |
+
Tier 1: Core concepts and fundamentals
|
| 13 |
+
Tier 2: Intermediate integration and application
|
| 14 |
+
Tier 3: Advanced / Role-specific scenario
|
| 15 |
+
|
| 16 |
+
For each tier, provide a highly specific, customized 'mini_project' description (2-3 sentences) that is directly relevant to their domain. DO NOT use generic phrases.
|
| 17 |
+
Also provide a 1-sentence 'why' explaining why this step is critical for overcoming their specific gap.
|
| 18 |
+
|
| 19 |
+
Return valid JSON in this exact format:
|
| 20 |
+
{
|
| 21 |
+
"skill_id_1": [
|
| 22 |
+
{
|
| 23 |
+
"tier": 1,
|
| 24 |
+
"mini_project": "Build a simple API using FastAPI and Pydantic to validate user inputs.",
|
| 25 |
+
"why": "This establishes the fundamental routing and validation concepts."
|
| 26 |
+
},
|
| 27 |
+
{
|
| 28 |
+
"tier": 2,
|
| 29 |
+
"mini_project": "...",
|
| 30 |
+
"why": "..."
|
| 31 |
+
},
|
| 32 |
+
{
|
| 33 |
+
"tier": 3,
|
| 34 |
+
"mini_project": "...",
|
| 35 |
+
"why": "..."
|
| 36 |
+
}
|
| 37 |
+
]
|
| 38 |
+
}
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
def generate_custom_roadmap_data(skill_scores: list[SkillScore], domain: str) -> dict:
|
| 42 |
+
gap_skills = [s for s in skill_scores if s.gap_level in ("high_gap", "medium_gap")]
|
| 43 |
+
if not gap_skills:
|
| 44 |
+
return {}
|
| 45 |
+
|
| 46 |
+
prompt = f"Domain: {domain}\nGap Skills to analyze:\n"
|
| 47 |
+
for s in gap_skills:
|
| 48 |
+
prompt += f"- ID: {s.skill_id} | Label: {s.label} | Gap: {s.gap_level} | Final Score: {s.final_score:.2f}\n"
|
| 49 |
+
|
| 50 |
+
llm = ChatGroq(
|
| 51 |
+
model=GROQ_MODEL,
|
| 52 |
+
api_key=os.getenv("GROQ_API_KEY", "dummy"),
|
| 53 |
+
temperature=0.4,
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
messages = [
|
| 57 |
+
SystemMessage(content=ROADMAP_SYSTEM),
|
| 58 |
+
HumanMessage(content=prompt)
|
| 59 |
+
]
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
response = llm.invoke(messages)
|
| 63 |
+
content = response.content.strip()
|
| 64 |
+
if content.startswith("```json"):
|
| 65 |
+
content = content[7:]
|
| 66 |
+
if content.endswith("```"):
|
| 67 |
+
content = content[:-3]
|
| 68 |
+
return json.loads(content)
|
| 69 |
+
except Exception as e:
|
| 70 |
+
print("Roadmap builder error:", e)
|
| 71 |
+
return {}
|
backend/output/__pycache__/roadmap_generator.cpython-312.pyc
CHANGED
|
Binary files a/backend/output/__pycache__/roadmap_generator.cpython-312.pyc and b/backend/output/__pycache__/roadmap_generator.cpython-312.pyc differ
|
|
|
backend/output/roadmap_generator.py
CHANGED
|
@@ -2,11 +2,13 @@ import json
|
|
| 2 |
from pathlib import Path
|
| 3 |
from models.schemas import RoadmapWeek, SkillScore
|
| 4 |
from knowledge_graph.graph_engine import get_engine
|
|
|
|
| 5 |
|
| 6 |
def generate_roadmap(
|
| 7 |
skill_scores: list[SkillScore],
|
| 8 |
graph_paths: dict[str, list[str]],
|
| 9 |
hours_per_day: float = 2.0,
|
|
|
|
| 10 |
) -> list[RoadmapWeek]:
|
| 11 |
engine = get_engine()
|
| 12 |
resources_path = Path("backend/knowledge_graph/resources.json")
|
|
@@ -20,6 +22,9 @@ def generate_roadmap(
|
|
| 20 |
base_hours_per_week = hours_per_day * 5
|
| 21 |
week_counter = 1
|
| 22 |
|
|
|
|
|
|
|
|
|
|
| 23 |
# Sort skills: high_gap first, then medium_gap
|
| 24 |
gap_skills = [s for s in skill_scores if s.gap_level in ("high_gap", "medium_gap")]
|
| 25 |
gap_skills.sort(key=lambda s: 0 if s.gap_level == "high_gap" else 1)
|
|
@@ -27,63 +32,35 @@ def generate_roadmap(
|
|
| 27 |
for skill in gap_skills:
|
| 28 |
skill_id = skill.skill_id
|
| 29 |
path = graph_paths.get(skill_id, [])
|
| 30 |
-
path_str = " → ".join(path)
|
| 31 |
|
| 32 |
resources = []
|
| 33 |
if skill_id in resources_db:
|
| 34 |
resources = resources_db[skill_id].get("courses", [])
|
| 35 |
|
| 36 |
-
|
| 37 |
-
if len(path) > 1:
|
| 38 |
-
source = path[0]
|
| 39 |
-
target = path[-1]
|
| 40 |
-
hops = len(path) - 1
|
| 41 |
-
has_edges = True if engine.graph.has_node(source) and engine.graph.has_node(target) else False
|
| 42 |
-
if has_edges:
|
| 43 |
-
edge_type = "path"
|
| 44 |
-
try:
|
| 45 |
-
if hops == 1:
|
| 46 |
-
edge_type = engine.graph[source][target]["type"]
|
| 47 |
-
except:
|
| 48 |
-
pass
|
| 49 |
-
why_msg = f"You already know {source} → {target} is {edge_type} ({hops} hop)"
|
| 50 |
-
else:
|
| 51 |
-
why_msg = f"Building up from {source} to {target}"
|
| 52 |
-
|
| 53 |
-
roadmap.append(RoadmapWeek(
|
| 54 |
-
week=week_counter,
|
| 55 |
-
skill_id=skill_id,
|
| 56 |
-
label=skill.label,
|
| 57 |
-
tier=1,
|
| 58 |
-
resources=resources,
|
| 59 |
-
mini_project=f"Build a basic script using {skill.label} core features",
|
| 60 |
-
graph_path=path,
|
| 61 |
-
why=why_msg
|
| 62 |
-
))
|
| 63 |
-
week_counter += 1
|
| 64 |
-
|
| 65 |
-
roadmap.append(RoadmapWeek(
|
| 66 |
-
week=week_counter,
|
| 67 |
-
skill_id=skill_id,
|
| 68 |
-
label=skill.label,
|
| 69 |
-
tier=2,
|
| 70 |
-
resources=[],
|
| 71 |
-
mini_project=f"Integrate {skill.label} into a broader project",
|
| 72 |
-
graph_path=path,
|
| 73 |
-
why=why_msg
|
| 74 |
-
))
|
| 75 |
-
week_counter += 1
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
|
| 89 |
return roadmap
|
|
|
|
| 2 |
from pathlib import Path
|
| 3 |
from models.schemas import RoadmapWeek, SkillScore
|
| 4 |
from knowledge_graph.graph_engine import get_engine
|
| 5 |
+
from agents.roadmap_builder import generate_custom_roadmap_data
|
| 6 |
|
| 7 |
def generate_roadmap(
|
| 8 |
skill_scores: list[SkillScore],
|
| 9 |
graph_paths: dict[str, list[str]],
|
| 10 |
hours_per_day: float = 2.0,
|
| 11 |
+
domain: str = "general",
|
| 12 |
) -> list[RoadmapWeek]:
|
| 13 |
engine = get_engine()
|
| 14 |
resources_path = Path("backend/knowledge_graph/resources.json")
|
|
|
|
| 22 |
base_hours_per_week = hours_per_day * 5
|
| 23 |
week_counter = 1
|
| 24 |
|
| 25 |
+
# 1. Fetch custom LLM analysis
|
| 26 |
+
custom_data = generate_custom_roadmap_data(skill_scores, domain)
|
| 27 |
+
|
| 28 |
# Sort skills: high_gap first, then medium_gap
|
| 29 |
gap_skills = [s for s in skill_scores if s.gap_level in ("high_gap", "medium_gap")]
|
| 30 |
gap_skills.sort(key=lambda s: 0 if s.gap_level == "high_gap" else 1)
|
|
|
|
| 32 |
for skill in gap_skills:
|
| 33 |
skill_id = skill.skill_id
|
| 34 |
path = graph_paths.get(skill_id, [])
|
|
|
|
| 35 |
|
| 36 |
resources = []
|
| 37 |
if skill_id in resources_db:
|
| 38 |
resources = resources_db[skill_id].get("courses", [])
|
| 39 |
|
| 40 |
+
custom_tiers = custom_data.get(skill_id, [])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
|
| 42 |
+
for tier in [1, 2, 3]:
|
| 43 |
+
# Default values
|
| 44 |
+
mini_project = f"Practice and implement {skill.label} concepts."
|
| 45 |
+
why_msg = f"Essential for mastering {skill.label}."
|
| 46 |
+
|
| 47 |
+
# Use LLM generated data if available
|
| 48 |
+
for ct in custom_tiers:
|
| 49 |
+
if ct.get("tier") == tier:
|
| 50 |
+
mini_project = ct.get("mini_project", mini_project)
|
| 51 |
+
why_msg = ct.get("why", why_msg)
|
| 52 |
+
break
|
| 53 |
+
|
| 54 |
+
roadmap.append(RoadmapWeek(
|
| 55 |
+
week=week_counter,
|
| 56 |
+
skill_id=skill_id,
|
| 57 |
+
label=skill.label,
|
| 58 |
+
tier=tier,
|
| 59 |
+
resources=resources if tier == 1 else [], # Only show resources in Tier 1
|
| 60 |
+
mini_project=mini_project,
|
| 61 |
+
graph_path=path,
|
| 62 |
+
why=why_msg
|
| 63 |
+
))
|
| 64 |
+
week_counter += 1
|
| 65 |
|
| 66 |
return roadmap
|
backend/routers/__pycache__/roadmap.cpython-312.pyc
CHANGED
|
Binary files a/backend/routers/__pycache__/roadmap.cpython-312.pyc and b/backend/routers/__pycache__/roadmap.cpython-312.pyc differ
|
|
|
backend/routers/roadmap.py
CHANGED
|
@@ -21,7 +21,8 @@ async def get_roadmap(assessment_id: str, hours_per_day: float = 2.0):
|
|
| 21 |
roadmap = generate_roadmap(
|
| 22 |
st.get("skill_scores", []),
|
| 23 |
st.get("graph_paths", {}),
|
| 24 |
-
hours_per_day
|
|
|
|
| 25 |
)
|
| 26 |
|
| 27 |
return AssessmentResult(
|
|
|
|
| 21 |
roadmap = generate_roadmap(
|
| 22 |
st.get("skill_scores", []),
|
| 23 |
st.get("graph_paths", {}),
|
| 24 |
+
hours_per_day,
|
| 25 |
+
st["extraction"].domain
|
| 26 |
)
|
| 27 |
|
| 28 |
return AssessmentResult(
|
frontend/app/candidate/job/[id]/page.tsx
DELETED
|
@@ -1,140 +0,0 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useEffect, useState } from "react"
|
| 3 |
-
import { useParams, useRouter } from "next/navigation"
|
| 4 |
-
import { createClient } from "@/utils/supabase/client"
|
| 5 |
-
import { useStore } from "@/lib/store"
|
| 6 |
-
import { startAssessment } from "@/lib/api"
|
| 7 |
-
import UploadZone from "@/components/upload/UploadZone"
|
| 8 |
-
|
| 9 |
-
export default function CandidateJobPage() {
|
| 10 |
-
const params = useParams()
|
| 11 |
-
const id = typeof params.id === "string" ? params.id : null
|
| 12 |
-
const router = useRouter()
|
| 13 |
-
const supabase = createClient()
|
| 14 |
-
|
| 15 |
-
const { hoursPerDay, setHoursPerDay, setJdText, resumeText, setResumeText, setAssessmentId, setExtraction } = useStore()
|
| 16 |
-
|
| 17 |
-
const [job, setJob] = useState<any>(null)
|
| 18 |
-
const [loading, setLoading] = useState(true)
|
| 19 |
-
const [starting, setStarting] = useState(false)
|
| 20 |
-
const [error, setError] = useState("")
|
| 21 |
-
|
| 22 |
-
useEffect(() => {
|
| 23 |
-
if (!id) return
|
| 24 |
-
fetchJob()
|
| 25 |
-
}, [id])
|
| 26 |
-
|
| 27 |
-
const fetchJob = async () => {
|
| 28 |
-
const { data: { user } } = await supabase.auth.getUser()
|
| 29 |
-
if (!user) {
|
| 30 |
-
router.push('/login')
|
| 31 |
-
return
|
| 32 |
-
}
|
| 33 |
-
|
| 34 |
-
const { data } = await supabase.from('jobs').select('*, profiles(email, full_name)').eq('id', id).single()
|
| 35 |
-
if (data) {
|
| 36 |
-
setJob(data)
|
| 37 |
-
setJdText(data.jd_text)
|
| 38 |
-
}
|
| 39 |
-
setLoading(false)
|
| 40 |
-
}
|
| 41 |
-
|
| 42 |
-
const handleBegin = async () => {
|
| 43 |
-
if (!job || !resumeText) return
|
| 44 |
-
setStarting(true)
|
| 45 |
-
setError("")
|
| 46 |
-
|
| 47 |
-
try {
|
| 48 |
-
const { data: { user } } = await supabase.auth.getUser()
|
| 49 |
-
if (!user) throw new Error("Not authenticated")
|
| 50 |
-
|
| 51 |
-
// 1. Create Assessment Row in Supabase
|
| 52 |
-
const { data: assessment, error: dbError } = await supabase.from('assessments').insert({
|
| 53 |
-
job_id: job.id,
|
| 54 |
-
candidate_id: user.id,
|
| 55 |
-
resume_text: resumeText,
|
| 56 |
-
status: 'in_progress'
|
| 57 |
-
}).select().single()
|
| 58 |
-
|
| 59 |
-
if (dbError) throw dbError
|
| 60 |
-
|
| 61 |
-
// 2. Start Python Backend Assessment with that same Supabase ID
|
| 62 |
-
const res = await startAssessment(job.jd_text, resumeText, hoursPerDay, assessment.id)
|
| 63 |
-
|
| 64 |
-
// 3. Save to Zustand store and navigate
|
| 65 |
-
setAssessmentId(assessment.id)
|
| 66 |
-
setExtraction(res.extraction)
|
| 67 |
-
router.push(`/assess/${assessment.id}`)
|
| 68 |
-
|
| 69 |
-
} catch (err: any) {
|
| 70 |
-
setError(err.message)
|
| 71 |
-
setStarting(false)
|
| 72 |
-
}
|
| 73 |
-
}
|
| 74 |
-
|
| 75 |
-
if (loading || !job) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Job...</div>
|
| 76 |
-
|
| 77 |
-
return (
|
| 78 |
-
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 79 |
-
<div className="max-w-4xl mx-auto space-y-8">
|
| 80 |
-
<button onClick={() => router.push('/candidate')} className="text-sm font-mono text-muted hover:text-accent transition-colors">
|
| 81 |
-
← Back to Portal
|
| 82 |
-
</button>
|
| 83 |
-
|
| 84 |
-
<header className="border-b border-border pb-6">
|
| 85 |
-
<h1 className="text-3xl font-bold font-mono tracking-tighter mb-2 text-accent">{job.title}</h1>
|
| 86 |
-
<p className="text-muted font-mono text-sm">Posted by: {job.profiles?.full_name || job.profiles?.email}</p>
|
| 87 |
-
</header>
|
| 88 |
-
|
| 89 |
-
{/* Job Description View */}
|
| 90 |
-
<section className="bg-surface border border-border shadow-sm p-8 rounded-3xl space-y-4 shadow-xl hover:shadow-[0_0_30px_rgba(0,240,255,0.05)] transition-shadow duration-500">
|
| 91 |
-
<h2 className="text-xl font-mono text-accent flex items-center">
|
| 92 |
-
<span className="w-8 h-px bg-accent/50 mr-4"></span>
|
| 93 |
-
Job Description
|
| 94 |
-
</h2>
|
| 95 |
-
<div className="bg-background/80 border border-border rounded-xl p-6 max-h-[300px] overflow-y-auto font-mono text-sm text-muted whitespace-pre-wrap leading-relaxed">
|
| 96 |
-
{job.jd_text}
|
| 97 |
-
</div>
|
| 98 |
-
</section>
|
| 99 |
-
|
| 100 |
-
<div className="bg-surface border border-border shadow-sm p-8 rounded-3xl space-y-8 shadow-xl">
|
| 101 |
-
<div>
|
| 102 |
-
<h2 className="text-xl font-mono text-text mb-4 flex items-center">
|
| 103 |
-
<span className="w-8 h-px bg-accent/50 mr-4"></span>
|
| 104 |
-
Apply & Test
|
| 105 |
-
</h2>
|
| 106 |
-
<p className="text-muted text-sm mb-6">
|
| 107 |
-
Upload your resume below to begin. Our AI will instantly evaluate your resume against this job description and conduct a dynamic technical interview.
|
| 108 |
-
</p>
|
| 109 |
-
|
| 110 |
-
<div className="max-w-lg">
|
| 111 |
-
<UploadZone label="Your Candidate Resume (PDF)" endpoint="resume" onUpload={setResumeText} />
|
| 112 |
-
</div>
|
| 113 |
-
</div>
|
| 114 |
-
|
| 115 |
-
<div className="max-w-lg bg-background/50 border border-border p-5 rounded-xl">
|
| 116 |
-
<label className="flex justify-between text-sm font-mono text-muted mb-4">
|
| 117 |
-
<span>Availability (hours/day)</span>
|
| 118 |
-
<span className="text-accent font-bold">{hoursPerDay}h</span>
|
| 119 |
-
</label>
|
| 120 |
-
<input
|
| 121 |
-
type="range" min="1" max="8" step="0.5"
|
| 122 |
-
value={hoursPerDay} onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
| 123 |
-
className="w-full h-1.5 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-accent transition-all hover:h-2"
|
| 124 |
-
/>
|
| 125 |
-
</div>
|
| 126 |
-
|
| 127 |
-
{error && <div className="text-red-500 font-mono text-sm">{error}</div>}
|
| 128 |
-
|
| 129 |
-
<button
|
| 130 |
-
onClick={handleBegin}
|
| 131 |
-
disabled={!resumeText || starting}
|
| 132 |
-
className="bg-accent text-white px-8 py-4 rounded-xl font-mono font-bold text-lg hover:scale-[1.02] shadow-[0_4px_14px_0_rgb(37,87,167,0.39)] disabled:opacity-50 disabled:hover:scale-100 transition-all"
|
| 133 |
-
>
|
| 134 |
-
{starting ? "Initializing Engine..." : "Begin AI Interview →"}
|
| 135 |
-
</button>
|
| 136 |
-
</div>
|
| 137 |
-
</div>
|
| 138 |
-
</div>
|
| 139 |
-
)
|
| 140 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/app/candidate/page.tsx
DELETED
|
@@ -1,145 +0,0 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useEffect, useState } from "react"
|
| 3 |
-
import { useRouter } from "next/navigation"
|
| 4 |
-
import { createClient } from "@/utils/supabase/client"
|
| 5 |
-
|
| 6 |
-
export default function CandidateDashboard() {
|
| 7 |
-
const router = useRouter()
|
| 8 |
-
const supabase = createClient()
|
| 9 |
-
const [jobs, setJobs] = useState<any[]>([])
|
| 10 |
-
const [loading, setLoading] = useState(true)
|
| 11 |
-
const [activeTab, setActiveTab] = useState<'opportunities' | 'profile'>('opportunities')
|
| 12 |
-
const [profileData, setProfileData] = useState<any>(null)
|
| 13 |
-
const [fullName, setFullName] = useState("")
|
| 14 |
-
const [saving, setSaving] = useState(false)
|
| 15 |
-
|
| 16 |
-
useEffect(() => {
|
| 17 |
-
fetchJobs()
|
| 18 |
-
}, [])
|
| 19 |
-
|
| 20 |
-
const fetchJobs = async () => {
|
| 21 |
-
const { data: { user } } = await supabase.auth.getUser()
|
| 22 |
-
if (!user) {
|
| 23 |
-
router.push('/login')
|
| 24 |
-
return
|
| 25 |
-
}
|
| 26 |
-
|
| 27 |
-
const { data: profile, error } = await supabase.from('profiles').select('*').eq('id', user.id).single()
|
| 28 |
-
|
| 29 |
-
if (error || !profile) {
|
| 30 |
-
await supabase.auth.signOut()
|
| 31 |
-
router.push('/login')
|
| 32 |
-
return
|
| 33 |
-
}
|
| 34 |
-
|
| 35 |
-
if (profile.role !== 'candidate') {
|
| 36 |
-
router.push('/employer')
|
| 37 |
-
return
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
setProfileData(profile)
|
| 41 |
-
setFullName(profile.full_name || "")
|
| 42 |
-
|
| 43 |
-
// Fetch all jobs
|
| 44 |
-
const { data } = await supabase.from('jobs').select('*, profiles(email, full_name)').order('created_at', { ascending: false })
|
| 45 |
-
if (data) setJobs(data)
|
| 46 |
-
setLoading(false)
|
| 47 |
-
}
|
| 48 |
-
|
| 49 |
-
const saveProfile = async () => {
|
| 50 |
-
if (!profileData) return
|
| 51 |
-
setSaving(true)
|
| 52 |
-
const { error } = await supabase.from('profiles').update({ full_name: fullName }).eq('id', profileData.id)
|
| 53 |
-
if (error) {
|
| 54 |
-
alert("Error saving profile. Make sure to run the SQL command to add the full_name column!")
|
| 55 |
-
} else {
|
| 56 |
-
setProfileData({ ...profileData, full_name: fullName })
|
| 57 |
-
alert("Profile updated successfully!")
|
| 58 |
-
}
|
| 59 |
-
setSaving(false)
|
| 60 |
-
}
|
| 61 |
-
|
| 62 |
-
const handleLogout = async () => {
|
| 63 |
-
await supabase.auth.signOut()
|
| 64 |
-
router.push('/')
|
| 65 |
-
}
|
| 66 |
-
|
| 67 |
-
if (loading) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Opportunities...</div>
|
| 68 |
-
|
| 69 |
-
return (
|
| 70 |
-
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 71 |
-
<div className="max-w-4xl mx-auto space-y-12">
|
| 72 |
-
<header className="flex flex-col gap-6 border-b border-border pb-6">
|
| 73 |
-
<div className="flex justify-between items-end">
|
| 74 |
-
<div>
|
| 75 |
-
<h1 className="text-4xl font-bold font-mono tracking-tighter mb-2">Candidate <span className="text-accent">Portal</span></h1>
|
| 76 |
-
<p className="text-muted font-mono">Discover roles and prove your skills</p>
|
| 77 |
-
</div>
|
| 78 |
-
<button onClick={handleLogout} className="text-sm font-mono text-muted hover:text-red-400 transition-colors">Log Out</button>
|
| 79 |
-
</div>
|
| 80 |
-
<div className="flex gap-4">
|
| 81 |
-
<button onClick={() => setActiveTab('opportunities')} className={`font-mono pb-2 border-b-2 transition-colors ${activeTab === 'opportunities' ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'}`}>Opportunities</button>
|
| 82 |
-
<button onClick={() => setActiveTab('profile')} className={`font-mono pb-2 border-b-2 transition-colors ${activeTab === 'profile' ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'}`}>My Profile</button>
|
| 83 |
-
</div>
|
| 84 |
-
</header>
|
| 85 |
-
|
| 86 |
-
{activeTab === 'opportunities' ? (
|
| 87 |
-
<section className="space-y-6">
|
| 88 |
-
<h2 className="text-2xl font-mono text-text">Open Positions</h2>
|
| 89 |
-
|
| 90 |
-
{jobs.length === 0 ? (
|
| 91 |
-
<div className="border border-dashed border-border rounded-2xl p-12 text-center text-muted font-mono">
|
| 92 |
-
No open positions at the moment. Check back soon!
|
| 93 |
-
</div>
|
| 94 |
-
) : (
|
| 95 |
-
<div className="grid grid-cols-1 gap-4">
|
| 96 |
-
{jobs.map(job => (
|
| 97 |
-
<div key={job.id} className="bg-surface border border-border shadow-sm p-6 rounded-2xl flex flex-col md:flex-row justify-between items-start md:items-center gap-4 hover:border-accent/50 transition-colors">
|
| 98 |
-
<div>
|
| 99 |
-
<h3 className="text-xl font-mono text-accent mb-2">{job.title}</h3>
|
| 100 |
-
<p className="text-xs font-mono text-muted">Posted by: {job.profiles?.full_name || job.profiles?.email}</p>
|
| 101 |
-
<p className="text-xs font-mono text-muted mt-1">{new Date(job.created_at).toLocaleDateString()}</p>
|
| 102 |
-
</div>
|
| 103 |
-
<button
|
| 104 |
-
onClick={() => router.push(`/candidate/job/${job.id}`)}
|
| 105 |
-
className="bg-accent/10 text-accent border border-accent/30 px-6 py-2 rounded-lg font-mono font-bold hover:bg-accent hover:text-background transition-all"
|
| 106 |
-
>
|
| 107 |
-
Apply & Assess →
|
| 108 |
-
</button>
|
| 109 |
-
</div>
|
| 110 |
-
))}
|
| 111 |
-
</div>
|
| 112 |
-
)}
|
| 113 |
-
</section>
|
| 114 |
-
) : (
|
| 115 |
-
<section className="bg-surface border border-border p-8 rounded-3xl max-w-lg">
|
| 116 |
-
<h2 className="text-2xl font-mono text-text mb-6">Profile Settings</h2>
|
| 117 |
-
<div className="space-y-6">
|
| 118 |
-
<div>
|
| 119 |
-
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Account Email</label>
|
| 120 |
-
<input type="text" disabled value={profileData?.email || ""} className="w-full bg-background border border-border rounded-xl p-3 text-sm opacity-50 cursor-not-allowed" />
|
| 121 |
-
</div>
|
| 122 |
-
<div>
|
| 123 |
-
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Full Name</label>
|
| 124 |
-
<input
|
| 125 |
-
type="text"
|
| 126 |
-
value={fullName}
|
| 127 |
-
onChange={(e) => setFullName(e.target.value)}
|
| 128 |
-
className="w-full bg-background border border-border rounded-xl p-3 text-sm focus:outline-none focus:border-accent"
|
| 129 |
-
placeholder="John Doe"
|
| 130 |
-
/>
|
| 131 |
-
</div>
|
| 132 |
-
<button
|
| 133 |
-
onClick={saveProfile}
|
| 134 |
-
disabled={saving}
|
| 135 |
-
className="w-full bg-accent text-white px-6 py-3 rounded-xl font-mono font-bold hover:scale-[1.02] shadow-md transition-all disabled:opacity-50"
|
| 136 |
-
>
|
| 137 |
-
{saving ? "Saving..." : "Save Profile"}
|
| 138 |
-
</button>
|
| 139 |
-
</div>
|
| 140 |
-
</section>
|
| 141 |
-
)}
|
| 142 |
-
</div>
|
| 143 |
-
</div>
|
| 144 |
-
)
|
| 145 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/app/employer/job/[id]/page.tsx
DELETED
|
@@ -1,121 +0,0 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useEffect, useState } from "react"
|
| 3 |
-
import { useParams, useRouter } from "next/navigation"
|
| 4 |
-
import { createClient } from "@/utils/supabase/client"
|
| 5 |
-
|
| 6 |
-
export default function EmployerJobPage() {
|
| 7 |
-
const params = useParams()
|
| 8 |
-
const id = typeof params.id === "string" ? params.id : null
|
| 9 |
-
const router = useRouter()
|
| 10 |
-
const supabase = createClient()
|
| 11 |
-
|
| 12 |
-
const [job, setJob] = useState<any>(null)
|
| 13 |
-
const [assessments, setAssessments] = useState<any[]>([])
|
| 14 |
-
const [loading, setLoading] = useState(true)
|
| 15 |
-
|
| 16 |
-
useEffect(() => {
|
| 17 |
-
if (!id) return
|
| 18 |
-
fetchJobAndCandidates()
|
| 19 |
-
}, [id])
|
| 20 |
-
|
| 21 |
-
const fetchJobAndCandidates = async () => {
|
| 22 |
-
const { data: { user } } = await supabase.auth.getUser()
|
| 23 |
-
if (!user) {
|
| 24 |
-
router.push('/login')
|
| 25 |
-
return
|
| 26 |
-
}
|
| 27 |
-
|
| 28 |
-
// Fetch job details
|
| 29 |
-
const { data: jobData } = await supabase.from('jobs').select('*').eq('id', id).single()
|
| 30 |
-
if (jobData) setJob(jobData)
|
| 31 |
-
|
| 32 |
-
// Fetch assessments for this job
|
| 33 |
-
const { data: assessmentData } = await supabase.from('assessments')
|
| 34 |
-
.select('id, status, created_at, result_data, profiles(email, full_name)')
|
| 35 |
-
.eq('job_id', id)
|
| 36 |
-
|
| 37 |
-
if (assessmentData) {
|
| 38 |
-
const mapped = assessmentData.map((a: any) => {
|
| 39 |
-
let score = 0;
|
| 40 |
-
if (a.result_data && a.result_data.skill_scores && a.result_data.skill_scores.length > 0) {
|
| 41 |
-
score = Math.round((a.result_data.skill_scores.reduce((sum: number, s: any) => sum + s.final_score, 0) / a.result_data.skill_scores.length) * 100)
|
| 42 |
-
}
|
| 43 |
-
return { ...a, score }
|
| 44 |
-
})
|
| 45 |
-
// Sort by score descending
|
| 46 |
-
mapped.sort((a, b) => b.score - a.score)
|
| 47 |
-
setAssessments(mapped)
|
| 48 |
-
}
|
| 49 |
-
|
| 50 |
-
setLoading(false)
|
| 51 |
-
}
|
| 52 |
-
|
| 53 |
-
if (loading || !job) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Job Details...</div>
|
| 54 |
-
|
| 55 |
-
return (
|
| 56 |
-
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 57 |
-
<div className="max-w-5xl mx-auto space-y-8">
|
| 58 |
-
<button onClick={() => router.push('/employer')} className="text-sm font-mono text-muted hover:text-accent transition-colors">
|
| 59 |
-
← Back to Dashboard
|
| 60 |
-
</button>
|
| 61 |
-
|
| 62 |
-
<header className="border-b border-border pb-6 flex justify-between items-end">
|
| 63 |
-
<div>
|
| 64 |
-
<h1 className="text-3xl font-bold font-mono tracking-tighter mb-2 text-accent">{job.title}</h1>
|
| 65 |
-
<p className="text-muted font-mono text-sm">Posted on {new Date(job.created_at).toLocaleDateString()}</p>
|
| 66 |
-
</div>
|
| 67 |
-
<div className="bg-surface/50 border border-border px-4 py-2 rounded-lg">
|
| 68 |
-
<span className="font-mono text-xl text-text font-bold">{assessments.length}</span>
|
| 69 |
-
<span className="text-muted text-xs uppercase ml-2">Candidates</span>
|
| 70 |
-
</div>
|
| 71 |
-
</header>
|
| 72 |
-
|
| 73 |
-
<section className="space-y-6">
|
| 74 |
-
<h2 className="text-2xl font-mono text-text">Candidate Assessments</h2>
|
| 75 |
-
|
| 76 |
-
{assessments.length === 0 ? (
|
| 77 |
-
<div className="border border-dashed border-border rounded-2xl p-12 text-center text-muted font-mono bg-surface/50">
|
| 78 |
-
No candidates have applied to this job yet.
|
| 79 |
-
</div>
|
| 80 |
-
) : (
|
| 81 |
-
<div className="grid grid-cols-1 gap-4">
|
| 82 |
-
{assessments.map((assessment) => (
|
| 83 |
-
<div key={assessment.id} className="bg-surface border border-border shadow-sm p-6 rounded-2xl flex flex-col md:flex-row justify-between items-start md:items-center gap-4 hover:border-accent/50 transition-colors">
|
| 84 |
-
<div>
|
| 85 |
-
<h3 className="text-lg font-mono text-text mb-1">
|
| 86 |
-
{assessment.profiles?.full_name ? assessment.profiles.full_name : assessment.profiles?.email}
|
| 87 |
-
{assessment.profiles?.full_name && <span className="text-sm text-muted ml-2">({assessment.profiles?.email})</span>}
|
| 88 |
-
</h3>
|
| 89 |
-
<div className="flex items-center gap-3">
|
| 90 |
-
<span className={`text-xs font-mono px-2 py-1 rounded-md border ${
|
| 91 |
-
assessment.status === 'completed'
|
| 92 |
-
? 'border-green-500/50 text-green-400 bg-green-500/10'
|
| 93 |
-
: 'border-yellow-500/50 text-yellow-400 bg-yellow-500/10'
|
| 94 |
-
}`}>
|
| 95 |
-
{assessment.status === 'completed' ? 'COMPLETED' : 'IN PROGRESS'}
|
| 96 |
-
</span>
|
| 97 |
-
{assessment.status === 'completed' && (
|
| 98 |
-
<span className="text-xs font-mono px-2 py-1 rounded-md border border-accent/50 text-accent bg-accent/10">
|
| 99 |
-
SCORE: {assessment.score}/100
|
| 100 |
-
</span>
|
| 101 |
-
)}
|
| 102 |
-
<span className="text-xs text-muted font-mono">{new Date(assessment.created_at).toLocaleString()}</span>
|
| 103 |
-
</div>
|
| 104 |
-
</div>
|
| 105 |
-
|
| 106 |
-
<button
|
| 107 |
-
disabled={assessment.status !== 'completed'}
|
| 108 |
-
onClick={() => router.push(`/results/${assessment.id}`)}
|
| 109 |
-
className="bg-background border border-border text-text px-6 py-2 rounded-lg font-mono font-bold hover:border-accent hover:text-accent transition-all disabled:opacity-50 disabled:hover:border-border disabled:hover:text-text"
|
| 110 |
-
>
|
| 111 |
-
{assessment.status === 'completed' ? "View Results →" : "Awaiting Completion"}
|
| 112 |
-
</button>
|
| 113 |
-
</div>
|
| 114 |
-
))}
|
| 115 |
-
</div>
|
| 116 |
-
)}
|
| 117 |
-
</section>
|
| 118 |
-
</div>
|
| 119 |
-
</div>
|
| 120 |
-
)
|
| 121 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/app/employer/page.tsx
DELETED
|
@@ -1,222 +0,0 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useEffect, useState } from "react"
|
| 3 |
-
import { useRouter } from "next/navigation"
|
| 4 |
-
import { createClient } from "@/utils/supabase/client"
|
| 5 |
-
import UploadZone from "@/components/upload/UploadZone"
|
| 6 |
-
|
| 7 |
-
export default function EmployerDashboard() {
|
| 8 |
-
const router = useRouter()
|
| 9 |
-
const supabase = createClient()
|
| 10 |
-
const [jobs, setJobs] = useState<any[]>([])
|
| 11 |
-
const [loading, setLoading] = useState(true)
|
| 12 |
-
const [jdText, setJdText] = useState("")
|
| 13 |
-
const [jobTitle, setJobTitle] = useState("")
|
| 14 |
-
const [creating, setCreating] = useState(false)
|
| 15 |
-
const [activeTab, setActiveTab] = useState<'dashboard' | 'profile'>('dashboard')
|
| 16 |
-
const [profileData, setProfileData] = useState<any>(null)
|
| 17 |
-
const [companyName, setCompanyName] = useState("")
|
| 18 |
-
const [saving, setSaving] = useState(false)
|
| 19 |
-
|
| 20 |
-
useEffect(() => {
|
| 21 |
-
fetchJobs()
|
| 22 |
-
}, [])
|
| 23 |
-
|
| 24 |
-
const fetchJobs = async () => {
|
| 25 |
-
const { data: { user } } = await supabase.auth.getUser()
|
| 26 |
-
if (!user) {
|
| 27 |
-
router.push('/login')
|
| 28 |
-
return
|
| 29 |
-
}
|
| 30 |
-
|
| 31 |
-
const { data: profile, error } = await supabase.from('profiles').select('*').eq('id', user.id).single()
|
| 32 |
-
|
| 33 |
-
if (error || !profile) {
|
| 34 |
-
await supabase.auth.signOut()
|
| 35 |
-
router.push('/login')
|
| 36 |
-
return
|
| 37 |
-
}
|
| 38 |
-
|
| 39 |
-
if (profile.role !== 'employer') {
|
| 40 |
-
router.push('/candidate')
|
| 41 |
-
return
|
| 42 |
-
}
|
| 43 |
-
|
| 44 |
-
setProfileData(profile)
|
| 45 |
-
setCompanyName(profile.full_name || "")
|
| 46 |
-
|
| 47 |
-
const { data } = await supabase.from('jobs').select('*').order('created_at', { ascending: false })
|
| 48 |
-
if (data) setJobs(data)
|
| 49 |
-
setLoading(false)
|
| 50 |
-
}
|
| 51 |
-
|
| 52 |
-
const saveProfile = async () => {
|
| 53 |
-
if (!profileData) return
|
| 54 |
-
setSaving(true)
|
| 55 |
-
const { error } = await supabase.from('profiles').update({ full_name: companyName }).eq('id', profileData.id)
|
| 56 |
-
if (error) {
|
| 57 |
-
alert("Error saving profile. Make sure to run the SQL command to add the full_name column!")
|
| 58 |
-
} else {
|
| 59 |
-
setProfileData({ ...profileData, full_name: companyName })
|
| 60 |
-
alert("Company Profile updated successfully!")
|
| 61 |
-
}
|
| 62 |
-
setSaving(false)
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
const handleCreateJob = async () => {
|
| 66 |
-
if (!jobTitle || !jdText) return
|
| 67 |
-
setCreating(true)
|
| 68 |
-
|
| 69 |
-
const { data: { user } } = await supabase.auth.getUser()
|
| 70 |
-
if (!user) return
|
| 71 |
-
|
| 72 |
-
const { error } = await supabase.from('jobs').insert({
|
| 73 |
-
employer_id: user.id,
|
| 74 |
-
title: jobTitle,
|
| 75 |
-
jd_text: jdText
|
| 76 |
-
})
|
| 77 |
-
|
| 78 |
-
setCreating(false)
|
| 79 |
-
if (!error) {
|
| 80 |
-
setJobTitle("")
|
| 81 |
-
setJdText("")
|
| 82 |
-
fetchJobs()
|
| 83 |
-
}
|
| 84 |
-
}
|
| 85 |
-
|
| 86 |
-
const handleDeleteJob = async (e: React.MouseEvent, jobId: string) => {
|
| 87 |
-
e.stopPropagation()
|
| 88 |
-
if (!confirm("Are you sure you want to delete this job posting? All candidate assessments for this job will also be deleted permanently.")) return
|
| 89 |
-
|
| 90 |
-
const { error } = await supabase.from('jobs').delete().eq('id', jobId)
|
| 91 |
-
if (error) {
|
| 92 |
-
alert("Failed to delete job")
|
| 93 |
-
} else {
|
| 94 |
-
fetchJobs()
|
| 95 |
-
}
|
| 96 |
-
}
|
| 97 |
-
|
| 98 |
-
const handleLogout = async () => {
|
| 99 |
-
await supabase.auth.signOut()
|
| 100 |
-
router.push('/')
|
| 101 |
-
}
|
| 102 |
-
|
| 103 |
-
if (loading) return <div className="min-h-screen bg-bg flex items-center justify-center font-mono text-accent animate-pulse">Loading Dashboard...</div>
|
| 104 |
-
|
| 105 |
-
return (
|
| 106 |
-
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 107 |
-
<div className="max-w-6xl mx-auto space-y-12">
|
| 108 |
-
<header className="flex flex-col gap-6 border-b border-border pb-6">
|
| 109 |
-
<div className="flex justify-between items-end">
|
| 110 |
-
<div>
|
| 111 |
-
<h1 className="text-4xl font-bold font-mono tracking-tighter mb-2">Employer <span className="text-accent">Portal</span></h1>
|
| 112 |
-
<p className="text-muted font-mono">Manage jobs and review candidate assessments</p>
|
| 113 |
-
</div>
|
| 114 |
-
<button onClick={handleLogout} className="text-sm font-mono text-muted hover:text-red-400 transition-colors">Log Out</button>
|
| 115 |
-
</div>
|
| 116 |
-
<div className="flex gap-4">
|
| 117 |
-
<button onClick={() => setActiveTab('dashboard')} className={`font-mono pb-2 border-b-2 transition-colors ${activeTab === 'dashboard' ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'}`}>Dashboard</button>
|
| 118 |
-
<button onClick={() => setActiveTab('profile')} className={`font-mono pb-2 border-b-2 transition-colors ${activeTab === 'profile' ? 'border-accent text-accent' : 'border-transparent text-muted hover:text-text'}`}>Company Profile</button>
|
| 119 |
-
</div>
|
| 120 |
-
</header>
|
| 121 |
-
|
| 122 |
-
{activeTab === 'dashboard' ? (
|
| 123 |
-
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
| 124 |
-
<div className="lg:col-span-1 space-y-6">
|
| 125 |
-
<div className="bg-surface border border-border shadow-sm p-6 rounded-2xl">
|
| 126 |
-
<h2 className="text-xl font-mono text-accent mb-6">Post New Job</h2>
|
| 127 |
-
|
| 128 |
-
<div className="space-y-4">
|
| 129 |
-
<div>
|
| 130 |
-
<label className="block text-xs font-mono text-muted mb-2">Job Title</label>
|
| 131 |
-
<input
|
| 132 |
-
type="text"
|
| 133 |
-
value={jobTitle}
|
| 134 |
-
onChange={(e) => setJobTitle(e.target.value)}
|
| 135 |
-
className="w-full bg-background border border-border rounded-xl p-3 text-sm focus:border-accent outline-none"
|
| 136 |
-
placeholder="e.g. Senior Frontend Engineer"
|
| 137 |
-
/>
|
| 138 |
-
</div>
|
| 139 |
-
|
| 140 |
-
<div className="pt-2">
|
| 141 |
-
<UploadZone label="Job Description (PDF)" endpoint="jd" onUpload={setJdText} />
|
| 142 |
-
</div>
|
| 143 |
-
|
| 144 |
-
<button
|
| 145 |
-
onClick={handleCreateJob}
|
| 146 |
-
disabled={creating || !jobTitle || !jdText}
|
| 147 |
-
className="w-full bg-accent text-background font-bold font-mono py-3 rounded-xl hover:brightness-110 disabled:opacity-50 mt-4"
|
| 148 |
-
>
|
| 149 |
-
{creating ? "Posting..." : "Create Job"}
|
| 150 |
-
</button>
|
| 151 |
-
</div>
|
| 152 |
-
</div>
|
| 153 |
-
</div>
|
| 154 |
-
|
| 155 |
-
<div className="lg:col-span-2 space-y-6">
|
| 156 |
-
<h2 className="text-2xl font-mono text-text">Your Active Jobs</h2>
|
| 157 |
-
|
| 158 |
-
{jobs.length === 0 ? (
|
| 159 |
-
<div className="border border-dashed border-border rounded-2xl p-12 text-center text-muted font-mono">
|
| 160 |
-
No jobs posted yet. Post your first job to start receiving candidates!
|
| 161 |
-
</div>
|
| 162 |
-
) : (
|
| 163 |
-
<div className="space-y-4">
|
| 164 |
-
{jobs.map(job => (
|
| 165 |
-
<div key={job.id} className="bg-surface border border-border p-6 rounded-2xl hover:border-accent/50 transition-colors flex justify-between items-center group cursor-pointer" onClick={() => router.push(`/employer/job/${job.id}`)}>
|
| 166 |
-
<div>
|
| 167 |
-
<h3 className="text-lg font-mono text-accent mb-1">{job.title}</h3>
|
| 168 |
-
<p className="text-xs text-muted font-mono">Posted {new Date(job.created_at).toLocaleDateString()}</p>
|
| 169 |
-
</div>
|
| 170 |
-
<div className="flex items-center gap-4">
|
| 171 |
-
<div className="text-muted group-hover:text-accent group-hover:-translate-x-1 transition-all">
|
| 172 |
-
View Candidates →
|
| 173 |
-
</div>
|
| 174 |
-
<button
|
| 175 |
-
onClick={(e) => handleDeleteJob(e, job.id)}
|
| 176 |
-
className="text-muted hover:text-red-500 p-2 rounded-lg hover:bg-red-50 transition-all"
|
| 177 |
-
title="Delete Job"
|
| 178 |
-
>
|
| 179 |
-
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>
|
| 180 |
-
</button>
|
| 181 |
-
</div>
|
| 182 |
-
</div>
|
| 183 |
-
))}
|
| 184 |
-
</div>
|
| 185 |
-
)}
|
| 186 |
-
</div>
|
| 187 |
-
</div>
|
| 188 |
-
) : (
|
| 189 |
-
<section className="bg-surface border border-border shadow-sm p-8 rounded-3xl max-w-lg shadow-xl">
|
| 190 |
-
<h2 className="text-2xl font-mono text-accent mb-6 flex items-center">
|
| 191 |
-
<span className="w-8 h-px bg-accent/50 mr-4"></span>
|
| 192 |
-
Company Details
|
| 193 |
-
</h2>
|
| 194 |
-
<div className="space-y-6">
|
| 195 |
-
<div>
|
| 196 |
-
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Account Email</label>
|
| 197 |
-
<input type="text" disabled value={profileData?.email || ""} className="w-full bg-background border border-border rounded-xl p-3 text-sm opacity-50 cursor-not-allowed" />
|
| 198 |
-
</div>
|
| 199 |
-
<div>
|
| 200 |
-
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Company Name</label>
|
| 201 |
-
<input
|
| 202 |
-
type="text"
|
| 203 |
-
value={companyName}
|
| 204 |
-
onChange={(e) => setCompanyName(e.target.value)}
|
| 205 |
-
className="w-full bg-background border border-border rounded-xl p-3 text-sm focus:outline-none focus:border-accent text-text"
|
| 206 |
-
placeholder="Acme Corp"
|
| 207 |
-
/>
|
| 208 |
-
</div>
|
| 209 |
-
<button
|
| 210 |
-
onClick={saveProfile}
|
| 211 |
-
disabled={saving}
|
| 212 |
-
className="w-full bg-accent text-white px-6 py-3 rounded-xl font-mono font-bold hover:scale-[1.02] shadow-sm hover:shadow-md transition-all disabled:opacity-50"
|
| 213 |
-
>
|
| 214 |
-
{saving ? "Saving..." : "Save Company Profile"}
|
| 215 |
-
</button>
|
| 216 |
-
</div>
|
| 217 |
-
</section>
|
| 218 |
-
)}
|
| 219 |
-
</div>
|
| 220 |
-
</div>
|
| 221 |
-
)
|
| 222 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/app/login/page.tsx
DELETED
|
@@ -1,148 +0,0 @@
|
|
| 1 |
-
"use client"
|
| 2 |
-
import { useState } from "react"
|
| 3 |
-
import { useRouter } from "next/navigation"
|
| 4 |
-
import { createClient } from "@/utils/supabase/client"
|
| 5 |
-
|
| 6 |
-
export default function LoginPage() {
|
| 7 |
-
const router = useRouter()
|
| 8 |
-
const supabase = createClient()
|
| 9 |
-
|
| 10 |
-
const [isLogin, setIsLogin] = useState(true)
|
| 11 |
-
const [email, setEmail] = useState("")
|
| 12 |
-
const [password, setPassword] = useState("")
|
| 13 |
-
const [role, setRole] = useState("candidate")
|
| 14 |
-
const [loading, setLoading] = useState(false)
|
| 15 |
-
const [error, setError] = useState("")
|
| 16 |
-
|
| 17 |
-
const handleAuth = async (e: React.FormEvent) => {
|
| 18 |
-
e.preventDefault()
|
| 19 |
-
setLoading(true)
|
| 20 |
-
setError("")
|
| 21 |
-
|
| 22 |
-
// Strict check for allowed email domains
|
| 23 |
-
const allowedDomains = ["@gmail.com", "@yahoo.com", "@outlook.com", "@hotmail.com", "@icloud.com"]
|
| 24 |
-
const hasValidDomain = allowedDomains.some(domain => email.toLowerCase().endsWith(domain))
|
| 25 |
-
|
| 26 |
-
if (!hasValidDomain) {
|
| 27 |
-
setError("Please use a standard email provider (e.g., @gmail.com, @yahoo.com)")
|
| 28 |
-
setLoading(false)
|
| 29 |
-
return
|
| 30 |
-
}
|
| 31 |
-
|
| 32 |
-
if (isLogin) {
|
| 33 |
-
const { data, error } = await supabase.auth.signInWithPassword({ email, password })
|
| 34 |
-
if (error) {
|
| 35 |
-
setError(error.message)
|
| 36 |
-
setLoading(false)
|
| 37 |
-
return
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
// Fetch role
|
| 41 |
-
const { data: profile } = await supabase.from('profiles').select('role').eq('id', data.user.id).single()
|
| 42 |
-
if (profile?.role === 'employer') {
|
| 43 |
-
router.push('/employer')
|
| 44 |
-
} else {
|
| 45 |
-
router.push('/candidate')
|
| 46 |
-
}
|
| 47 |
-
} else {
|
| 48 |
-
const { data, error } = await supabase.auth.signUp({ email, password })
|
| 49 |
-
if (error) {
|
| 50 |
-
setError(error.message)
|
| 51 |
-
setLoading(false)
|
| 52 |
-
return
|
| 53 |
-
}
|
| 54 |
-
if (data.user) {
|
| 55 |
-
const { error: profileError } = await supabase.from('profiles').insert({
|
| 56 |
-
id: data.user.id,
|
| 57 |
-
email,
|
| 58 |
-
role
|
| 59 |
-
})
|
| 60 |
-
if (profileError) {
|
| 61 |
-
setError(profileError.message)
|
| 62 |
-
setLoading(false)
|
| 63 |
-
return
|
| 64 |
-
}
|
| 65 |
-
if (role === 'employer') {
|
| 66 |
-
router.push('/employer')
|
| 67 |
-
} else {
|
| 68 |
-
router.push('/candidate')
|
| 69 |
-
}
|
| 70 |
-
}
|
| 71 |
-
}
|
| 72 |
-
}
|
| 73 |
-
|
| 74 |
-
return (
|
| 75 |
-
<div className="min-h-screen relative bg-bg text-text flex items-center justify-center p-6 overflow-hidden">
|
| 76 |
-
<div className="relative z-10 max-w-md w-full bg-surface border border-border shadow-md p-8 rounded-3xl shadow-xl">
|
| 77 |
-
<h1 className="text-4xl font-bold font-mono text-center tracking-tighter mb-2">
|
| 78 |
-
SkillForge<span className="text-accent">.ai</span>
|
| 79 |
-
</h1>
|
| 80 |
-
<h2 className="text-lg text-center text-muted font-mono mb-8">{isLogin ? "Access your portal" : "Create your account"}</h2>
|
| 81 |
-
|
| 82 |
-
{error && <div className="bg-red-500/10 border border-red-500/50 text-red-500 p-4 rounded-xl mb-6 text-sm font-mono">{error}</div>}
|
| 83 |
-
|
| 84 |
-
<form onSubmit={handleAuth} className="space-y-5">
|
| 85 |
-
<div>
|
| 86 |
-
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Email Address</label>
|
| 87 |
-
<input
|
| 88 |
-
type="email"
|
| 89 |
-
required
|
| 90 |
-
value={email}
|
| 91 |
-
onChange={e => setEmail(e.target.value)}
|
| 92 |
-
className="w-full bg-background border border-border rounded-xl p-3 text-sm focus:outline-none focus:border-accent transition-colors"
|
| 93 |
-
placeholder="you@example.com"
|
| 94 |
-
/>
|
| 95 |
-
</div>
|
| 96 |
-
<div>
|
| 97 |
-
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">Password</label>
|
| 98 |
-
<input
|
| 99 |
-
type="password"
|
| 100 |
-
required
|
| 101 |
-
value={password}
|
| 102 |
-
onChange={e => setPassword(e.target.value)}
|
| 103 |
-
className="w-full bg-background border border-border rounded-xl p-3 text-sm focus:outline-none focus:border-accent transition-colors"
|
| 104 |
-
placeholder="••••••••"
|
| 105 |
-
/>
|
| 106 |
-
</div>
|
| 107 |
-
|
| 108 |
-
{!isLogin && (
|
| 109 |
-
<div>
|
| 110 |
-
<label className="block text-xs uppercase tracking-wider font-mono text-muted mb-2">I am a...</label>
|
| 111 |
-
<div className="flex gap-4">
|
| 112 |
-
<button
|
| 113 |
-
type="button"
|
| 114 |
-
onClick={() => setRole("candidate")}
|
| 115 |
-
className={`flex-1 p-3 rounded-xl border font-mono transition-all duration-300 ${role === "candidate" ? "border-accent bg-accent/10 text-accent shadow-sm" : "border-border bg-background text-muted hover:border-border"}`}
|
| 116 |
-
>
|
| 117 |
-
Candidate
|
| 118 |
-
</button>
|
| 119 |
-
<button
|
| 120 |
-
type="button"
|
| 121 |
-
onClick={() => setRole("employer")}
|
| 122 |
-
className={`flex-1 p-3 rounded-xl border font-mono transition-all duration-300 ${role === "employer" ? "border-accent bg-accent/10 text-accent shadow-sm" : "border-border bg-background text-muted hover:border-border"}`}
|
| 123 |
-
>
|
| 124 |
-
Employer
|
| 125 |
-
</button>
|
| 126 |
-
</div>
|
| 127 |
-
</div>
|
| 128 |
-
)}
|
| 129 |
-
|
| 130 |
-
<button
|
| 131 |
-
type="submit"
|
| 132 |
-
disabled={loading}
|
| 133 |
-
className="w-full bg-accent text-white font-bold font-mono py-4 rounded-xl hover:scale-[1.02] shadow-md disabled:opacity-50 disabled:hover:scale-100 transition-all mt-4"
|
| 134 |
-
>
|
| 135 |
-
{loading ? "Authenticating..." : (isLogin ? "Secure Login →" : "Create Account →")}
|
| 136 |
-
</button>
|
| 137 |
-
</form>
|
| 138 |
-
|
| 139 |
-
<div className="text-center mt-8 text-muted text-sm font-mono">
|
| 140 |
-
{isLogin ? "Don't have an account? " : "Already have an account? "}
|
| 141 |
-
<button onClick={() => setIsLogin(!isLogin)} className="text-accent hover:underline">
|
| 142 |
-
{isLogin ? "Sign Up" : "Log In"}
|
| 143 |
-
</button>
|
| 144 |
-
</div>
|
| 145 |
-
</div>
|
| 146 |
-
</div>
|
| 147 |
-
)
|
| 148 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
frontend/app/page.tsx
CHANGED
|
@@ -1,42 +1,86 @@
|
|
| 1 |
"use client"
|
|
|
|
| 2 |
import { useRouter } from "next/navigation"
|
|
|
|
|
|
|
|
|
|
| 3 |
|
| 4 |
export default function LandingPage() {
|
| 5 |
const router = useRouter()
|
|
|
|
|
|
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
-
<
|
| 17 |
-
<
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
>
|
| 24 |
-
|
| 25 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
</div>
|
|
|
|
| 27 |
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
<
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
| 37 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
</div>
|
| 39 |
</div>
|
| 40 |
-
</
|
| 41 |
)
|
| 42 |
}
|
|
|
|
| 1 |
"use client"
|
| 2 |
+
import { useState } from "react"
|
| 3 |
import { useRouter } from "next/navigation"
|
| 4 |
+
import { useStore } from "@/lib/store"
|
| 5 |
+
import { startAssessment } from "@/lib/api"
|
| 6 |
+
import UploadZone from "@/components/upload/UploadZone"
|
| 7 |
|
| 8 |
export default function LandingPage() {
|
| 9 |
const router = useRouter()
|
| 10 |
+
|
| 11 |
+
const { hoursPerDay, setHoursPerDay, setJdText, resumeText, setResumeText, setAssessmentId, setExtraction } = useStore()
|
| 12 |
|
| 13 |
+
const [jdTextLocal, setJdTextLocal] = useState("")
|
| 14 |
+
const [starting, setStarting] = useState(false)
|
| 15 |
+
const [error, setError] = useState("")
|
| 16 |
+
|
| 17 |
+
const handleBegin = async () => {
|
| 18 |
+
if (!jdTextLocal || !resumeText) return
|
| 19 |
+
setStarting(true)
|
| 20 |
+
setError("")
|
| 21 |
+
|
| 22 |
+
try {
|
| 23 |
+
// 1. Start Python Backend Assessment
|
| 24 |
+
const res = await startAssessment(jdTextLocal, resumeText, hoursPerDay)
|
| 25 |
+
|
| 26 |
+
// 2. Save to Zustand store and navigate
|
| 27 |
+
setJdText(jdTextLocal)
|
| 28 |
+
setAssessmentId(res.assessment_id)
|
| 29 |
+
setExtraction(res.extraction)
|
| 30 |
+
router.push(`/assess/${res.assessment_id}`)
|
| 31 |
|
| 32 |
+
} catch (err: any) {
|
| 33 |
+
setError(err.message)
|
| 34 |
+
setStarting(false)
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return (
|
| 39 |
+
<div className="min-h-screen bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 40 |
+
<div className="max-w-4xl mx-auto space-y-8">
|
| 41 |
|
| 42 |
+
<header className="border-b border-border pb-6 text-center">
|
| 43 |
+
<h1 className="text-4xl md:text-5xl font-bold font-mono tracking-tighter mb-4 text-accent">SkillForge</h1>
|
| 44 |
+
<p className="text-muted font-mono text-sm">Upload a Job Description and a Candidate Resume to begin the AI assessment.</p>
|
| 45 |
+
</header>
|
| 46 |
+
|
| 47 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
| 48 |
+
<div className="bg-surface border border-border shadow-sm p-6 rounded-3xl">
|
| 49 |
+
<h2 className="text-lg font-mono text-text mb-4">1. Job Description</h2>
|
| 50 |
+
<UploadZone label="Upload JD (PDF)" endpoint="jd" onUpload={setJdTextLocal} />
|
| 51 |
+
{jdTextLocal && <div className="mt-4 text-xs text-green-600 font-mono">✓ JD Extracted</div>}
|
| 52 |
+
</div>
|
| 53 |
+
<div className="bg-surface border border-border shadow-sm p-6 rounded-3xl">
|
| 54 |
+
<h2 className="text-lg font-mono text-text mb-4">2. Candidate Resume</h2>
|
| 55 |
+
<UploadZone label="Upload Resume (PDF)" endpoint="resume" onUpload={setResumeText} />
|
| 56 |
+
{resumeText && <div className="mt-4 text-xs text-green-600 font-mono">✓ Resume Extracted</div>}
|
| 57 |
</div>
|
| 58 |
+
</div>
|
| 59 |
|
| 60 |
+
<div className="bg-surface border border-border shadow-sm p-8 rounded-3xl space-y-8 max-w-lg mx-auto">
|
| 61 |
+
<div className="bg-gray-50 border border-border p-5 rounded-xl">
|
| 62 |
+
<label className="flex justify-between text-sm font-mono text-muted mb-4">
|
| 63 |
+
<span>Availability (hours/day)</span>
|
| 64 |
+
<span className="text-accent font-bold">{hoursPerDay}h</span>
|
| 65 |
+
</label>
|
| 66 |
+
<input
|
| 67 |
+
type="range" min="1" max="8" step="0.5"
|
| 68 |
+
value={hoursPerDay} onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
| 69 |
+
className="w-full h-1.5 bg-gray-300 rounded-lg appearance-none cursor-pointer accent-accent transition-all hover:h-2"
|
| 70 |
+
/>
|
| 71 |
</div>
|
| 72 |
+
|
| 73 |
+
{error && <div className="text-red-500 font-mono text-sm text-center">{error}</div>}
|
| 74 |
+
|
| 75 |
+
<button
|
| 76 |
+
onClick={handleBegin}
|
| 77 |
+
disabled={!resumeText || !jdTextLocal || starting}
|
| 78 |
+
className="w-full bg-accent text-white px-8 py-4 rounded-xl font-mono font-bold text-lg hover:scale-[1.02] shadow-sm disabled:opacity-50 disabled:hover:scale-100 transition-all"
|
| 79 |
+
>
|
| 80 |
+
{starting ? "Initializing Engine..." : "Begin AI Interview →"}
|
| 81 |
+
</button>
|
| 82 |
</div>
|
| 83 |
</div>
|
| 84 |
+
</div>
|
| 85 |
)
|
| 86 |
}
|
frontend/app/results/[id]/page.tsx
CHANGED
|
@@ -3,7 +3,6 @@ import { useEffect, useState } from "react"
|
|
| 3 |
import { useParams, useRouter } from "next/navigation"
|
| 4 |
import { useStore } from "@/lib/store"
|
| 5 |
import { getRoadmap } from "@/lib/api"
|
| 6 |
-
import { createClient } from "@/utils/supabase/client"
|
| 7 |
import SkillHeatmap from "@/components/results/SkillHeatmap"
|
| 8 |
import RoadmapTimeline from "@/components/results/RoadmapTimeline"
|
| 9 |
import GraphPathViz from "@/components/results/GraphPathViz"
|
|
@@ -12,60 +11,28 @@ export default function ResultsPage() {
|
|
| 12 |
const params = useParams()
|
| 13 |
const id = typeof params.id === "string" ? params.id : null
|
| 14 |
const router = useRouter()
|
| 15 |
-
const { hoursPerDay, setHoursPerDay, result, setResult, resetAssessment } = useStore()
|
| 16 |
const [mounted, setMounted] = useState(false)
|
| 17 |
const [error, setError] = useState("")
|
| 18 |
-
const [resumeText, setResumeText] = useState("")
|
| 19 |
-
const [role, setRole] = useState("")
|
| 20 |
-
const [candidateInfo, setCandidateInfo] = useState<{name: string, email: string} | null>(null)
|
| 21 |
|
| 22 |
useEffect(() => {
|
| 23 |
setMounted(true)
|
| 24 |
if (!id) return
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
const
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
const { data: assessment } = await supabase.from('assessments').select('status, result_data, resume_text, profiles(full_name, email)').eq('id', id).single()
|
| 36 |
-
|
| 37 |
-
if (assessment) {
|
| 38 |
-
setResumeText(assessment.resume_text || "")
|
| 39 |
-
if (assessment.profiles) {
|
| 40 |
-
setCandidateInfo({
|
| 41 |
-
name: assessment.profiles.full_name || "",
|
| 42 |
-
email: assessment.profiles.email || ""
|
| 43 |
-
})
|
| 44 |
-
}
|
| 45 |
-
if (assessment.status === 'completed' && assessment.result_data) {
|
| 46 |
-
setResult(assessment.result_data)
|
| 47 |
-
return
|
| 48 |
}
|
| 49 |
}
|
| 50 |
-
|
| 51 |
-
// 2. If not completed or not in Supabase, try to fetch from Python backend (live computation)
|
| 52 |
-
try {
|
| 53 |
-
const res = await getRoadmap(id, hoursPerDay)
|
| 54 |
-
setResult(res)
|
| 55 |
-
|
| 56 |
-
// 3. Save the newly computed result to Supabase
|
| 57 |
-
await supabase.from('assessments').update({
|
| 58 |
-
status: 'completed',
|
| 59 |
-
result_data: res
|
| 60 |
-
}).eq('id', id)
|
| 61 |
-
} catch (err) {
|
| 62 |
-
console.error("Failed to load roadmap:", err)
|
| 63 |
-
setError("This assessment session was interrupted or wiped from memory before completion.")
|
| 64 |
-
}
|
| 65 |
}
|
| 66 |
-
|
| 67 |
-
loadResults()
|
| 68 |
-
}, [id, hoursPerDay, setResult, router])
|
| 69 |
|
| 70 |
if (error) {
|
| 71 |
return (
|
|
@@ -89,31 +56,6 @@ export default function ResultsPage() {
|
|
| 89 |
? Math.round((result.skill_scores.reduce((sum: number, s: any) => sum + s.final_score, 0) / result.skill_scores.length) * 100)
|
| 90 |
: 0;
|
| 91 |
|
| 92 |
-
if (role === 'candidate') {
|
| 93 |
-
return (
|
| 94 |
-
<div className="min-h-screen flex flex-col items-center justify-center bg-bg font-sans text-center p-6 space-y-6">
|
| 95 |
-
<div className="bg-surface border border-border p-12 rounded-3xl shadow-xl max-w-lg w-full flex flex-col items-center">
|
| 96 |
-
<div className="w-20 h-20 bg-green-500/10 text-green-500 rounded-full flex items-center justify-center mb-6">
|
| 97 |
-
<svg className="w-10 h-10" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="3" d="M5 13l4 4L19 7"></path></svg>
|
| 98 |
-
</div>
|
| 99 |
-
<h1 className="text-3xl font-bold text-text mb-4">Assessment Submitted</h1>
|
| 100 |
-
<p className="text-muted mb-8">
|
| 101 |
-
Your technical assessment has been successfully completed and securely transmitted to the employer. They will review your results and contact you soon.
|
| 102 |
-
</p>
|
| 103 |
-
<button
|
| 104 |
-
onClick={() => {
|
| 105 |
-
resetAssessment()
|
| 106 |
-
router.push('/candidate')
|
| 107 |
-
}}
|
| 108 |
-
className="w-full bg-accent text-white font-bold py-4 rounded-xl hover:scale-[1.02] shadow-md transition-all"
|
| 109 |
-
>
|
| 110 |
-
Return to Candidate Portal
|
| 111 |
-
</button>
|
| 112 |
-
</div>
|
| 113 |
-
</div>
|
| 114 |
-
)
|
| 115 |
-
}
|
| 116 |
-
|
| 117 |
return (
|
| 118 |
<div className="min-h-screen relative bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 119 |
|
|
@@ -124,11 +66,6 @@ export default function ResultsPage() {
|
|
| 124 |
<h1 className="text-4xl md:text-5xl font-bold font-mono tracking-tighter mb-3">
|
| 125 |
<span className="text-accent">Assessment</span> Results
|
| 126 |
</h1>
|
| 127 |
-
{candidateInfo && (
|
| 128 |
-
<p className="text-lg font-mono text-text mb-2">
|
| 129 |
-
Candidate: <span className="font-bold">{candidateInfo.name || candidateInfo.email}</span>
|
| 130 |
-
</p>
|
| 131 |
-
)}
|
| 132 |
<p className="text-muted font-mono flex items-center">
|
| 133 |
<span className="w-2 h-2 rounded-full bg-accent mr-3 animate-pulse"></span>
|
| 134 |
Profile: {result.extraction.seniority_level} {result.extraction.domain}
|
|
@@ -141,9 +78,9 @@ export default function ResultsPage() {
|
|
| 141 |
<span className="text-accent font-bold">{hoursPerDay}h/day</span>
|
| 142 |
</label>
|
| 143 |
<input
|
| 144 |
-
type="range" min="1" max="8" step="0.5"
|
| 145 |
value={hoursPerDay} onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
| 146 |
-
className="w-full h-1.5 bg-gray-300 rounded-lg appearance-none cursor-
|
| 147 |
/>
|
| 148 |
</div>
|
| 149 |
</header>
|
|
|
|
| 3 |
import { useParams, useRouter } from "next/navigation"
|
| 4 |
import { useStore } from "@/lib/store"
|
| 5 |
import { getRoadmap } from "@/lib/api"
|
|
|
|
| 6 |
import SkillHeatmap from "@/components/results/SkillHeatmap"
|
| 7 |
import RoadmapTimeline from "@/components/results/RoadmapTimeline"
|
| 8 |
import GraphPathViz from "@/components/results/GraphPathViz"
|
|
|
|
| 11 |
const params = useParams()
|
| 12 |
const id = typeof params.id === "string" ? params.id : null
|
| 13 |
const router = useRouter()
|
| 14 |
+
const { hoursPerDay, setHoursPerDay, result, setResult, resetAssessment, resumeText } = useStore()
|
| 15 |
const [mounted, setMounted] = useState(false)
|
| 16 |
const [error, setError] = useState("")
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
useEffect(() => {
|
| 19 |
setMounted(true)
|
| 20 |
if (!id) return
|
| 21 |
+
|
| 22 |
+
// Only fetch if we don't have results for this ID
|
| 23 |
+
if (!result || result.skill_scores === undefined) {
|
| 24 |
+
const loadResults = async () => {
|
| 25 |
+
try {
|
| 26 |
+
const res = await getRoadmap(id, hoursPerDay)
|
| 27 |
+
setResult(res)
|
| 28 |
+
} catch (err) {
|
| 29 |
+
console.error("Failed to load roadmap:", err)
|
| 30 |
+
setError("This assessment session was interrupted or wiped from memory before completion.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
}
|
| 33 |
+
loadResults()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
}
|
| 35 |
+
}, [id, hoursPerDay, result, setResult])
|
|
|
|
|
|
|
| 36 |
|
| 37 |
if (error) {
|
| 38 |
return (
|
|
|
|
| 56 |
? Math.round((result.skill_scores.reduce((sum: number, s: any) => sum + s.final_score, 0) / result.skill_scores.length) * 100)
|
| 57 |
: 0;
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
return (
|
| 60 |
<div className="min-h-screen relative bg-bg text-text p-6 md:p-12 font-sans overflow-x-hidden">
|
| 61 |
|
|
|
|
| 66 |
<h1 className="text-4xl md:text-5xl font-bold font-mono tracking-tighter mb-3">
|
| 67 |
<span className="text-accent">Assessment</span> Results
|
| 68 |
</h1>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
<p className="text-muted font-mono flex items-center">
|
| 70 |
<span className="w-2 h-2 rounded-full bg-accent mr-3 animate-pulse"></span>
|
| 71 |
Profile: {result.extraction.seniority_level} {result.extraction.domain}
|
|
|
|
| 78 |
<span className="text-accent font-bold">{hoursPerDay}h/day</span>
|
| 79 |
</label>
|
| 80 |
<input
|
| 81 |
+
type="range" min="1" max="8" step="0.5" disabled
|
| 82 |
value={hoursPerDay} onChange={(e) => setHoursPerDay(parseFloat(e.target.value))}
|
| 83 |
+
className="w-full h-1.5 bg-gray-300 rounded-lg appearance-none cursor-not-allowed accent-accent opacity-70"
|
| 84 |
/>
|
| 85 |
</div>
|
| 86 |
</header>
|