ketannnn commited on
Commit
90d2b87
·
1 Parent(s): 1768a66

feat: add JD detail page with ranked candidates and weight sliders

Browse files
Files changed (1) hide show
  1. frontend/src/app/jds/[id]/page.tsx +302 -0
frontend/src/app/jds/[id]/page.tsx ADDED
@@ -0,0 +1,302 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ "use client";
2
+ import { useEffect, useState, useCallback, useRef } from "react";
3
+ import { useParams } from "next/navigation";
4
+ import { api, type JD, type MatchResponse, type MatchedCandidate } from "../../../lib/api";
5
+
6
+ const DEFAULT_WEIGHTS = {
7
+ semantic: 0.20, skill: 0.35, yoe: 0.15, company: 0.10, growth: 0.10, education: 0.10,
8
+ };
9
+
10
+ const WEIGHT_LABELS: Record<string, string> = {
11
+ semantic: "Semantic Sim", skill: "Skill Overlap", yoe: "Experience",
12
+ company: "Company Quality", growth: "Career Growth", education: "Education",
13
+ };
14
+
15
+ const SCORE_COLORS = ["#6c63ff", "#22c55e", "#3b82f6", "#f59e0b", "#a78bfa", "#34d399"];
16
+
17
+ export default function JDDetailPage() {
18
+ const params = useParams<{ id: string }>();
19
+ const jdId = params.id;
20
+
21
+ const [jd, setJD] = useState<JD | null>(null);
22
+ const [match, setMatch] = useState<MatchResponse | null>(null);
23
+ const [loading, setLoading] = useState(true);
24
+ const [matching, setMatching] = useState(false);
25
+ const [weights, setWeights] = useState(DEFAULT_WEIGHTS);
26
+ const [reranking, setReranking] = useState(false);
27
+ const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
28
+
29
+ const loadData = useCallback(async () => {
30
+ setLoading(true);
31
+ try {
32
+ const [jdData, matchData] = await Promise.all([
33
+ api.getJD(jdId),
34
+ api.getMatchResults(jdId).catch(() => null),
35
+ ]);
36
+ setJD(jdData);
37
+ setMatch(matchData);
38
+ } finally {
39
+ setLoading(false);
40
+ }
41
+ }, [jdId]);
42
+
43
+ useEffect(() => { loadData(); }, [loadData]);
44
+
45
+ const handleTriggerMatch = async () => {
46
+ setMatching(true);
47
+ try {
48
+ const result = await api.triggerMatch(jdId);
49
+ setMatch(result);
50
+ } finally {
51
+ setMatching(false);
52
+ }
53
+ };
54
+
55
+ const handleWeightChange = (key: string, value: number) => {
56
+ const newWeights = { ...weights, [key]: value };
57
+ setWeights(newWeights);
58
+ if (debounceRef.current) clearTimeout(debounceRef.current);
59
+ debounceRef.current = setTimeout(async () => {
60
+ setReranking(true);
61
+ const result = await api.rerank(jdId, newWeights).catch(() => null);
62
+ if (result) setMatch(result);
63
+ setReranking(false);
64
+ }, 400);
65
+ };
66
+
67
+ const scoreColor = (score: number) => {
68
+ if (score >= 0.7) return "var(--green)";
69
+ if (score >= 0.45) return "var(--yellow)";
70
+ return "var(--red)";
71
+ };
72
+
73
+ const rankClass = (rank: number) => {
74
+ if (rank === 1) return "rank-1";
75
+ if (rank === 2) return "rank-2";
76
+ if (rank === 3) return "rank-3";
77
+ return "rank-other";
78
+ };
79
+
80
+ if (loading) {
81
+ return (
82
+ <main className="page"><div className="container" style={{ display: "flex", alignItems: "center", justifyContent: "center", minHeight: "60vh" }}>
83
+ <div style={{ textAlign: "center" }}>
84
+ <div className="spinner" style={{ width: 40, height: 40, margin: "0 auto 16px" }} />
85
+ <div style={{ color: "var(--text-secondary)" }}>Loading…</div>
86
+ </div>
87
+ </div></main>
88
+ );
89
+ }
90
+
91
+ if (!jd) return <main className="page"><div className="container"><p>JD not found</p></div></main>;
92
+
93
+ const quality = jd.jd_quality;
94
+ const qualityColor = quality?.overall === "good" ? "badge-green" : quality?.overall === "fair" ? "badge-yellow" : "badge-red";
95
+
96
+ return (
97
+ <main className="page">
98
+ <div className="container">
99
+ <div style={{ marginBottom: 24 }}>
100
+ <a href="/" style={{ color: "var(--text-muted)", fontSize: "0.8125rem", display: "inline-flex", alignItems: "center", gap: 6, marginBottom: 16 }}>← Dashboard</a>
101
+ <div style={{ display: "flex", alignItems: "flex-start", justifyContent: "space-between", gap: 16, flexWrap: "wrap" }}>
102
+ <div>
103
+ <h1 className="page-title">{jd.title}</h1>
104
+ <div style={{ display: "flex", gap: 10, flexWrap: "wrap", marginTop: 8 }}>
105
+ {jd.engineer_type && <span className="badge badge-purple">🔧 {jd.engineer_type}</span>}
106
+ {jd.min_yoe && <span className="badge badge-blue">📅 {jd.min_yoe}+ yrs</span>}
107
+ {jd.location && <span className="badge badge-blue">📍 {jd.location}</span>}
108
+ {quality && <span className={`badge ${qualityColor}`}>{quality.overall === "good" ? "✓" : "⚠"} JD {quality.overall}</span>}
109
+ </div>
110
+ </div>
111
+ <button id="trigger-match-btn" className="btn btn-primary" onClick={handleTriggerMatch} disabled={matching || jd.status !== "ready"}>
112
+ {matching ? <><div className="spinner" style={{ width: 16, height: 16 }} /> Matching…</> : match ? "🔄 Re-run Match" : "⚡ Run Match"}
113
+ </button>
114
+ </div>
115
+ </div>
116
+
117
+ {quality?.warnings?.length > 0 && (
118
+ <div style={{ marginBottom: 24, padding: "14px 18px", borderRadius: 12, background: "rgba(234,179,8,0.08)", border: "1px solid rgba(234,179,8,0.3)" }}>
119
+ <div style={{ fontWeight: 600, fontSize: "0.875rem", color: "var(--yellow)", marginBottom: 6 }}>⚠ JD Quality Issues</div>
120
+ {quality.warnings.map((w, i) => (
121
+ <div key={i} style={{ fontSize: "0.8125rem", color: "var(--text-secondary)", marginTop: 4 }}>• {w}</div>
122
+ ))}
123
+ </div>
124
+ )}
125
+
126
+ {jd.required_skills?.length > 0 && (
127
+ <div style={{ marginBottom: 24, display: "flex", flexWrap: "wrap", gap: 6 }}>
128
+ {jd.required_skills.map((s) => <span key={s} className="chip">{s}</span>)}
129
+ </div>
130
+ )}
131
+
132
+ <div className="grid-2" style={{ gap: 24, alignItems: "start" }}>
133
+ <div>
134
+ {!match && !matching && (
135
+ <div style={{ textAlign: "center", padding: "60px 20px", background: "var(--bg-card)", borderRadius: "var(--radius-xl)", border: "1px dashed var(--border)" }}>
136
+ <div style={{ fontSize: "2.5rem", marginBottom: 12 }}>🎯</div>
137
+ <div style={{ fontWeight: 600, marginBottom: 8 }}>No results yet</div>
138
+ <div style={{ color: "var(--text-secondary)", fontSize: "0.875rem", marginBottom: 20 }}>Click Run Match to start the two-stage pipeline</div>
139
+ <button className="btn btn-primary" onClick={handleTriggerMatch} disabled={jd.status !== "ready"}>
140
+ ⚡ Run Match
141
+ </button>
142
+ </div>
143
+ )}
144
+
145
+ {matching && (
146
+ <div style={{ textAlign: "center", padding: "60px 20px", background: "var(--bg-card)", borderRadius: "var(--radius-xl)", border: "1px solid var(--border)" }}>
147
+ <div className="spinner" style={{ width: 48, height: 48, margin: "0 auto 16px" }} />
148
+ <div style={{ fontWeight: 600 }}>Running Stage 1 → Stage 2 pipeline…</div>
149
+ <div style={{ color: "var(--text-secondary)", fontSize: "0.875rem", marginTop: 8 }}>ANN search → feature scoring → cross-encoder reranking</div>
150
+ </div>
151
+ )}
152
+
153
+ {match && (
154
+ <div>
155
+ <div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", marginBottom: 16 }}>
156
+ <div style={{ fontWeight: 600, fontSize: "1rem" }}>
157
+ Top Matches
158
+ <span style={{ marginLeft: 10, fontWeight: 400, color: "var(--text-secondary)", fontSize: "0.875rem" }}>
159
+ {match.total_matched} candidates ranked
160
+ </span>
161
+ </div>
162
+ {reranking && <div className="spinner" style={{ width: 18, height: 18 }} />}
163
+ </div>
164
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
165
+ {match.results.map((c) => (
166
+ <CandidateCard key={c.candidate_id} candidate={c} jdId={jdId} rankClass={rankClass} scoreColor={scoreColor} />
167
+ ))}
168
+ </div>
169
+ </div>
170
+ )}
171
+ </div>
172
+
173
+ <div style={{ display: "flex", flexDirection: "column", gap: 16, position: "sticky", top: 80 }}>
174
+ <div className="weight-panel">
175
+ <div style={{ fontWeight: 600, fontSize: "0.9rem", marginBottom: 4 }}>🎛 Adjust Scoring Weights</div>
176
+ <div style={{ fontSize: "0.775rem", color: "var(--text-muted)", marginBottom: 16 }}>Drag sliders to re-rank instantly. No re-embedding needed.</div>
177
+ <div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
178
+ {Object.entries(weights).map(([key, val], i) => (
179
+ <div key={key} className="weight-row">
180
+ <div className="weight-label-row">
181
+ <span className="weight-key">{WEIGHT_LABELS[key] || key}</span>
182
+ <span className="weight-val">{(val * 100).toFixed(0)}%</span>
183
+ </div>
184
+ <input
185
+ id={`weight-${key}`} type="range" className="slider"
186
+ min={0} max={1} step={0.01} value={val}
187
+ onChange={(e) => handleWeightChange(key, parseFloat(e.target.value))}
188
+ style={{ accentColor: SCORE_COLORS[i % SCORE_COLORS.length] }}
189
+ />
190
+ </div>
191
+ ))}
192
+ </div>
193
+ <button className="btn btn-ghost btn-sm" style={{ marginTop: 16, width: "100%" }}
194
+ onClick={() => { setWeights(DEFAULT_WEIGHTS); api.rerank(jdId, DEFAULT_WEIGHTS).then((r) => r && setMatch(r)); }}>
195
+ ↺ Reset to default
196
+ </button>
197
+ </div>
198
+
199
+ {match?.jd_quality && (
200
+ <div className="card" style={{ padding: 20 }}>
201
+ <div style={{ fontWeight: 600, fontSize: "0.9rem", marginBottom: 12 }}>📊 JD Quality Report</div>
202
+ <div style={{ display: "flex", flexDirection: "column", gap: 10 }}>
203
+ {[
204
+ { label: "Vagueness", val: match.jd_quality.vagueness_score, invert: true },
205
+ { label: "Breadth", val: match.jd_quality.breadth_score, invert: true },
206
+ ].map(({ label, val, invert }) => (
207
+ <div key={label} className="score-bar-row">
208
+ <span className="score-bar-label">{label}</span>
209
+ <div className="score-bar-track">
210
+ <div className="score-bar-fill" style={{ width: `${val * 100}%`, background: invert ? (val > 0.6 ? "var(--red)" : val > 0.35 ? "var(--yellow)" : "var(--green)") : "var(--accent)" }} />
211
+ </div>
212
+ <span className="score-bar-pct">{(val * 100).toFixed(0)}%</span>
213
+ </div>
214
+ ))}
215
+ <div style={{ fontSize: "0.775rem", color: "var(--text-secondary)", marginTop: 4 }}>
216
+ Skills found: <strong>{match.jd_quality.skill_count}</strong>
217
+ </div>
218
+ </div>
219
+ </div>
220
+ )}
221
+ </div>
222
+ </div>
223
+ </div>
224
+ </main>
225
+ );
226
+ }
227
+
228
+ function CandidateCard({ candidate, jdId, rankClass, scoreColor }: {
229
+ candidate: MatchedCandidate; jdId: string;
230
+ rankClass: (r: number) => string; scoreColor: (s: number) => string;
231
+ }) {
232
+ const comp = candidate.component_scores;
233
+ const bars = [
234
+ { key: "skill", label: "Skill", val: comp.skill, color: SCORE_COLORS[1] },
235
+ { key: "semantic", label: "Semantic", val: comp.semantic, color: SCORE_COLORS[0] },
236
+ { key: "yoe", label: "YOE", val: comp.yoe, color: SCORE_COLORS[2] },
237
+ ];
238
+
239
+ return (
240
+ <a href={`/jds/${jdId}/candidates/${candidate.candidate_id}`} id={`cand-${candidate.candidate_id}`}
241
+ style={{ display: "block", textDecoration: "none" }}>
242
+ <div className="candidate-card">
243
+ <div className="candidate-card-top">
244
+ <div className={`rank-badge ${rankClass(candidate.rank)}`}>{candidate.rank}</div>
245
+ <div className="candidate-card-info">
246
+ <div className="candidate-name">{candidate.name || "Anonymous Candidate"}</div>
247
+ <div className="candidate-meta">
248
+ {candidate.years_of_experience != null && <span>📅 {candidate.years_of_experience} yrs</span>}
249
+ {candidate.engineer_type && <span>🔧 {candidate.engineer_type}</span>}
250
+ {candidate.most_recent_company && <span>🏢 {candidate.most_recent_company}</span>}
251
+ </div>
252
+ </div>
253
+ <div className="score-pill">
254
+ <div className="score-pill-value" style={{ color: scoreColor(candidate.final_score) }}>
255
+ {(candidate.final_score * 100).toFixed(0)}
256
+ </div>
257
+ <div className="score-pill-label">score</div>
258
+ </div>
259
+ </div>
260
+
261
+ {candidate.parsed_summary && (
262
+ <div style={{ fontSize: "0.8rem", color: "var(--text-secondary)", lineHeight: 1.5, display: "-webkit-box", WebkitLineClamp: 2, WebkitBoxOrient: "vertical" as "vertical", overflow: "hidden" }}>
263
+ {candidate.parsed_summary}
264
+ </div>
265
+ )}
266
+
267
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
268
+ {candidate.programming_languages.slice(0, 5).map((l) => (
269
+ <span key={l} className="chip">{l}</span>
270
+ ))}
271
+ {candidate.programming_languages.length > 5 && (
272
+ <span className="chip">+{candidate.programming_languages.length - 5}</span>
273
+ )}
274
+ </div>
275
+
276
+ <div className="score-bar-wrap">
277
+ {bars.map(({ key, label, val, color }) => (
278
+ <div key={key} className="score-bar-row">
279
+ <span className="score-bar-label">{label}</span>
280
+ <div className="score-bar-track">
281
+ <div className="score-bar-fill" style={{ width: `${val * 100}%`, background: color }} />
282
+ </div>
283
+ <span className="score-bar-pct">{(val * 100).toFixed(0)}%</span>
284
+ </div>
285
+ ))}
286
+ </div>
287
+
288
+ {candidate.gaps.length > 0 && (
289
+ <div style={{ display: "flex", flexWrap: "wrap", gap: 6 }}>
290
+ {candidate.gaps.slice(0, 3).map((g, i) => (
291
+ <span key={i} style={{ fontSize: "0.72rem", padding: "2px 8px", borderRadius: 6, background: "var(--red-dim)", color: "var(--red)", border: "1px solid rgba(239,68,68,0.2)" }}>
292
+ ⚠ {g.type.replace("_", " ")}
293
+ </span>
294
+ ))}
295
+ </div>
296
+ )}
297
+ </div>
298
+ </a>
299
+ );
300
+ }
301
+
302
+ const SCORE_COLORS = ["#6c63ff", "#22c55e", "#3b82f6", "#f59e0b", "#a78bfa", "#34d399"];