diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f397363..77ef4dd 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -39,16 +39,30 @@ function parseOidcHash(): { return { token: params.get("oidc_token"), error: params.get("oidc_error") }; } +/** Hidden admin entry: visiting /admin or /admin/* routes straight to org-auth. */ +function isAdminPath(): boolean { + return window.location.pathname.replace(/\/+$/, "").startsWith("/admin"); +} + +function initialPage(): Page { + if (parseOidcHash()?.error) return "login"; + if (isAdminPath()) { + const storedOrgToken = localStorage.getItem("org_token"); + return storedOrgToken ? "org-dashboard" : "org-auth"; + } + return "landing"; +} + function App() { - // Initial page/loader are derived from the URL so an OIDC return never flashes - // the landing page: a token shows the loader, an error drops straight to login. - const [page, setPage] = useState(() => - parseOidcHash()?.error ? "login" : "landing", - ); + const [page, setPage] = useState(initialPage); const [auth, setAuth] = useState(null); const [activeInterviewId, setActiveInterviewId] = useState( null, ); + const [orgToken, setOrgToken] = useState(() => + localStorage.getItem("org_token"), + ); + const [orgName, setOrgName] = useState(undefined); const [hydrating, setHydrating] = useState(() => Boolean(parseOidcHash()?.token), ); @@ -59,7 +73,6 @@ function App() { const oidc = parseOidcHash(); if (!oidc) return; - // Strip the token/error from the URL so it isn't kept in history or re-read. window.history.replaceState( null, "", @@ -67,7 +80,7 @@ function App() { ); const token = oidc.token; - if (!token) return; // error case already reflected in the initial page state + if (!token) return; localStorage.setItem("token", token); fetchCurrentUser(token) @@ -109,15 +122,14 @@ function App() { }; const handleOrgLoginSuccess = (token: string) => { + localStorage.setItem("org_token", token); setOrgToken(token); setPage("org-dashboard"); }; const handleOrgSignupSuccess = (data: OrgSignupResponse) => { - const token = data.organization - ? (localStorage.getItem("org_token") ?? "") - : ""; - setOrgToken(token); + localStorage.setItem("org_token", data.access_token); + setOrgToken(data.access_token); setOrgName(data.organization?.id?.toString()); setPage("org-dashboard"); }; @@ -126,7 +138,8 @@ function App() { localStorage.removeItem("org_token"); setOrgToken(null); setOrgName(undefined); - setPage("landing"); + // Keep the user on /admin so they can sign back in without retyping the URL. + setPage("org-auth"); }; const handleAttemptInterview = (interviewId: number) => { @@ -139,6 +152,15 @@ function App() { setPage("dashboard"); }; + const handleBackFromOrgAuth = () => { + // /admin URL: there's no public landing for admins, so a back press just + // clears the admin path and sends them home. + if (isAdminPath()) { + window.history.replaceState(null, "", "/"); + } + setPage("landing"); + }; + if (hydrating) { return ; } @@ -199,26 +221,34 @@ function App() { setPage("landing")} + onBack={handleBackFromOrgAuth} /> ); - case "org-dashboard": + case "org-dashboard": { + const token = orgToken ?? localStorage.getItem("org_token") ?? ""; + if (!token) { + // No token: render the auth page directly instead of mutating + // page state during render (would violate React purity rules). + return ( + + ); + } return ( ); + } default: - return ( - setPage("login")} - onOrgLoginClick={() => setPage("org-auth")} - /> - ); + return setPage("login")} />; } } @@ -231,20 +261,4 @@ function OidcLoader() { ); } -function Placeholder({ label, onBack }: { label: string; onBack: () => void }) { - return ( -
-
πŸš€
-

{label} β€” coming soon

- -
- ); -} - export default App; - diff --git a/frontend/src/components/LandingPage.tsx b/frontend/src/components/LandingPage.tsx index 49dcd8a..cc595e2 100644 --- a/frontend/src/components/LandingPage.tsx +++ b/frontend/src/components/LandingPage.tsx @@ -3,7 +3,6 @@ import Orb from "./Orb"; export interface LandingPageProps { onLoginClick?: () => void; - onOrgLoginClick?: () => void; } interface Stat { @@ -28,7 +27,6 @@ const STATS: Stat[] = [ export default function LandingPage({ onLoginClick, - onOrgLoginClick, }: LandingPageProps): JSX.Element { return (
- ))}
@@ -367,7 +367,7 @@ const OrgSignupInline: React.FC = ({ = ({ ) : ( <> - Create Organisation Account + Create Admin Account )} @@ -555,7 +555,7 @@ const OrgLoginInline: React.FC = ({ padding: 0, }} > - Register your organisation + Register an admin account

diff --git a/frontend/src/features/org/OrgDashboardPage.tsx b/frontend/src/features/org/OrgDashboardPage.tsx index c5bfbb1..1f28ee1 100644 --- a/frontend/src/features/org/OrgDashboardPage.tsx +++ b/frontend/src/features/org/OrgDashboardPage.tsx @@ -1,140 +1,131 @@ -import React, { useState, useEffect, useCallback } from "react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; import { - getOrgInterviews, + createOrgInterview, + getInterviewApplications, getLeaderboard, + getOrgInterview, + getOrgInterviews, + seedTestInterview, + toggleShortlist, LeaderboardServiceError, - type OrgInterview, - type LeaderboardResponse, + type ApplicationResponse, + type CreateInterviewPayload, type LeaderboardEntry, + type LeaderboardResponse, + type OrgInterview, + type OrgInterviewDetail, + type SessionResult, } from "../../services/leaderboard.service"; -// ── Props ───────────────────────────────────────────────────────────────────── - export interface OrgDashboardPageProps { token: string; orgName?: string; onLogout: () => void; } -// ── Helpers ─────────────────────────────────────────────────────────────────── - -function formatDate(iso: string) { - return new Date(iso).toLocaleDateString("en-IN", { - day: "numeric", - month: "short", - year: "numeric", - }); -} - -function isLive(interview: OrgInterview) { - const now = Date.now(); - return ( - new Date(interview.start_time).getTime() <= now && - now <= new Date(interview.end_time).getTime() - ); -} - -function isUpcoming(interview: OrgInterview) { - return new Date(interview.start_time).getTime() > Date.now(); -} - -function getScoreColor(score: number) { - if (score >= 80) return "#10b981"; - if (score >= 60) return "#f59e0b"; - if (score >= 40) return "#f97316"; - return "#ef4444"; -} - -function getScoreBg(score: number) { - if (score >= 80) return "rgba(209,250,229,0.8)"; - if (score >= 60) return "rgba(254,243,199,0.8)"; - if (score >= 40) return "rgba(255,237,213,0.8)"; - return "rgba(254,226,226,0.8)"; -} - -function getRankMedal(rank: number) { - if (rank === 1) return "πŸ₯‡"; - if (rank === 2) return "πŸ₯ˆ"; - if (rank === 3) return "πŸ₯‰"; - return null; -} - -// ── Main Page ───────────────────────────────────────────────────────────────── +type View = "list" | "detail" | "create"; +type DetailTab = "overview" | "applications" | "leaderboard"; const OrgDashboardPage: React.FC = ({ token, orgName, onLogout, }) => { + const [view, setView] = useState("list"); const [interviews, setInterviews] = useState([]); - const [isLoadingInterviews, setIsLoadingInterviews] = useState(true); - const [interviewsError, setInterviewsError] = useState(null); - - const [selectedInterview, setSelectedInterview] = - useState(null); - const [leaderboard, setLeaderboard] = useState( + const [isLoadingList, setIsLoadingList] = useState(true); + const [listError, setListError] = useState(null); + const [activeInterviewId, setActiveInterviewId] = useState( null, ); - const [isLoadingLeaderboard, setIsLoadingLeaderboard] = useState(false); - const [leaderboardError, setLeaderboardError] = useState(null); + const [isSeedingTest, setIsSeedingTest] = useState(false); + const [seedError, setSeedError] = useState(null); - // Fetch org's interview list + const displayName = orgName ?? "Admin"; + + // Triggered by the Retry button (event-driven, sync setStates allowed). const fetchInterviews = useCallback(async () => { - setIsLoadingInterviews(true); - setInterviewsError(null); + setIsLoadingList(true); + setListError(null); try { - const data = await getOrgInterviews(token); - setInterviews(data); + setInterviews(await getOrgInterviews(token)); } catch (err) { - setInterviewsError( + setListError( err instanceof LeaderboardServiceError ? err.message : "Failed to load interviews.", ); } finally { - setIsLoadingInterviews(false); + setIsLoadingList(false); } }, [token]); + // Initial load: keep the effect pure (no sync setState). Initial state + // already has `isLoadingList: true`, so we only flip it via the async tail. useEffect(() => { - fetchInterviews(); - }, [fetchInterviews]); - - // Fetch leaderboard for selected interview - const openLeaderboard = async (interview: OrgInterview) => { - setSelectedInterview(interview); - setLeaderboard(null); - setLeaderboardError(null); - setIsLoadingLeaderboard(true); + let cancelled = false; + getOrgInterviews(token) + .then((data) => { + if (!cancelled) setInterviews(data); + }) + .catch((err) => { + if (cancelled) return; + setListError( + err instanceof LeaderboardServiceError + ? err.message + : "Failed to load interviews.", + ); + }) + .finally(() => { + if (!cancelled) setIsLoadingList(false); + }); + return () => { + cancelled = true; + }; + }, [token]); + + const liveCount = interviews.filter(isLive).length; + const upcomingCount = interviews.filter(isUpcoming).length; + const closedCount = interviews.filter( + (i) => !isLive(i) && !isUpcoming(i), + ).length; + + const openDetail = (id: number) => { + setActiveInterviewId(id); + setView("detail"); + }; + + const openCreate = () => setView("create"); + const goList = () => { + setActiveInterviewId(null); + setView("list"); + }; + + const handleCreated = async (created: OrgInterviewDetail) => { + await fetchInterviews(); + setActiveInterviewId(created.id); + setView("detail"); + }; + + const handleSeedTest = async () => { + setIsSeedingTest(true); + setSeedError(null); try { - const data = await getLeaderboard(interview.id, token); - setLeaderboard(data); + const created = await seedTestInterview(token); + await fetchInterviews(); + setActiveInterviewId(created.id); + setView("detail"); } catch (err) { - setLeaderboardError( + setSeedError( err instanceof LeaderboardServiceError ? err.message - : "Failed to load leaderboard.", + : "Failed to create test interview.", ); } finally { - setIsLoadingLeaderboard(false); + setIsSeedingTest(false); } }; - const closeLeaderboard = () => { - setSelectedInterview(null); - setLeaderboard(null); - setLeaderboardError(null); - }; - - const displayName = orgName ?? "Organisation"; - const initials = displayName.slice(0, 2).toUpperCase(); - - const liveCount = interviews.filter(isLive).length; - const upcomingCount = interviews.filter(isUpcoming).length; - const closedCount = interviews.filter( - (i) => !isLive(i) && !isUpcoming(i), - ).length; - return (
= ({ > - {/* ── Sticky Top Nav ── */} -
-
- {/* Logo */} -
-
- - X - -
- - InterXAI - -
- - {/* Nav links */} -
- - - -
- - {/* Right side */} -
-
-
- {initials} -
- - {displayName} - -
- -
-
-
+ - {/* ── Main ── */}
= ({ padding: "32px 52px 60px", }} > - {/* Hero stats */} -
- {/* Welcome card */} -
-
-
- ✦ Org Dashboard -
-
- Welcome back, {displayName} πŸ‘‹ -
-
- {liveCount} interview{liveCount !== 1 ? "s" : ""} live right now -
-
- - } + {view === "list" && ( + - } - /> - } - /> -
- - {/* Section header */} -
-
-

- Interviews -

-

- Click on an interview to view the candidate leaderboard -

-
-
- - Leaderboard ready -
-
- - {/* Interview cards */} - {isLoadingInterviews && ( -
- {[1, 2, 3].map((i) => ( - - ))} -
- )} - - {!isLoadingInterviews && interviewsError && ( - )} - {!isLoadingInterviews && !interviewsError && interviews.length === 0 && ( - + {view === "create" && ( + )} - {!isLoadingInterviews && !interviewsError && interviews.length > 0 && ( -
- {interviews.map((iv) => ( - openLeaderboard(iv)} - /> - ))} -
+ {view === "detail" && activeInterviewId != null && ( + )}
- - {/* ── Leaderboard Modal ── */} - {selectedInterview && ( - openLeaderboard(selectedInterview)} - /> - )}
); }; -// ── Interview Card ───────────────────────────────────────────────────────────── +// ── List view ──────────────────────────────────────────────────────────────── -const InterviewCard: React.FC<{ - interview: OrgInterview; - active: boolean; - onClick: () => void; -}> = ({ interview, active, onClick }) => { - const live = isLive(interview); - const upcoming = isUpcoming(interview); - const label = live ? "Live" : upcoming ? "Upcoming" : "Closed"; - const labelColor = live ? "#10b981" : upcoming ? "#f59e0b" : "#8b5cf6"; - const labelBg = live - ? "rgba(209,250,229,0.8)" - : upcoming - ? "rgba(254,243,199,0.8)" - : "rgba(237,233,254,0.8)"; - - return ( +const ListView: React.FC<{ + displayName: string; + interviews: OrgInterview[]; + liveCount: number; + upcomingCount: number; + closedCount: number; + isLoading: boolean; + error: string | null; + seedError: string | null; + isSeedingTest: boolean; + onRetry: () => void; + onOpen: (id: number) => void; + onCreate: () => void; + onSeedTest: () => void; +}> = ({ + displayName, + interviews, + liveCount, + upcomingCount, + closedCount, + isLoading, + error, + seedError, + isSeedingTest, + onRetry, + onOpen, + onCreate, + onSeedTest, +}) => ( + <>
+ background: + "linear-gradient(135deg, rgba(59,130,246,0.95), rgba(29,78,216,0.95))", + borderRadius: 22, + padding: "22px 24px", + color: "#fff", + boxShadow: "0 18px 40px -10px rgba(29,78,216,0.45)", + position: "relative", + overflow: "hidden", + }} + > +
+ +
+ Welcome back, {displayName} πŸ‘‹ +
+
+ {liveCount} interview{liveCount !== 1 ? "s" : ""} live right now Β·{" "} + {upcomingCount} upcoming Β· {closedCount} closed +
+
+ + } + /> + } + /> + } + /> +
+ +
+
+

+ Interviews +

+

+ Open an interview to view candidates and the leaderboard. +

+
+
+
+ + +
+ {seedError && ( +
+ {seedError} +
+ )} +
+
+ + {isLoading && ( +
+ {[1, 2, 3].map((i) => ( + + ))} +
+ )} + + {!isLoading && error && } + + {!isLoading && !error && interviews.length === 0 && ( + + Create Interview β†’ + + } + /> + )} + + {!isLoading && !error && interviews.length > 0 && ( +
+ {interviews.map((iv) => ( + onOpen(iv.id)} + /> + ))} +
+ )} + +); + +const InterviewCard: React.FC<{ + interview: OrgInterview; + onClick: () => void; +}> = ({ interview, onClick }) => { + const live = isLive(interview); + const upcoming = isUpcoming(interview); + const label = live ? "Live" : upcoming ? "Upcoming" : "Closed"; + const accent = live ? "#10b981" : upcoming ? "#f59e0b" : "#8b5cf6"; + const bg = live + ? "rgba(209,250,229,0.8)" + : upcoming + ? "rgba(254,243,199,0.8)" + : "rgba(237,233,254,0.8)"; + + return ( +
+

- {interview.experience} experience + {interview.experience} experience Β· #{interview.id}

+
- + Open interview + +
); }; -// ── Leaderboard Modal ────────────────────────────────────────────────────────── +// ── Detail view ────────────────────────────────────────────────────────────── + +const InterviewDetailView: React.FC<{ + interviewId: number; + token: string; + onBack: () => void; +}> = ({ interviewId, token, onBack }) => { + const [tab, setTab] = useState("overview"); + const [detail, setDetail] = useState(null); + const [detailError, setDetailError] = useState(null); + + // Parent re-keys on interviewId so initial state is already fresh. + useEffect(() => { + let cancelled = false; + getOrgInterview(interviewId, token) + .then((d) => { + if (!cancelled) setDetail(d); + }) + .catch((err) => { + if (cancelled) return; + setDetailError( + err instanceof LeaderboardServiceError + ? err.message + : "Failed to load interview.", + ); + }); + return () => { + cancelled = true; + }; + }, [interviewId, token]); -const LeaderboardModal: React.FC<{ - interview: OrgInterview; - leaderboard: LeaderboardResponse | null; - isLoading: boolean; - error: string | null; - onClose: () => void; - onRetry: () => void; -}> = ({ interview, leaderboard, isLoading, error, onClose, onRetry }) => { return ( <> - {/* Backdrop */} -
- - {/* Panel */} -
- {/* Header */} -
+ ← All interviews + + + {detailError && ( + + )} + + {!detail && !detailError && } + + {detail && ( + <>
-
-
- - Candidate Leaderboard -
-

- {interview.position} -

-

- {interview.experience} Β· Closed{" "} - {formatDate(interview.end_time)} - {leaderboard && ( - <> Β· {leaderboard.total_candidates} candidates - )} -

-
- -
-
- - {/* Body */} -
- {isLoading && } - {!isLoading && error && ( -
+ +

+ {detail.position} +

+

+ {detail.experience} experience Β· {detail.duration} min Β·{" "} + {detail.questions.length} questions Β·{" "} + {detail.dsa_topics.length} DSA topics +

+
+ +
+

-

⚠️
-
- Failed to load leaderboard -
-
{error}
- -
- )} - {!isLoading && !error && leaderboard?.entries.length === 0 && ( + {detail.description} +

-
🎯
-
- No candidates yet -
-
- Candidates who complete the interview will appear here, ranked - by score. -
+ + +
- )} - {!isLoading && !error && leaderboard && leaderboard.entries.length > 0 && ( - <> - {/* Top 3 podium */} - {leaderboard.entries.length >= 3 && ( - - )} - - {/* Full ranking table */} -
= 3 ? 24 : 0, - }} - > -
+ +
+ {(["overview", "applications", "leaderboard"] as DetailTab[]).map( + (t) => ( +
+ {t} + + ), + )} +
- {leaderboard.entries.map((entry) => ( - - ))} -
- + {tab === "overview" && } + {tab === "applications" && ( + )} -
-
- - - + {tab === "leaderboard" && ( + + )} + + )} + + ); +}; + +// ── Overview tab ───────────────────────────────────────────────────────────── + +const OverviewTab: React.FC<{ detail: OrgInterviewDetail }> = ({ detail }) => ( +
+ + {detail.questions.length === 0 ? ( + No custom questions configured. + ) : ( + detail.questions.map((q, i) => ( +
+
+ Q{i + 1} +
+
+ {q.question} +
+ {q.expected_answer && ( +
+ Expected: {q.expected_answer} +
+ )} +
+ )) + )} +
+ + + {detail.dsa_topics.length === 0 ? ( + No DSA topics configured. + ) : ( +
+ {detail.dsa_topics.map((t) => ( +
+ + {t.topic} + + +
+ ))} +
+ )} +
+ + + + + + + + + + + + + + +
+); + +// ── Applications tab ──────────────────────────────────────────────────────── + +const ApplicationsTab: React.FC<{ interviewId: number; token: string }> = ({ + interviewId, + token, +}) => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + getInterviewApplications(interviewId, token) + .then((d) => { + if (!cancelled) setData(d); + }) + .catch((err) => { + if (cancelled) return; + setError( + err instanceof LeaderboardServiceError + ? err.message + : "Failed to load applications.", + ); + }); + return () => { + cancelled = true; + }; + }, [interviewId, token]); + + const handleToggle = async (applicationId: number) => { + try { + const updated = await toggleShortlist(applicationId, token); + setData((prev) => + prev + ? prev.map((a) => (a.id === updated.id ? updated : a)) + : prev, + ); + } catch { + // silently ignore β€” the button state reverts automatically + } + }; + + if (error) return null} />; + if (!data) return ; + if (data.length === 0) { + return ( + + ); + } + + return ( +
+
+
App #
+
Candidate
+
Resume score
+
Shortlisted
+
Status
+
Applied
+
Action
+
+ {data.map((a) => ( + + ))} +
+ ); +}; + +const ApplicationRow: React.FC<{ + application: ApplicationResponse; + onToggle: (id: number) => Promise; +}> = ({ application, onToggle }) => { + const [loading, setLoading] = useState(false); + + const handleClick = async () => { + setLoading(true); + await onToggle(application.id); + setLoading(false); + }; + + const approved = application.shortlisting_decision; + + return ( +
+
#{application.id}
+
+
+ User #{application.user_id} +
+
+ {application.resume ?? "no resume"} +
+
+
+ +
+
+ {approved ? ( + + ) : ( + + )} +
+
+ +
+
+ {formatDate(application.created_at)} +
+
+ +
+
); }; -// ── Podium ───────────────────────────────────────────────────────────────────── +// ── Leaderboard tab ───────────────────────────────────────────────────────── + +const LeaderboardTab: React.FC<{ interviewId: number; token: string }> = ({ + interviewId, + token, +}) => { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + + // Retry handler β€” event-driven, so sync resets are allowed here. + const load = useCallback(() => { + setData(null); + setError(null); + getLeaderboard(interviewId, token) + .then(setData) + .catch((err) => { + setError( + err instanceof LeaderboardServiceError + ? err.message + : "Failed to load leaderboard.", + ); + }); + }, [interviewId, token]); + + // Initial load: parent re-keys on interviewId, so state starts fresh and + // the effect just kicks off the async fetch. + useEffect(() => { + let cancelled = false; + getLeaderboard(interviewId, token) + .then((d) => { + if (!cancelled) setData(d); + }) + .catch((err) => { + if (cancelled) return; + setError( + err instanceof LeaderboardServiceError + ? err.message + : "Failed to load leaderboard.", + ); + }); + return () => { + cancelled = true; + }; + }, [interviewId, token]); + + if (error) return ; + if (!data) return ; + if (data.entries.length === 0) { + return ( + + ); + } + + return ( + <> + {data.entries.length >= 3 && ( + + )} + +
= 3 ? 20 : 0, + }} + > +
+
Rank
+
Candidate
+
Interview
+
Resume
+
Status
+
+
+ + {data.entries.map((entry) => ( + + + setExpandedId((p) => + p === entry.application_id ? null : entry.application_id, + ) + } + /> + {expandedId === entry.application_id && ( + + )} + + ))} +
+ + ); +}; const Podium: React.FC<{ entries: LeaderboardEntry[] }> = ({ entries }) => { - const order = [entries[1], entries[0], entries[2]]; // silver, gold, bronze - const heights = [80, 108, 64]; - const bgColors = [ + const order = [entries[1], entries[0], entries[2]]; + const heights = [78, 108, 64]; + const colors = [ "linear-gradient(145deg,#94a3b8,#64748b)", "linear-gradient(145deg,#fbbf24,#d97706)", "linear-gradient(145deg,#f97316,#ea580c)", ]; - return (
{order.map((entry, idx) => ( @@ -892,22 +1228,23 @@ const Podium: React.FC<{ entries: LeaderboardEntry[] }> = ({ entries }) => { gap: 8, }} > - {/* Avatar */}
{entry.username.slice(0, 2).toUpperCase()} @@ -921,18 +1258,16 @@ const Podium: React.FC<{ entries: LeaderboardEntry[] }> = ({ entries }) => { lineHeight: 1, }} > - {getRankMedal(entry.rank)} + {rankMedal(entry.rank)}
- - {/* Name + score */}
= ({ entries }) => { style={{ fontSize: 13, fontWeight: 800, - color: getScoreColor(entry.score), + color: scoreColor(entry.interview_score), }} > - {entry.score.toFixed(1)} + {entry.interview_score?.toFixed(1) ?? "β€”"}
- - {/* Podium block */}
= ({ entries }) => { color: "#fff", fontSize: 22, fontWeight: 900, - boxShadow: `0 -4px 20px ${idx === 1 ? "rgba(251,191,36,0.3)" : "rgba(15,23,42,0.1)"}`, + boxShadow: `0 -4px 20px ${ + idx === 1 ? "rgba(251,191,36,0.3)" : "rgba(15,23,42,0.1)" + }`, }} > {entry.rank} @@ -975,173 +1310,1976 @@ const Podium: React.FC<{ entries: LeaderboardEntry[] }> = ({ entries }) => { ); }; -// ── Leaderboard Row ──────────────────────────────────────────────────────────── +const LeaderboardRow: React.FC<{ + entry: LeaderboardEntry; + expanded: boolean; + onToggle: () => void; +}> = ({ entry, expanded, onToggle }) => ( +
+ +
+
+ {entry.username} +
+
{entry.email}
+
+
+ +
+
+ +
+
+ {entry.shortlisting_decision ? ( + + ) : ( + + )} +
+
+ β–Έ +
+
+); -const LeaderboardRow: React.FC<{ entry: LeaderboardEntry }> = ({ entry }) => { - const medal = getRankMedal(entry.rank); - const isShortlisted = entry.shortlisting_decision; +const ExpandedEntry: React.FC<{ entry: LeaderboardEntry }> = ({ entry }) => ( +
+ {entry.application_feedback && ( + + )} + {entry.sessions.length === 0 ? ( + No sessions completed. + ) : ( + entry.sessions.map((session, i) => ( + + )) + )} +
+); - return ( +const SessionDetail: React.FC<{ session: SessionResult; index: number }> = ({ + session, + index, +}) => ( +
- {/* Rank */} -
- {medal ?? entry.rank} -
- - {/* User */} -
-
+ - {entry.username} -
-
{entry.email}
+ Session {index} Β· #{session.id} + +
- - {/* Score */} -
-
+ + - {entry.score.toFixed(1)} -
+ {formatDate(session.start_time)} + {session.end_time ? ` β†’ ${formatDate(session.end_time)}` : ""} +
+
+ + {session.recommendation && ( + + )} + {session.strengths && ( + + )} + {session.feedback && ( + + )} + + + {session.questions_round.length === 0 ? ( + No behavioural answers. + ) : ( + session.questions_round.map((q) => ( +
+
+
+ {q.question ?? "(question deleted)"} +
+ +
+ {q.feedback && ( +
+ {q.feedback} +
+ )} +
+ {q.follow_ups.map((t) => ( +
+
+ AI asked +
+
+ {t.question} +
+ {t.answer && ( + <> +
+ Candidate +
+
+ {t.answer} +
+ + )} +
+ ))} +
+
+ )) + )} +
+ + + {session.dsa_round.length === 0 ? ( + No coding submissions. + ) : ( + session.dsa_round.map((dsa) => ( +
+
+
+
+ {dsa.problem_name ?? "(problem)"} +
+
+ {dsa.topic ?? "?"} Β·{" "} + {dsa.difficulty ? ( + + ) : ( + "?" + )}{" "} + Β· {dsa.language ?? "β€”"} +
+
+ +
+ {dsa.code && ( +
+                {dsa.code}
+              
+ )} +
+ )) + )} +
+ + + {session.resume_round.length === 0 ? ( + No resume conversations. + ) : ( + session.resume_round.map((conv) => ( +
+
+ + Conversation #{conv.id} + + +
+ {conv.feedback && ( +
+ {conv.feedback} +
+ )} + {conv.questions.map((qq) => ( +
+
+ Q: {qq.question} +
+
+ A: {qq.answer ?? "(no answer)"} +
+
+ ))} +
+ )) + )} +
+
+); + +// ── Create interview wizard (3 steps) ─────────────────────────────────────── + +interface CreateFormState { + position: string; + description: string; + experience: string; + submission_deadline: string; + start_time: string; + end_time: string; + duration: number; + dsa_score: number; + dev_score: number; + resume_shortlist_score: number; + ask_questions_on_resume: boolean; + questions: { question: string; expected_answer: string }[]; + dsa_topics: { topic: string; difficulty: string }[]; +} + +type WizardStep = 1 | 2 | 3; + +const STEP_META: Record< + WizardStep, + { title: string; subtitle: string; pill: string } +> = { + 1: { + title: "Basic details", + subtitle: "Position, schedule, duration, and scoring weights.", + pill: "Step 1 of 3", + }, + 2: { + title: "Behavioural questions", + subtitle: + "The questions the AI interviewer will ask each candidate in the first round.", + pill: "Step 2 of 3", + }, + 3: { + title: "Coding round", + subtitle: + "Pick the topics and difficulty β€” the AI generates a fresh problem per candidate during the interview.", + pill: "Step 3 of 3", + }, +}; + +const CreateInterviewView: React.FC<{ + token: string; + onBack: () => void; + onCreated: (created: OrgInterviewDetail) => void; +}> = ({ token, onBack, onCreated }) => { + const [step, setStep] = useState(1); + const [form, setForm] = useState({ + position: "", + description: "", + experience: "Mid", + submission_deadline: toLocalInput(addDays(new Date(), 7)), + start_time: toLocalInput(addDays(new Date(), 8)), + end_time: toLocalInput(addDays(new Date(), 14)), + duration: 60, + dsa_score: 50, + dev_score: 50, + resume_shortlist_score: 0, + ask_questions_on_resume: false, + questions: [{ question: "", expected_answer: "" }], + dsa_topics: [{ topic: "", difficulty: "easy" }], + }); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const set = (k: K, v: CreateFormState[K]) => + setForm((f) => ({ ...f, [k]: v })); + + const setQ = (i: number, patch: Partial) => + setForm((f) => ({ + ...f, + questions: f.questions.map((q, idx) => + idx === i ? { ...q, ...patch } : q, + ), + })); + const addQ = () => + setForm((f) => ({ + ...f, + questions: [...f.questions, { question: "", expected_answer: "" }], + })); + const rmQ = (i: number) => + setForm((f) => ({ + ...f, + questions: f.questions.filter((_, idx) => idx !== i), + })); + + const setT = (i: number, patch: Partial) => + setForm((f) => ({ + ...f, + dsa_topics: f.dsa_topics.map((t, idx) => + idx === i ? { ...t, ...patch } : t, + ), + })); + const addT = () => + setForm((f) => ({ + ...f, + dsa_topics: [...f.dsa_topics, { topic: "", difficulty: "easy" }], + })); + const rmT = (i: number) => + setForm((f) => ({ + ...f, + dsa_topics: f.dsa_topics.filter((_, idx) => idx !== i), + })); + + const totalScore = form.dsa_score + form.dev_score; + const valid = useMemo(() => { + if (!form.position.trim() || !form.description.trim()) return false; + if (totalScore !== 100) return false; + if ( + form.questions.length === 0 || + form.questions.some((q) => !q.question.trim()) + ) + return false; + if ( + form.dsa_topics.length === 0 || + form.dsa_topics.some((t) => !t.topic.trim()) + ) + return false; + return true; + }, [form, totalScore]); + + const submit = async (e: React.FormEvent) => { + e.preventDefault(); + if (!valid) return; + setSubmitting(true); + setError(null); + try { + const payload: CreateInterviewPayload = { + position: form.position.trim(), + description: form.description.trim(), + experience: form.experience.trim(), + submission_deadline: toIso(form.submission_deadline), + start_time: toIso(form.start_time), + end_time: toIso(form.end_time), + duration: form.duration, + dsa_score: form.dsa_score, + dev_score: form.dev_score, + resume_shortlist_score: form.resume_shortlist_score, + ask_questions_on_resume: form.ask_questions_on_resume, + questions: form.questions.map((q) => ({ + question: q.question.trim(), + expected_answer: q.expected_answer.trim(), + })), + dsa_topics: form.dsa_topics.map((t) => ({ + topic: t.topic.trim(), + difficulty: t.difficulty, + })), + }; + const created = await createOrgInterview(payload, token); + onCreated(created); + } catch (err) { + setError( + err instanceof LeaderboardServiceError + ? err.message + : "Failed to create interview.", + ); + } finally { + setSubmitting(false); + } + }; + + // Per-step validation. Step 1 = basics; Step 2 = behavioural questions; + // Step 3 = DSA topics. Only valid steps allow you to advance. + const step1Valid = + form.position.trim().length > 0 && + form.description.trim().length > 0 && + totalScore === 100 && + form.duration > 0; + const step2Valid = + form.questions.length > 0 && + form.questions.every((q) => q.question.trim().length > 0); + const step3Valid = + form.dsa_topics.length > 0 && + form.dsa_topics.every((t) => t.topic.trim().length > 0); + + const canAdvance = + (step === 1 && step1Valid) || + (step === 2 && step2Valid) || + (step === 3 && step3Valid && valid); + + const goNext = () => setStep((s) => (s < 3 ? ((s + 1) as WizardStep) : s)); + const goPrev = () => setStep((s) => (s > 1 ? ((s - 1) as WizardStep) : s)); + + return ( + <> + + +
+ + +
+ +

+ {STEP_META[step].title} +

+

+ {STEP_META[step].subtitle} +

+
+ +
{ + e.preventDefault(); + if (step !== 3) goNext(); + else void submit(e); + }} + style={{ marginTop: 22 }} + > + {error && ( +
+ {error} +
+ )} + + {step === 1 && ( + { + set("dsa_score", dsa); + set("dev_score", 100 - dsa); + }} + /> + )} + {step === 2 && ( + + )} + {step === 3 && ( + + )} + +
+ + + +
+ +
+ + ); +}; + +// ── Wizard pieces ─────────────────────────────────────────────────────────── + +const WizardStepper: React.FC<{ + step: WizardStep; + step1Valid: boolean; + step2Valid: boolean; +}> = ({ step, step1Valid, step2Valid }) => { + const labels = ["Basics", "Questions", "Coding"]; + const states: ("done" | "active" | "todo")[] = [ + step === 1 ? "active" : step1Valid ? "done" : "todo", + step === 2 ? "active" : step > 2 && step2Valid ? "done" : "todo", + step === 3 ? "active" : "todo", + ]; + return ( +
+ {labels.map((label, i) => { + const state = states[i]; + const colour = + state === "active" || state === "done" ? "#2563eb" : "#cbd5e1"; + return ( + +
+
+ {state === "done" ? "βœ“" : i + 1} +
+ + {label} + +
+ {i < labels.length - 1 && ( +
+ )} + + ); + })} +
+ ); +}; + +const Step1Basics: React.FC<{ + form: CreateFormState; + set: (k: K, v: CreateFormState[K]) => void; + totalScore: number; + onScoreChange: (dsa: number) => void; +}> = ({ form, set, totalScore, onScoreChange }) => ( + <> +
+ set("position", v)} + placeholder="Senior Backend Engineer" + /> + set("experience", v)} + placeholder="Mid / Senior / Staff" + /> +
+ set("description", v)} + placeholder="What the role is, what you're looking for, anything candidates need to know." + /> + +
+ set("submission_deadline", v)} + /> + set("start_time", v)} + /> + set("end_time", v)} + /> +
+ +
+ set("duration", parseInt(v) || 0)} + /> + set("resume_shortlist_score", parseFloat(v) || 0)} + /> +
+ + + {totalScore !== 100 && ( +
+ DSA + Dev weights must total 100 (currently {totalScore}). +
+ )} + + + +); + +const Step2Questions: React.FC<{ + questions: CreateFormState["questions"]; + setQ: (i: number, patch: Partial) => void; + addQ: () => void; + rmQ: (i: number) => void; +}> = ({ questions, setQ, addQ, rmQ }) => ( + <> + + {questions.map((q, i) => ( +
+
+ + Q{i + 1} + + {questions.length > 1 && rmQ(i)} />} +
+ setQ(i, { question: v })} + rows={2} + /> + setQ(i, { expected_answer: v })} + rows={2} + /> +
+ ))} + + Add question + +); + +const Step3Topics: React.FC<{ + topics: CreateFormState["dsa_topics"]; + setT: (i: number, patch: Partial) => void; + addT: () => void; + rmT: (i: number) => void; +}> = ({ topics, setT, addT, rmT }) => ( + <> + + {topics.map((t, i) => ( +
+ setT(i, { topic: v })} + placeholder="Two Pointers, Trees, DP…" + /> + setT(i, { difficulty: v })} + /> + {topics.length > 1 ? rmT(i)} /> :
} +
+ ))} + + Add another topic + +); + +// ── Step 1 helpers ────────────────────────────────────────────────────────── + +const WeightSlider: React.FC<{ + dsa: number; + dev: number; + onChange: (dsa: number) => void; +}> = ({ dsa, dev, onChange }) => { + const pct = Math.max(0, Math.min(100, dsa)); + return ( +
+
+ + Round weighting + + + Must total 100 + +
+ +
+ + +
+ +
+
+ onChange(parseInt(e.target.value, 10))} + aria-label="DSA versus Dev weight" + style={{ + position: "relative", + width: "100%", + height: 28, + appearance: "none", + WebkitAppearance: "none", + background: "transparent", + cursor: "pointer", + zIndex: 1, + }} + /> + +
+
+ 0 + 25 + 50 + 75 + 100 +
+
+ ); +}; + +const WeightChip: React.FC<{ + label: string; + value: number; + accent: string; +}> = ({ label, value, accent }) => ( +
+ + {label} + + + {value}% + +
+); + +// ── Step 3 helpers ────────────────────────────────────────────────────────── + +const AiGeneratesPanel: React.FC<{ topicCount: number }> = ({ topicCount }) => ( +
+
+
+ +
+
+
+ Questions are AI-generated per candidate +
+
+ Just pick the topics and difficulty. + When a candidate starts the coding round, the AI generates a unique + problem from your pool β€” so no two candidates see the same question. +
+
+
+ {topicCount} topic{topicCount === 1 ? "" : "s"} +
+
+
+); + +const DifficultyPicker: React.FC<{ + value: string; + onChange: (v: string) => void; +}> = ({ value, onChange }) => { + const opts: { value: string; label: string; color: string }[] = [ + { value: "easy", label: "Easy", color: "#10b981" }, + { value: "medium", label: "Medium", color: "#f59e0b" }, + { value: "hard", label: "Hard", color: "#ef4444" }, + ]; + return ( +
+
+ Difficulty +
+
+ {opts.map((o) => { + const active = o.value === value; + return ( + + ); + })} +
+
+ ); +}; + +// ── Shared step UI ────────────────────────────────────────────────────────── + +const InfoBox: React.FC<{ title: string; body: string }> = ({ + title, + body, +}) => ( +
+
+ {title} +
+
+ {body} +
+
+); + +const SparkleIcon = () => ( + + + + + +); + +// ── Header ─────────────────────────────────────────────────────────────────── + +const AdminHeader: React.FC<{ + displayName: string; + onHome: () => void; + onLogout: () => void; +}> = ({ displayName, onHome, onLogout }) => ( +
+
+ + +
+
+
+ {displayName.slice(0, 2).toUpperCase()} +
+ + {displayName} + +
+ +
+
+
+); + +// ── Shared utilities ──────────────────────────────────────────────────────── + +function isLive(i: OrgInterview) { + const now = Date.now(); + return ( + new Date(i.start_time).getTime() <= now && + now <= new Date(i.end_time).getTime() + ); +} +function isUpcoming(i: OrgInterview) { + return new Date(i.start_time).getTime() > Date.now(); +} +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString("en-IN", { + day: "numeric", + month: "short", + year: "numeric", + }); +} +function formatDateTime(iso: string) { + return new Date(iso).toLocaleString("en-IN", { + day: "numeric", + month: "short", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + }); +} +function scoreColor(s: number | null | undefined) { + if (s == null) return "#94a3b8"; + if (s >= 80) return "#10b981"; + if (s >= 60) return "#f59e0b"; + if (s >= 40) return "#f97316"; + return "#ef4444"; +} +function scoreBg(s: number | null | undefined) { + if (s == null) return "rgba(241,245,249,0.8)"; + if (s >= 80) return "rgba(209,250,229,0.8)"; + if (s >= 60) return "rgba(254,243,199,0.8)"; + if (s >= 40) return "rgba(255,237,213,0.8)"; + return "rgba(254,226,226,0.8)"; +} +function rankMedal(rank: number) { + if (rank === 1) return "πŸ₯‡"; + if (rank === 2) return "πŸ₯ˆ"; + if (rank === 3) return "πŸ₯‰"; + return null; +} +function addDays(d: Date, n: number) { + const out = new Date(d); + out.setDate(out.getDate() + n); + return out; +} +function toLocalInput(d: Date) { + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad( + d.getHours(), + )}:${pad(d.getMinutes())}`; +} +function toIso(local: string) { + return new Date(local).toISOString(); +} + +// ── Small UI pieces ───────────────────────────────────────────────────────── + +const Card: React.FC<{ title: string; children: React.ReactNode }> = ({ + title, + children, +}) => ( +
+
+ {title} +
+ {children} +
+); + +const KeyVal: React.FC<{ k: string; v: React.ReactNode }> = ({ k, v }) => ( +
+ {k} + {v} +
+); + +const AddBtn: React.FC<{ onClick: () => void; children: React.ReactNode }> = ({ + onClick, + children, +}) => ( + +); + +const RemoveBtn: React.FC<{ onClick: () => void }> = ({ onClick }) => ( + +); + +const Field: React.FC<{ + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + type?: string; + step?: string; +}> = ({ label, value, onChange, placeholder, type = "text", step }) => ( + +); + +const FieldArea: React.FC<{ + label: string; + value: string; + onChange: (v: string) => void; + placeholder?: string; + rows?: number; +}> = ({ label, value, onChange, placeholder, rows = 3 }) => ( +