norhan12 commited on
Commit
ba42f3b
·
verified ·
1 Parent(s): 0efd223

Company and candidate workspaces, job board, applications, hiring analytics

Browse files
README.md CHANGED
@@ -29,8 +29,13 @@ English and Arabic throughout, including right-to-left PDF reports.
29
  - **Optional camera presence** (eye contact, smiling, face in frame), computed on-device with MediaPipe. Only the numbers leave the browser, never the video.
30
  - **Progress tracking:** score history, skill breakdown, and a learning plan for your track.
31
 
 
 
32
  ### For hiring teams
33
- - Create a job with your own questions or AI-suggested ones and share **one link**.
 
 
 
34
  - Every applicant answers the **same questions with the same time limit**, whenever it suits them.
35
  - A **ranked candidate list** with scores, a hiring signal, the written assessment, recordings and transcripts.
36
  - Shortlist, reject, rate and add notes. **People make the decision**; EvalBot informs it.
 
29
  - **Optional camera presence** (eye contact, smiling, face in frame), computed on-device with MediaPipe. Only the numbers leave the browser, never the video.
30
  - **Progress tracking:** score history, skill breakdown, and a learning plan for your track.
31
 
32
+ - **A job board:** browse open roles from companies on the platform (search, track, level, on-site/remote/hybrid, job type), apply with one spoken interview, and follow each application's status.
33
+
34
  ### For hiring teams
35
+ - Separate **company accounts** with their own workspace: company profile, job posts and analytics. Company accounts can't apply to jobs, and job seekers can't see company pages.
36
+ - Post a job with location, work mode, job type, salary, skills and a deadline, listed **publicly on the job board** or **private (link only)**.
37
+ - Create the interview with your own questions or AI-suggested ones and share **one link**.
38
+ - A **hiring dashboard**: applicants, completion rate, average and median score, a 30-day applications chart, the hiring funnel (viewed → started → completed → shortlisted → hired), score distribution, EvalBot hiring signals, the **top 10 candidates** across all jobs, the candidate pool's delivery profile and skill averages, and a per-job table. Each job has the same analytics plus search, a minimum-score filter and **CSV export**.
39
  - Every applicant answers the **same questions with the same time limit**, whenever it suits them.
40
  - A **ranked candidate list** with scores, a hiring signal, the written assessment, recordings and transcripts.
41
  - Shortlist, reject, rate and add notes. **People make the decision**; EvalBot informs it.
frontend/src/components/Shell.tsx CHANGED
@@ -1,4 +1,4 @@
1
- import { Briefcase, History, LayoutDashboard, LogOut, Menu, Moon, Plus, Settings, Sun, UploadCloud, X } from "lucide-react";
2
  import { useState, type ReactNode } from "react";
3
  import { Link, NavLink, useNavigate } from "react-router-dom";
4
  import { useAuth } from "../lib/auth";
@@ -32,6 +32,7 @@ export function PublicHeader() {
32
  <header className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-5 sm:px-6">
33
  <Link to="/" aria-label="EvalBot home"><Logo /></Link>
34
  <nav className="flex items-center gap-1 sm:gap-2">
 
35
  <LangSwitch />
36
  <ThemeSwitch />
37
  {profile ? (
@@ -55,13 +56,13 @@ export function AppShell({ children }: { children: ReactNode }) {
55
  const isRecruiter = profile?.role === "recruiter";
56
  const links = isRecruiter
57
  ? [
 
58
  { to: "/app/jobs", icon: Briefcase, label: t("nav_jobs") },
59
- { to: "/app", icon: LayoutDashboard, label: t("nav_dashboard"), end: true },
60
- { to: "/app/new", icon: Plus, label: t("nav_new") },
61
- { to: "/app/history", icon: History, label: t("nav_history") },
62
  ]
63
  : [
64
  { to: "/app", icon: LayoutDashboard, label: t("nav_dashboard"), end: true },
 
 
65
  { to: "/app/new", icon: Plus, label: t("nav_new") },
66
  { to: "/app/history", icon: History, label: t("nav_history") },
67
  ];
 
1
+ import { Briefcase, FileCheck2, History, LayoutDashboard, LogOut, Menu, Moon, Plus, Search, Settings, Sun, UploadCloud, X } from "lucide-react";
2
  import { useState, type ReactNode } from "react";
3
  import { Link, NavLink, useNavigate } from "react-router-dom";
4
  import { useAuth } from "../lib/auth";
 
32
  <header className="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-5 sm:px-6">
33
  <Link to="/" aria-label="EvalBot home"><Logo /></Link>
34
  <nav className="flex items-center gap-1 sm:gap-2">
35
+ <Link to="/jobs" className="hidden rounded-xl px-3 py-2 text-sm font-medium text-ink hover:bg-sunken sm:inline-block">{t("browse_jobs")}</Link>
36
  <LangSwitch />
37
  <ThemeSwitch />
38
  {profile ? (
 
56
  const isRecruiter = profile?.role === "recruiter";
57
  const links = isRecruiter
58
  ? [
59
+ { to: "/app", icon: LayoutDashboard, label: t("nav_overview"), end: true },
60
  { to: "/app/jobs", icon: Briefcase, label: t("nav_jobs") },
 
 
 
61
  ]
62
  : [
63
  { to: "/app", icon: LayoutDashboard, label: t("nav_dashboard"), end: true },
64
+ { to: "/app/board", icon: Search, label: t("nav_board") },
65
+ { to: "/app/applications", icon: FileCheck2, label: t("nav_applications") },
66
  { to: "/app/new", icon: Plus, label: t("nav_new") },
67
  { to: "/app/history", icon: History, label: t("nav_history") },
68
  ];
frontend/src/components/hiring.tsx ADDED
@@ -0,0 +1,212 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Star } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { Link, useNavigate } from "react-router-dom";
4
+ import { Area, AreaChart, Bar, BarChart, CartesianGrid, Cell, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
5
+ import type { HiringStats } from "../lib/api";
6
+ import { useI18n } from "../lib/i18n";
7
+ import { Badge, Meter, Panel, scoreColor } from "./ui";
8
+
9
+ const tip = { background: "var(--color-surface)", border: "1px solid var(--color-line)", borderRadius: 12, color: "var(--color-ink)" };
10
+
11
+ export const signalTone = (s: string | null) => (s === "strong_yes" || s === "yes" ? "good" : s === "maybe" ? "warn" : s === "no" ? "bad" : "neutral");
12
+ export const reviewTone = { new: "neutral", shortlisted: "info", rejected: "bad", hired: "good" } as const;
13
+
14
+ export function Kpi({ label, value, sub, accent }: { label: string; value: ReactNode; sub?: ReactNode; accent?: boolean }) {
15
+ return (
16
+ <div className={`rounded-2xl border p-4 ${accent ? "border-amber bg-amber-soft" : "border-line bg-surface"}`}>
17
+ <p className="text-sm text-muted">{label}</p>
18
+ <p className="mt-1 font-display text-3xl font-semibold tabular text-ink">{value ?? "–"}</p>
19
+ {sub && <p className="mt-0.5 text-xs text-muted">{sub}</p>}
20
+ </div>
21
+ );
22
+ }
23
+
24
+ export function KpiGrid({ s }: { s: HiringStats["totals"] }) {
25
+ const { t } = useI18n();
26
+ return (
27
+ <div className="grid grid-cols-2 gap-3 md:grid-cols-4">
28
+ <Kpi label={t("k_applicants")} value={s.applicants} sub={s.views ? `${t("k_views")}: ${s.views}` : undefined} />
29
+ <Kpi label={t("k_completed")} value={s.completed} sub={s.completion_rate != null ? `${t("k_completion")}: ${s.completion_rate}%` : undefined} />
30
+ <Kpi label={t("k_avg")} value={s.avg_score != null ? Math.round(s.avg_score) : "–"}
31
+ sub={s.median_score != null ? `${t("k_median")}: ${Math.round(s.median_score)} · ${t("k_best")}: ${Math.round(s.best_score ?? 0)}` : t("out_of_100")} />
32
+ <Kpi label={t("k_to_review")} value={s.to_review} accent={s.to_review > 0}
33
+ sub={`${t("k_shortlisted")}: ${s.shortlisted} · ${t("k_hired")}: ${s.hired}`} />
34
+ </div>
35
+ );
36
+ }
37
+
38
+ export function Funnel({ data }: { data: HiringStats["funnel"] }) {
39
+ const { t } = useI18n();
40
+ const shown = data.filter((d) => d.stage !== "views" || d.count > 0);
41
+ const max = Math.max(1, ...shown.map((d) => d.count));
42
+ return (
43
+ <Panel title={t("funnel_title")}>
44
+ <ol className="space-y-3">
45
+ {shown.map((d, i) => {
46
+ const prev = i > 0 ? shown[i - 1].count : null;
47
+ const conv = prev && d.count <= prev ? Math.round((100 * d.count) / prev) : null;
48
+ return (
49
+ <li key={d.stage}>
50
+ <div className="mb-1 flex items-baseline justify-between gap-2 text-sm">
51
+ <span className="text-ink">{t(`fn_${d.stage}` as "fn_views")}</span>
52
+ <span className="tabular text-muted"><span className="font-semibold text-ink">{d.count}</span>{conv != null && ` · ${conv}%`}</span>
53
+ </div>
54
+ <div className="h-3 overflow-hidden rounded-full bg-sunken">
55
+ <div className="h-full rounded-full bg-nile" style={{ width: `${Math.max(2, (100 * d.count) / max)}%`, opacity: 1 - i * 0.13 }} />
56
+ </div>
57
+ </li>
58
+ );
59
+ })}
60
+ </ol>
61
+ </Panel>
62
+ );
63
+ }
64
+
65
+ export function Timeline({ data }: { data: HiringStats["timeline"] }) {
66
+ const { t, lang } = useI18n();
67
+ const fmt = (d: string) => new Date(d).toLocaleDateString(lang === "ar" ? "ar-EG" : "en-GB", { day: "numeric", month: "short" });
68
+ return (
69
+ <Panel title={t("timeline_title")}>
70
+ <div className="h-56" dir="ltr">
71
+ <ResponsiveContainer width="100%" height="100%">
72
+ <AreaChart data={data} margin={{ left: -24, right: 8, top: 8 }}>
73
+ <CartesianGrid stroke="var(--color-line)" vertical={false} />
74
+ <XAxis dataKey="date" tickFormatter={fmt} stroke="var(--color-muted)" fontSize={12} interval={6} />
75
+ <YAxis allowDecimals={false} stroke="var(--color-muted)" fontSize={12} />
76
+ <Tooltip contentStyle={tip} labelFormatter={(l) => fmt(String(l))} />
77
+ <Area type="monotone" dataKey="applicants" name={t("k_applicants")} stroke="var(--color-nile)" fill="var(--color-nile)" fillOpacity={0.15} strokeWidth={2} />
78
+ <Area type="monotone" dataKey="completed" name={t("k_completed")} stroke="var(--color-amber)" fill="var(--color-amber)" fillOpacity={0.12} strokeWidth={2} />
79
+ </AreaChart>
80
+ </ResponsiveContainer>
81
+ </div>
82
+ </Panel>
83
+ );
84
+ }
85
+
86
+ export function Distribution({ data }: { data: HiringStats["distribution"] }) {
87
+ const { t } = useI18n();
88
+ return (
89
+ <Panel title={t("dist_title")}>
90
+ <div className="h-56" dir="ltr">
91
+ <ResponsiveContainer width="100%" height="100%">
92
+ <BarChart data={data} margin={{ left: -24, right: 8, top: 8 }}>
93
+ <CartesianGrid stroke="var(--color-line)" vertical={false} />
94
+ <XAxis dataKey="range" stroke="var(--color-muted)" fontSize={11} />
95
+ <YAxis allowDecimals={false} stroke="var(--color-muted)" fontSize={12} />
96
+ <Tooltip contentStyle={tip} cursor={{ fill: "var(--color-sunken)" }} />
97
+ <Bar dataKey="count" name={t("k_applicants")} radius={[6, 6, 0, 0]}>
98
+ {data.map((d, i) => <Cell key={d.range} fill={scoreColor(i * 10 + 5)} />)}
99
+ </Bar>
100
+ </BarChart>
101
+ </ResponsiveContainer>
102
+ </div>
103
+ </Panel>
104
+ );
105
+ }
106
+
107
+ export function Signals({ data }: { data: HiringStats["signals"] }) {
108
+ const { t } = useI18n();
109
+ const order = ["strong_yes", "yes", "maybe", "no"] as const;
110
+ const colors = { strong_yes: "var(--color-nile-deep)", yes: "var(--color-nile)", maybe: "var(--color-amber)", no: "var(--color-danger)" };
111
+ const total = order.reduce((a, k) => a + data[k], 0);
112
+ return (
113
+ <Panel title={t("signals_title")}>
114
+ {total === 0 ? <p className="text-sm text-muted">–</p> : (
115
+ <>
116
+ <div className="flex h-4 overflow-hidden rounded-full bg-sunken" role="img" aria-label={t("signals_title")}>
117
+ {order.map((k) => data[k] > 0 && <div key={k} style={{ width: `${(100 * data[k]) / total}%`, background: colors[k] }} />)}
118
+ </div>
119
+ <ul className="mt-4 space-y-2">
120
+ {order.map((k) => (
121
+ <li key={k} className="flex items-center gap-2 text-sm">
122
+ <span className="h-3 w-3 flex-none rounded-sm" style={{ background: colors[k] }} />
123
+ <span className="text-ink">{t(`sig_${k}`)}</span>
124
+ <span className="ms-auto tabular text-muted">{data[k]} · {Math.round((100 * data[k]) / total)}%</span>
125
+ </li>
126
+ ))}
127
+ </ul>
128
+ </>
129
+ )}
130
+ </Panel>
131
+ );
132
+ }
133
+
134
+ export function TraitsPanel({ data }: { data: HiringStats["traits"] }) {
135
+ const { t } = useI18n();
136
+ if (!data.length) return null;
137
+ return (
138
+ <Panel title={t("traits_title")}>
139
+ <p className="-mt-2 mb-4 text-sm text-muted">{t("traits_sub")}</p>
140
+ <ul className="space-y-3">
141
+ {data.map((x) => (
142
+ <li key={x.trait}>
143
+ <div className="mb-1 flex justify-between gap-2 text-sm"><span className="text-ink">{x.label}</span><span className="tabular text-muted">{x.avg_percentile}%</span></div>
144
+ <Meter value={x.avg_percentile} color="var(--color-nile)" />
145
+ </li>
146
+ ))}
147
+ </ul>
148
+ </Panel>
149
+ );
150
+ }
151
+
152
+ export function SkillsPanel({ data }: { data: HiringStats["skills"] }) {
153
+ const { t } = useI18n();
154
+ if (!data.length) return null;
155
+ return (
156
+ <Panel title={t("skills_pool")}>
157
+ <ul className="space-y-3">
158
+ {data.map((s) => (
159
+ <li key={s.skill}>
160
+ <div className="mb-1 flex justify-between gap-2 text-sm"><span className="ltr truncate text-ink">{s.skill}</span><span className="tabular text-muted">{s.avg}/10 · {s.n}</span></div>
161
+ <Meter value={s.avg} max={10} />
162
+ </li>
163
+ ))}
164
+ </ul>
165
+ </Panel>
166
+ );
167
+ }
168
+
169
+ export function TopCandidates({ rows, showJob = true }: { rows: HiringStats["top"]; showJob?: boolean }) {
170
+ const { t } = useI18n();
171
+ const nav = useNavigate();
172
+ return (
173
+ <Panel title={t("top_title")}>
174
+ {rows.length === 0 ? <p className="py-4 text-center text-muted">{t("empty_candidates")}</p> : (
175
+ <div className="-mx-5 overflow-x-auto sm:-mx-6">
176
+ <table className="w-full min-w-[600px] text-[15px]">
177
+ <thead>
178
+ <tr className="border-b border-line text-sm text-muted">
179
+ <th className="px-5 py-2 text-start font-medium sm:px-6">#</th>
180
+ <th className="py-2 text-start font-medium">{t("c_name")}</th>
181
+ {showJob && <th className="py-2 text-start font-medium">{t("c_job")}</th>}
182
+ <th className="py-2 text-start font-medium">{t("c_score")}</th>
183
+ <th className="py-2 text-start font-medium">{t("c_signal")}</th>
184
+ <th className="px-5 py-2 text-start font-medium sm:px-6">{t("c_status")}</th>
185
+ </tr>
186
+ </thead>
187
+ <tbody>
188
+ {rows.map((r, i) => (
189
+ <tr key={r.id} onClick={() => nav(`/app/report/${r.id}`)} className="cursor-pointer border-b border-line last:border-0 hover:bg-sunken/60">
190
+ <td className="px-5 py-3 sm:px-6">
191
+ <span className={`inline-flex h-7 w-7 items-center justify-center rounded-full text-sm font-semibold tabular ${i < 3 ? "bg-amber-soft text-ink" : "text-muted"}`}>{i + 1}</span>
192
+ </td>
193
+ <td className="py-3">
194
+ <p className="font-medium text-ink">{r.full_name || r.email}</p>
195
+ <p className="flex items-center gap-2 text-xs text-muted">
196
+ {r.candidate_headline}
197
+ {r.rating && <span className="flex">{Array.from({ length: r.rating }).map((_, k) => <Star key={k} size={11} className="fill-amber text-amber" />)}</span>}
198
+ </p>
199
+ </td>
200
+ {showJob && <td className="py-3"><Link to={`/app/jobs/${r.job_id}`} onClick={(e) => e.stopPropagation()} className="text-sm text-ink hover:text-nile">{r.job_title}</Link></td>}
201
+ <td className="py-3"><span className="font-display text-lg font-semibold tabular" style={{ color: scoreColor(r.overall_score) }}>{r.overall_score != null ? Math.round(r.overall_score) : "–"}</span></td>
202
+ <td className="py-3">{r.hiring_signal && <Badge tone={signalTone(r.hiring_signal)}>{t(`sig_${r.hiring_signal}` as "sig_yes")}</Badge>}</td>
203
+ <td className="px-5 py-3 sm:px-6"><Badge tone={reviewTone[r.review_status]}>{t(`rv_${r.review_status}`)}</Badge></td>
204
+ </tr>
205
+ ))}
206
+ </tbody>
207
+ </table>
208
+ </div>
209
+ )}
210
+ </Panel>
211
+ );
212
+ }
frontend/src/components/jobs.tsx ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Banknote, Building2, CalendarClock, MapPin, Users } from "lucide-react";
2
+ import type { ReactNode } from "react";
3
+ import { Link } from "react-router-dom";
4
+ import type { PublicJob } from "../lib/api";
5
+ import { trackName, useI18n } from "../lib/i18n";
6
+ import { Badge, fmtDate, useCatalog } from "./ui";
7
+
8
+ export function Chip({ icon, children }: { icon?: ReactNode; children: ReactNode }) {
9
+ return <span className="inline-flex items-center gap-1.5 text-sm text-muted">{icon}{children}</span>;
10
+ }
11
+
12
+ /** Location, work mode, job type, salary and deadline as a row of small facts. */
13
+ export function JobFacts({ job, showDeadline = true }: { job: Pick<PublicJob, "location" | "work_mode" | "employment_type" | "salary" | "deadline">; showDeadline?: boolean }) {
14
+ const { t, lang } = useI18n();
15
+ return (
16
+ <div className="flex flex-wrap items-center gap-x-4 gap-y-1.5">
17
+ {job.location && <Chip icon={<MapPin size={15} />}>{job.location}</Chip>}
18
+ <Badge tone="info">{t(`wm_${job.work_mode}` as "wm_onsite")}</Badge>
19
+ <Badge>{t(`et_${job.employment_type}` as "et_full_time")}</Badge>
20
+ {job.salary && <Chip icon={<Banknote size={15} />}>{job.salary}</Chip>}
21
+ {showDeadline && job.deadline && <Chip icon={<CalendarClock size={15} />}>{t("deadline", { d: fmtDate(job.deadline, lang) })}</Chip>}
22
+ </div>
23
+ );
24
+ }
25
+
26
+ export function Skills({ skills }: { skills: string[] }) {
27
+ if (!skills?.length) return null;
28
+ return (
29
+ <ul className="flex flex-wrap gap-1.5">
30
+ {skills.map((s) => <li key={s} className="ltr rounded-lg border border-line px-2 py-0.5 text-sm text-ink">{s}</li>)}
31
+ </ul>
32
+ );
33
+ }
34
+
35
+ export function CompanyMark({ name, size = 44 }: { name: string; size?: number }) {
36
+ const initials = name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
37
+ return (
38
+ <span className="flex flex-none items-center justify-center rounded-xl bg-nile-soft font-display font-semibold text-nile-deep"
39
+ style={{ width: size, height: size, fontSize: size * 0.36 }} aria-hidden="true">
40
+ {initials || <Building2 size={size * 0.45} />}
41
+ </span>
42
+ );
43
+ }
44
+
45
+ export function JobCard({ job, applied }: { job: PublicJob; applied?: boolean }) {
46
+ const { t, lang } = useI18n();
47
+ const catalog = useCatalog();
48
+ return (
49
+ <li>
50
+ <Link to={`/apply/${job.share_token}`}
51
+ className="group flex gap-4 rounded-2xl border border-line bg-surface p-5 transition-colors hover:border-nile">
52
+ <CompanyMark name={job.company} />
53
+ <div className="min-w-0 flex-1 space-y-2.5">
54
+ <div className="flex flex-wrap items-start justify-between gap-2">
55
+ <div className="min-w-0">
56
+ <h3 className="text-lg font-semibold text-ink group-hover:text-nile">{job.title}</h3>
57
+ <p className="text-[15px] text-muted">
58
+ {job.company}{job.industry ? `, ${job.industry}` : ""} · {trackName(catalog?.tracks, job.track, lang)} · {t(`lv_${job.level}` as "lv_junior")}
59
+ </p>
60
+ </div>
61
+ {applied && <Badge tone="good">{t("applied")}</Badge>}
62
+ </div>
63
+ <JobFacts job={job} />
64
+ {job.snippet && <p className="line-clamp-2 text-[15px] text-ink/85">{job.snippet}</p>}
65
+ <div className="flex flex-wrap items-center justify-between gap-3">
66
+ <Skills skills={job.skills.slice(0, 6)} />
67
+ <span className="flex items-center gap-3 text-sm text-muted">
68
+ {!!job.applicants && <Chip icon={<Users size={14} />}>{t("n_applicants", { n: job.applicants })}</Chip>}
69
+ {t("posted", { d: fmtDate(job.created_at, lang) })}
70
+ </span>
71
+ </div>
72
+ </div>
73
+ </Link>
74
+ </li>
75
+ );
76
+ }
frontend/src/lib/api.ts CHANGED
@@ -52,9 +52,11 @@ export type Profile = {
52
  headline: string | null;
53
  target_track: string | null;
54
  locale: string;
55
- company?: { id: string; name: string; website?: string | null } | null;
56
  };
57
 
 
 
58
  export type Track = { id: string; en: string; ar: string; group: string };
59
  export type Catalog = { tracks: Track[]; levels: string[]; types: string[] };
60
 
@@ -158,6 +160,55 @@ export type Job = {
158
  created_at: string;
159
  applicants?: number;
160
  completed?: number;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
161
  };
162
 
163
  export type CandidateRow = {
 
52
  headline: string | null;
53
  target_track: string | null;
54
  locale: string;
55
+ company?: Company | null;
56
  };
57
 
58
+ export type Company = { id: string; name: string; website?: string | null; about?: string | null; industry?: string | null; size?: string | null; location?: string | null };
59
+
60
  export type Track = { id: string; en: string; ar: string; group: string };
61
  export type Catalog = { tracks: Track[]; levels: string[]; types: string[] };
62
 
 
160
  created_at: string;
161
  applicants?: number;
162
  completed?: number;
163
+ location: string | null;
164
+ work_mode: WorkMode;
165
+ employment_type: EmploymentType;
166
+ salary: string | null;
167
+ skills: string[];
168
+ deadline: string | null;
169
+ visibility: "public" | "link";
170
+ views?: number;
171
+ };
172
+
173
+ export type WorkMode = "onsite" | "remote" | "hybrid";
174
+ export type EmploymentType = "full_time" | "part_time" | "internship" | "contract";
175
+
176
+ /** A job as candidates see it (job board and apply page). */
177
+ export type PublicJob = {
178
+ id: string; title: string; track: string; level: string; location: string | null; work_mode: WorkMode;
179
+ employment_type: EmploymentType; salary: string | null; skills: string[]; deadline: string | null; share_token: string;
180
+ created_at: string; answer_seconds: number; question_count: number; company: string; website: string | null;
181
+ industry: string | null; company_size: string | null; company_about: string | null; company_location: string | null;
182
+ snippet?: string; applicants?: number; description?: string | null; status?: string; accepting?: boolean;
183
+ };
184
+
185
+ export type Application = {
186
+ id: string; status: Session["status"]; overall_score: number | null; created_at: string; completed_at: string | null;
187
+ title: string; share_token: string; location: string | null; work_mode: WorkMode; employment_type: EmploymentType;
188
+ company: string; review_status: "new" | "shortlisted" | "rejected" | "hired"; answered: number; planned: number;
189
+ };
190
+
191
+ export type TopCandidate = {
192
+ id: string; overall_score: number | null; completed_at: string; content_score: string | null; delivery_score: string | null;
193
+ hiring_signal: string | null; headline: string | null; full_name: string | null; email: string | null;
194
+ candidate_headline: string | null; job_id: string; job_title: string; review_status: CandidateRow["review_status"]; rating: number | null;
195
+ };
196
+
197
+ export type HiringStats = {
198
+ totals: {
199
+ jobs: number; open_jobs: number; views: number; applicants: number; people: number; completed: number; in_progress: number;
200
+ shortlisted: number; hired: number; rejected: number; to_review: number; avg_score: number | null; median_score: number | null;
201
+ best_score: number | null; completion_rate: number | null; avg_minutes: number | null;
202
+ };
203
+ funnel: { stage: "views" | "started" | "completed" | "shortlisted" | "hired"; count: number }[];
204
+ timeline: { date: string; applicants: number; completed: number }[];
205
+ distribution: { range: string; count: number }[];
206
+ signals: Record<"strong_yes" | "yes" | "maybe" | "no", number>;
207
+ traits: { trait: string; label: string; avg_percentile: number; n: number }[];
208
+ skills: { skill: string; avg: number; n: number }[];
209
+ top: TopCandidate[];
210
+ jobs: { id: string; title: string; status: Job["status"]; views: number; created_at: string; deadline: string | null; accepting: boolean;
211
+ applicants: number; completed: number; avg_score: number | null; best_score: number | null; shortlisted: number; hired: number }[];
212
  };
213
 
214
  export type CandidateRow = {
frontend/src/lib/i18n.tsx CHANGED
@@ -109,6 +109,39 @@ const en = {
109
  settings_title: "Settings", profile: "Profile", headline: "Headline", headline_ph: "e.g. Data engineering graduate",
110
  target_track: "Target track", new_password: "New password", change_password: "Change password", password_changed: "Password changed",
111
  company: "Company",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
112
  };
113
 
114
  type Dict = typeof en;
@@ -222,6 +255,39 @@ const ar: Dict = {
222
  settings_title: "الإعدادات", profile: "الملف الشخصي", headline: "نبذة قصيرة", headline_ph: "مثال: خريج هندسة بيانات",
223
  target_track: "المجال المستهدف", new_password: "كلمة مرور جديدة", change_password: "تغيير كلمة المرور", password_changed: "تم تغيير كلمة المرور",
224
  company: "الشركة",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
225
  };
226
 
227
  export type Lang = "en" | "ar";
 
109
  settings_title: "Settings", profile: "Profile", headline: "Headline", headline_ph: "e.g. Data engineering graduate",
110
  target_track: "Target track", new_password: "New password", change_password: "Change password", password_changed: "Password changed",
111
  company: "Company",
112
+
113
+ nav_board: "Find jobs", nav_applications: "My applications", nav_overview: "Overview", nav_practice: "Practice",
114
+ browse_jobs: "Browse jobs",
115
+ board_title: "Find your next job", board_sub: "Open roles from companies hiring with EvalBot. Each one starts with a short spoken interview you can take whenever it suits you.",
116
+ board_search: "Search by title, skill, company or city", any_track: "Any track", any_level: "Any level", any_mode: "Any work mode",
117
+ any_type: "Any job type", empty_board: "No open jobs match your search right now. Try fewer filters, or practice for your track meanwhile.",
118
+ wm_onsite: "On-site", wm_remote: "Remote", wm_hybrid: "Hybrid",
119
+ et_full_time: "Full-time", et_part_time: "Part-time", et_internship: "Internship", et_contract: "Contract",
120
+ applied: "Applied", view_job: "View job", posted: "Posted {d}", deadline: "Apply by {d}", n_applicants: "{n} applicants",
121
+ open_roles: "Open roles", see_all_jobs: "See all jobs",
122
+ apps_title: "My applications", apps_empty: "You haven't applied to a job yet.", app_status: "Application status",
123
+ as_new: "Under review", as_shortlisted: "Shortlisted", as_rejected: "Not selected", as_hired: "Offer stage",
124
+ as_unfinished: "Interview not finished", as_processing: "Interview being scored", finish_interview: "Finish interview",
125
+ company_cant_apply: "You're signed in with a company account. Use a personal account to apply to jobs.",
126
+ about_company: "About {company}", skills_req: "Skills", salary: "Salary", location: "Location", work_mode: "Work mode",
127
+ employment_type: "Job type", visibility: "Who can see it", vis_public: "Public: listed on the EvalBot job board",
128
+ vis_link: "Private: only people with the link", skills_ph: "Python, SQL, Spark", salary_ph: "e.g. EGP 25k-35k / month",
129
+ location_ph: "e.g. Cairo, Egypt", deadline_l: "Application deadline", job_basics: "Job post", job_interview: "Interview",
130
+ industry: "Industry", company_size: "Company size", company_about: "About the company", company_loc: "Headquarters",
131
+
132
+ co_hello: "{company} hiring overview", co_sub: "Every number below updates as candidates finish their interviews.",
133
+ k_open_jobs: "Open jobs", k_applicants: "Applicants", k_completed: "Interviews completed", k_avg: "Average score",
134
+ k_to_review: "Waiting for your review", k_shortlisted: "Shortlisted", k_hired: "Hired", k_completion: "Completion rate",
135
+ k_views: "Job views", k_median: "Median score", k_best: "Best score", k_minutes: "Avg. interview length",
136
+ funnel_title: "Hiring funnel", fn_views: "Viewed the job", fn_started: "Started the interview", fn_completed: "Completed",
137
+ fn_shortlisted: "Shortlisted", fn_hired: "Hired",
138
+ timeline_title: "Applications in the last 30 days", dist_title: "Score distribution", signals_title: "EvalBot hiring signal",
139
+ top_title: "Top 10 candidates", traits_title: "Candidate pool: delivery profile",
140
+ traits_sub: "Average position of your candidates among the 138 human-rated reference interviews.",
141
+ skills_pool: "Skills across all answers", jobs_table: "Jobs at a glance", export_csv: "Export CSV", c_job: "Job",
142
+ empty_company: "Post your first job to start receiving interviews. Public jobs appear on the EvalBot job board, and every job gets a link you can share anywhere.",
143
+ job_stats: "This job in numbers", search_candidates: "Search candidates", min_score: "Minimum score",
144
+ co_new_job_cta: "Post a job", out_of_100: "out of 100",
145
  };
146
 
147
  type Dict = typeof en;
 
255
  settings_title: "الإعدادات", profile: "الملف الشخصي", headline: "نبذة قصيرة", headline_ph: "مثال: خريج هندسة بيانات",
256
  target_track: "المجال المستهدف", new_password: "كلمة مرور جديدة", change_password: "تغيير كلمة المرور", password_changed: "تم تغيير كلمة المرور",
257
  company: "الشركة",
258
+
259
+ nav_board: "ابحث عن وظيفة", nav_applications: "طلباتي", nav_overview: "نظرة عامة", nav_practice: "تدريب",
260
+ browse_jobs: "تصفّح الوظائف",
261
+ board_title: "ابحث عن وظيفتك القادمة", board_sub: "وظائف مفتوحة من شركات توظّف باستخدام EvalBot. كل وظيفة تبدأ بمقابلة صوتية قصيرة تجريها في الوقت الذي يناسبك.",
262
+ board_search: "ابحث بالمسمى أو المهارة أو الشركة أو المدينة", any_track: "كل المجالات", any_level: "كل المستويات", any_mode: "كل أنماط العمل",
263
+ any_type: "كل أنواع الوظائف", empty_board: "لا توجد وظائف مفتوحة تطابق بحثك الآن. جرّب فلاتر أقل، أو تدرّب على مجالك في الأثناء.",
264
+ wm_onsite: "من المقر", wm_remote: "عن بُعد", wm_hybrid: "هجين",
265
+ et_full_time: "دوام كامل", et_part_time: "دوام جزئي", et_internship: "تدريب", et_contract: "عقد مؤقت",
266
+ applied: "تقدّمت", view_job: "عرض الوظيفة", posted: "نُشرت {d}", deadline: "التقديم حتى {d}", n_applicants: "{n} متقدم",
267
+ open_roles: "وظائف مفتوحة", see_all_jobs: "كل الوظائف",
268
+ apps_title: "طلباتي", apps_empty: "لم تتقدم إلى أي وظيفة بعد.", app_status: "حالة الطلب",
269
+ as_new: "قيد المراجعة", as_shortlisted: "في القائمة المختصرة", as_rejected: "لم يتم الاختيار", as_hired: "مرحلة العرض الوظيفي",
270
+ as_unfinished: "المقابلة لم تكتمل", as_processing: "جارٍ تقييم المقابلة", finish_interview: "أكمل المقابلة",
271
+ company_cant_apply: "أنت مسجّل بحساب شركة. استخدم حساباً شخصياً للتقدم إلى الوظائف.",
272
+ about_company: "عن {company}", skills_req: "المهارات", salary: "الراتب", location: "المكان", work_mode: "نمط العمل",
273
+ employment_type: "نوع الوظيفة", visibility: "من يستطيع رؤيتها", vis_public: "عامة: تظهر في لوحة وظائف EvalBot",
274
+ vis_link: "خاصة: لمن لديه الرابط فقط", skills_ph: "Python, SQL, Spark", salary_ph: "مثال: 25-35 ألف جنيه شهرياً",
275
+ location_ph: "مثال: القاهرة، مصر", deadline_l: "آخر موعد للتقديم", job_basics: "إعلان الوظيفة", job_interview: "المقابلة",
276
+ industry: "القطاع", company_size: "حجم الشركة", company_about: "عن الشركة", company_loc: "المقر الرئيسي",
277
+
278
+ co_hello: "نظرة على التوظيف في {company}", co_sub: "كل الأرقام هنا تتحدّث تلقائياً كلما أنهى مرشح مقابلته.",
279
+ k_open_jobs: "وظائف مفتوحة", k_applicants: "المتقدمون", k_completed: "مقابلات مكتملة", k_avg: "متوسط الدرجة",
280
+ k_to_review: "بانتظار مراجعتك", k_shortlisted: "في القائمة المختصرة", k_hired: "تم تعيينهم", k_completion: "نسبة الإكمال",
281
+ k_views: "مشاهدات الوظائف", k_median: "الدرجة الوسيطة", k_best: "أعلى درجة", k_minutes: "متوسط مدة المقابلة",
282
+ funnel_title: "مسار التوظيف", fn_views: "شاهدوا الوظيفة", fn_started: "بدأوا المقابلة", fn_completed: "أكملوها",
283
+ fn_shortlisted: "في القائمة المختصرة", fn_hired: "تم تعيينهم",
284
+ timeline_title: "الطلبات في آخر 30 يوماً", dist_title: "توزيع الدرجات", signals_title: "مؤشر التوظيف من EvalBot",
285
+ top_title: "أفضل 10 مرشحين", traits_title: "المرشحون: ملف الأداء",
286
+ traits_sub: "متوسط موقع مرشحيك بين 138 مقابلة مرجعية قيّمها بشر.",
287
+ skills_pool: "المهارات عبر كل الإجابات", jobs_table: "الوظائف باختصار", export_csv: "تصدير CSV", c_job: "الوظيفة",
288
+ empty_company: "انشر أول وظيفة لتبدأ في استقبال المقابلات. الوظائف العامة تظهر في لوحة وظائف EvalBot، ولكل وظيفة رابط تشاركه في أي مكان.",
289
+ job_stats: "الوظيفة بالأرقام", search_candidates: "ابحث في المرشحين", min_score: "أقل درجة",
290
+ co_new_job_cta: "انشر وظيفة", out_of_100: "من 100",
291
  };
292
 
293
  export type Lang = "en" | "ar";
frontend/src/main.tsx CHANGED
@@ -6,7 +6,9 @@ import { Spinner } from "./components/ui";
6
  import "./index.css";
7
  import { AuthProvider, useAuth } from "./lib/auth";
8
  import { I18nProvider } from "./lib/i18n";
 
9
  import Apply from "./pages/Apply";
 
10
  import Auth from "./pages/Auth";
11
  import Dashboard from "./pages/Dashboard";
12
  import History from "./pages/History";
@@ -28,14 +30,23 @@ try {
28
  /* ignore */
29
  }
30
 
31
- function Private({ children, bare }: { children: ReactNode; bare?: boolean }) {
 
32
  const { ready, profile } = useAuth();
33
  const loc = useLocation();
34
  if (!ready) return <Spinner />;
35
  if (!profile) return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname + loc.search)}`} replace />;
 
36
  return bare ? <>{children}</> : <AppShell>{children}</AppShell>;
37
  }
38
 
 
 
 
 
 
 
 
39
  createRoot(document.getElementById("root")!).render(
40
  <StrictMode>
41
  <I18nProvider>
@@ -45,18 +56,21 @@ createRoot(document.getElementById("root")!).render(
45
  <Route path="/" element={<Landing />} />
46
  <Route path="/login" element={<Auth mode="signin" />} />
47
  <Route path="/signup" element={<Auth mode="signup" />} />
 
48
  <Route path="/apply/:token" element={<Apply />} />
49
  <Route path="/app/welcome" element={<Private bare><Onboarding /></Private>} />
50
  <Route path="/app" element={<Private><Dashboard /></Private>} />
51
- <Route path="/app/new" element={<Private><NewInterview /></Private>} />
52
- <Route path="/app/history" element={<Private><History /></Private>} />
 
 
53
  <Route path="/app/interview/:id" element={<Private bare><InterviewRoom /></Private>} />
54
  <Route path="/app/report/:id" element={<Private><ReportPage /></Private>} />
55
  <Route path="/app/settings" element={<Private><SettingsPage /></Private>} />
56
- <Route path="/app/jobs" element={<Private><Jobs /></Private>} />
57
- <Route path="/app/jobs/new" element={<Private><JobEditor /></Private>} />
58
- <Route path="/app/jobs/:id/edit" element={<Private><JobEditor /></Private>} />
59
- <Route path="/app/jobs/:id" element={<Private><JobDetail /></Private>} />
60
  <Route path="*" element={<Navigate to="/" replace />} />
61
  </Routes>
62
  </BrowserRouter>
 
6
  import "./index.css";
7
  import { AuthProvider, useAuth } from "./lib/auth";
8
  import { I18nProvider } from "./lib/i18n";
9
+ import Applications from "./pages/Applications";
10
  import Apply from "./pages/Apply";
11
+ import Board from "./pages/Board";
12
  import Auth from "./pages/Auth";
13
  import Dashboard from "./pages/Dashboard";
14
  import History from "./pages/History";
 
30
  /* ignore */
31
  }
32
 
33
+ /** Signed-in pages. `role` keeps company accounts and job seekers in their own parts of the app. */
34
+ function Private({ children, bare, role }: { children: ReactNode; bare?: boolean; role?: "candidate" | "recruiter" }) {
35
  const { ready, profile } = useAuth();
36
  const loc = useLocation();
37
  if (!ready) return <Spinner />;
38
  if (!profile) return <Navigate to={`/login?next=${encodeURIComponent(loc.pathname + loc.search)}`} replace />;
39
+ if (role && profile.role !== role) return <Navigate to="/app" replace />;
40
  return bare ? <>{children}</> : <AppShell>{children}</AppShell>;
41
  }
42
 
43
+ /** The job board is public; signed-in job seekers see it inside the app. */
44
+ function JobsPublic() {
45
+ const { ready, profile } = useAuth();
46
+ if (!ready) return <Spinner />;
47
+ return profile?.role === "candidate" ? <Navigate to="/app/board" replace /> : <Board />;
48
+ }
49
+
50
  createRoot(document.getElementById("root")!).render(
51
  <StrictMode>
52
  <I18nProvider>
 
56
  <Route path="/" element={<Landing />} />
57
  <Route path="/login" element={<Auth mode="signin" />} />
58
  <Route path="/signup" element={<Auth mode="signup" />} />
59
+ <Route path="/jobs" element={<JobsPublic />} />
60
  <Route path="/apply/:token" element={<Apply />} />
61
  <Route path="/app/welcome" element={<Private bare><Onboarding /></Private>} />
62
  <Route path="/app" element={<Private><Dashboard /></Private>} />
63
+ <Route path="/app/new" element={<Private role="candidate"><NewInterview /></Private>} />
64
+ <Route path="/app/history" element={<Private role="candidate"><History /></Private>} />
65
+ <Route path="/app/board" element={<Private role="candidate"><Board inApp /></Private>} />
66
+ <Route path="/app/applications" element={<Private role="candidate"><Applications /></Private>} />
67
  <Route path="/app/interview/:id" element={<Private bare><InterviewRoom /></Private>} />
68
  <Route path="/app/report/:id" element={<Private><ReportPage /></Private>} />
69
  <Route path="/app/settings" element={<Private><SettingsPage /></Private>} />
70
+ <Route path="/app/jobs" element={<Private role="recruiter"><Jobs /></Private>} />
71
+ <Route path="/app/jobs/new" element={<Private role="recruiter"><JobEditor /></Private>} />
72
+ <Route path="/app/jobs/:id/edit" element={<Private role="recruiter"><JobEditor /></Private>} />
73
+ <Route path="/app/jobs/:id" element={<Private role="recruiter"><JobDetail /></Private>} />
74
  <Route path="*" element={<Navigate to="/" replace />} />
75
  </Routes>
76
  </BrowserRouter>
frontend/src/pages/Applications.tsx ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { useEffect, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
+ import { CompanyMark, JobFacts } from "../components/jobs";
4
+ import { Badge, Spinner, fmtDate, scoreColor } from "../components/ui";
5
+ import { api, type Application } from "../lib/api";
6
+ import { useI18n } from "../lib/i18n";
7
+
8
+ export default function Applications() {
9
+ const { t, lang } = useI18n();
10
+ const [rows, setRows] = useState<Application[] | null>(null);
11
+ useEffect(() => {
12
+ api.get<Application[]>("/applications").then(setRows).catch(() => setRows([]));
13
+ }, []);
14
+ if (!rows) return <Spinner />;
15
+
16
+ const status = (a: Application): [string, "neutral" | "good" | "warn" | "bad" | "info"] => {
17
+ if (a.status === "in_progress") return [t("as_unfinished"), "warn"];
18
+ if (a.status === "processing") return [t("as_processing"), "info"];
19
+ if (a.status === "failed") return [t("st_failed"), "bad"];
20
+ return { new: [t("as_new"), "neutral"], shortlisted: [t("as_shortlisted"), "good"], rejected: [t("as_rejected"), "bad"], hired: [t("as_hired"), "good"] }[a.review_status] as [string, "neutral"];
21
+ };
22
+
23
+ return (
24
+ <div className="space-y-6">
25
+ <div className="flex flex-wrap items-center justify-between gap-3">
26
+ <h1 className="text-3xl font-semibold text-ink">{t("apps_title")}</h1>
27
+ <Link to="/app/board" className="rounded-xl bg-nile px-4 py-2.5 text-sm font-medium text-white dark:text-paper">{t("browse_jobs")}</Link>
28
+ </div>
29
+ {rows.length === 0 ? (
30
+ <p className="rounded-2xl border border-dashed border-line p-8 text-center text-muted">{t("apps_empty")}</p>
31
+ ) : (
32
+ <ul className="space-y-3">
33
+ {rows.map((a) => {
34
+ const [label, tone] = status(a);
35
+ return (
36
+ <li key={a.id} className="flex flex-col gap-4 rounded-2xl border border-line bg-surface p-5 sm:flex-row sm:items-center">
37
+ <CompanyMark name={a.company} />
38
+ <div className="min-w-0 flex-1 space-y-2">
39
+ <div>
40
+ <Link to={`/apply/${a.share_token}`} className="text-lg font-semibold text-ink hover:text-nile">{a.title}</Link>
41
+ <p className="text-[15px] text-muted">{a.company} · {fmtDate(a.created_at, lang)}</p>
42
+ </div>
43
+ <JobFacts job={{ ...a, salary: null, deadline: null }} showDeadline={false} />
44
+ </div>
45
+ <div className="flex items-center gap-4 sm:flex-col sm:items-end sm:gap-2">
46
+ <Badge tone={tone}>{label}</Badge>
47
+ {a.status === "in_progress" ? (
48
+ <Link to={`/app/interview/${a.id}`} className="text-sm font-medium text-nile hover:underline">{t("finish_interview")}</Link>
49
+ ) : a.status === "completed" ? (
50
+ <Link to={`/app/report/${a.id}`} className="flex items-baseline gap-1.5 text-sm font-medium text-nile hover:underline">
51
+ <span className="font-display text-xl font-semibold tabular" style={{ color: scoreColor(a.overall_score) }}>{a.overall_score != null ? Math.round(a.overall_score) : "–"}</span>
52
+ {t("view_report")}
53
+ </Link>
54
+ ) : null}
55
+ </div>
56
+ </li>
57
+ );
58
+ })}
59
+ </ul>
60
+ )}
61
+ </div>
62
+ );
63
+ }
frontend/src/pages/Apply.tsx CHANGED
@@ -1,65 +1,101 @@
1
- import { Clock, ListChecks, Mic } from "lucide-react";
2
  import { useEffect, useState } from "react";
3
  import { Link, useNavigate, useParams } from "react-router-dom";
4
- import { PublicHeader } from "../components/Shell";
5
- import { Button, ErrorNote, Spinner, Waveform } from "../components/ui";
6
- import { api, type Session } from "../lib/api";
 
7
  import { useAuth } from "../lib/auth";
8
- import { useI18n } from "../lib/i18n";
9
-
10
- type PublicJob = { id: string; title: string; company: string; description: string | null; answer_seconds: number; status: string; question_count: number };
11
 
12
  export default function Apply() {
13
  const { token } = useParams();
14
  const { t, lang } = useI18n();
15
  const { profile, ready } = useAuth();
 
16
  const nav = useNavigate();
17
  const [job, setJob] = useState<PublicJob | null>(null);
 
18
  const [error, setError] = useState<string | null>(null);
19
  const [busy, setBusy] = useState(false);
20
- const [done, setDone] = useState(false);
21
 
22
  useEffect(() => {
23
  api.get<PublicJob>(`/public/jobs/${token}`).then(setJob).catch((e) => setError(e.message));
24
  }, [token]);
 
 
 
 
 
25
 
26
  const start = async () => {
27
  setBusy(true);
28
  try {
29
  const s = await api.post<Session>(`/jobs/apply/${token}`, { language: lang });
30
- if (s.status === "in_progress") nav(`/app/interview/${s.id}`);
31
- else setDone(true);
32
  } catch (e: any) {
33
  setError(e.message);
34
- } finally {
35
  setBusy(false);
36
  }
37
  };
38
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
39
  return (
40
  <div className="min-h-screen">
41
  <PublicHeader />
42
- <main className="mx-auto max-w-2xl px-4 pb-16 pt-8">
43
- {error ? <ErrorNote>{error}</ErrorNote> : !job || !ready ? <Spinner /> : (
44
- <div className="rounded-3xl border border-line bg-surface p-6 sm:p-10">
45
- <div className="h-10 w-40"><Waveform bars={20} color="var(--color-nile)" /></div>
46
- <h1 className="mt-6 text-3xl font-semibold text-ink">{t("apply_title", { company: job.company })}</h1>
47
- <p className="mt-1 text-xl text-muted">{t("apply_for", { title: job.title })}</p>
48
- {job.description && <p className="mt-6 whitespace-pre-line text-[15px] text-ink">{job.description}</p>}
49
- <ul className="mt-6 space-y-2 text-[15px] text-ink">
50
- <li className="flex items-center gap-3"><ListChecks size={18} className="text-nile" /> {t("apply_q", { n: job.question_count })}</li>
51
- <li className="flex items-center gap-3"><Clock size={18} className="text-nile" /> {t("apply_time", { m: Math.round(job.answer_seconds / 60) })}</li>
52
- <li className="flex items-start gap-3"><Mic size={18} className="mt-0.5 flex-none text-nile" /> {t("apply_how")}</li>
53
- </ul>
54
- <div className="mt-8">
55
- {job.status !== "open" ? <p className="text-muted">{t("apply_closed")}</p>
56
- : done ? <p className="rounded-xl bg-nile-soft px-4 py-3 text-nile-deep">{t("apply_done")}</p>
57
- : profile ? <Button size="lg" onClick={start} busy={busy}>{t("apply_start")}</Button>
58
- : <Link to={`/signup?next=${encodeURIComponent(`/apply/${token}`)}`} className="inline-block rounded-xl bg-nile px-6 py-3.5 font-medium text-white dark:text-paper">{t("apply_signin")}</Link>}
59
- </div>
60
- </div>
61
- )}
62
- </main>
63
  </div>
64
  );
65
  }
 
1
+ import { ArrowLeft, Clock, Globe, ListChecks, Mic } from "lucide-react";
2
  import { useEffect, useState } from "react";
3
  import { Link, useNavigate, useParams } from "react-router-dom";
4
+ import { CompanyMark, JobFacts, Skills } from "../components/jobs";
5
+ import { AppShell, PublicHeader } from "../components/Shell";
6
+ import { Button, ErrorNote, Spinner, fmtDate, useCatalog } from "../components/ui";
7
+ import { api, type Application, type PublicJob, type Session } from "../lib/api";
8
  import { useAuth } from "../lib/auth";
9
+ import { trackName, useI18n } from "../lib/i18n";
 
 
10
 
11
  export default function Apply() {
12
  const { token } = useParams();
13
  const { t, lang } = useI18n();
14
  const { profile, ready } = useAuth();
15
+ const catalog = useCatalog();
16
  const nav = useNavigate();
17
  const [job, setJob] = useState<PublicJob | null>(null);
18
+ const [mine, setMine] = useState<Application | null | undefined>(undefined);
19
  const [error, setError] = useState<string | null>(null);
20
  const [busy, setBusy] = useState(false);
 
21
 
22
  useEffect(() => {
23
  api.get<PublicJob>(`/public/jobs/${token}`).then(setJob).catch((e) => setError(e.message));
24
  }, [token]);
25
+ useEffect(() => {
26
+ if (!ready) return;
27
+ if (profile?.role === "candidate") api.get<Application[]>("/applications").then((a) => setMine(a.find((x) => x.share_token === token) ?? null)).catch(() => setMine(null));
28
+ else setMine(null);
29
+ }, [ready, profile, token]);
30
 
31
  const start = async () => {
32
  setBusy(true);
33
  try {
34
  const s = await api.post<Session>(`/jobs/apply/${token}`, { language: lang });
35
+ nav(s.status === "in_progress" ? `/app/interview/${s.id}` : `/app/applications`);
 
36
  } catch (e: any) {
37
  setError(e.message);
 
38
  setBusy(false);
39
  }
40
  };
41
 
42
+ const action = () => {
43
+ if (!job) return null;
44
+ if (profile?.role === "recruiter") return <p className="rounded-xl bg-amber-soft px-4 py-3 text-ink">{t("company_cant_apply")}</p>;
45
+ if (mine?.status === "in_progress") return <Link to={`/app/interview/${mine.id}`} className="inline-block rounded-xl bg-nile px-6 py-3.5 font-medium text-white dark:text-paper">{t("finish_interview")}</Link>;
46
+ if (mine) return <p className="rounded-xl bg-nile-soft px-4 py-3 text-nile-deep">{t("apply_done")} <Link to="/app/applications" className="font-medium underline">{t("nav_applications")}</Link></p>;
47
+ if (!job.accepting) return <p className="text-muted">{t("apply_closed")}</p>;
48
+ if (profile) return <Button size="lg" onClick={start} busy={busy}>{t("apply_start")}</Button>;
49
+ return <Link to={`/signup?next=${encodeURIComponent(`/apply/${token}`)}`} className="inline-block rounded-xl bg-nile px-6 py-3.5 font-medium text-white dark:text-paper">{t("apply_signin")}</Link>;
50
+ };
51
+
52
+ const content = error && !job ? <ErrorNote>{error}</ErrorNote> : !job || !ready || mine === undefined ? <Spinner /> : (
53
+ <div className="space-y-6">
54
+ {profile?.role === "candidate" && (
55
+ <Link to="/app/board" className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-ink"><ArrowLeft size={16} className="rtl:rotate-180" /> {t("nav_board")}</Link>
56
+ )}
57
+ <div className="rounded-3xl border border-line bg-surface p-6 sm:p-10">
58
+ <div className="flex items-start gap-4">
59
+ <CompanyMark name={job.company} size={56} />
60
+ <div className="min-w-0">
61
+ <h1 className="text-3xl font-semibold text-ink">{job.title}</h1>
62
+ <p className="mt-1 text-lg text-muted">{job.company} · {trackName(catalog?.tracks, job.track, lang)} · {t(`lv_${job.level}` as "lv_junior")}</p>
63
+ </div>
64
+ </div>
65
+ <div className="mt-6 space-y-3">
66
+ <JobFacts job={job} />
67
+ <Skills skills={job.skills} />
68
+ <p className="text-sm text-muted">{t("posted", { d: fmtDate(job.created_at, lang) })}</p>
69
+ </div>
70
+ {job.description && <p className="mt-6 whitespace-pre-line text-[15px] leading-relaxed text-ink">{job.description}</p>}
71
+
72
+ <div className="mt-8 rounded-2xl bg-sunken p-5">
73
+ <p className="font-semibold text-ink">{t("apply_title", { company: job.company })}</p>
74
+ <ul className="mt-3 space-y-2 text-[15px] text-ink">
75
+ <li className="flex items-center gap-3"><ListChecks size={18} className="text-nile" /> {t("apply_q", { n: job.question_count })}</li>
76
+ <li className="flex items-center gap-3"><Clock size={18} className="text-nile" /> {t("apply_time", { m: Math.round(job.answer_seconds / 60) })}</li>
77
+ <li className="flex items-start gap-3"><Mic size={18} className="mt-0.5 flex-none text-nile" /> {t("apply_how")}</li>
78
+ </ul>
79
+ <div className="mt-6">{action()}</div>
80
+ {error && <div className="mt-4"><ErrorNote>{error}</ErrorNote></div>}
81
+ </div>
82
+ </div>
83
+ {(job.company_about || job.industry || job.company_location) && (
84
+ <div className="rounded-3xl border border-line bg-surface p-6 sm:p-8">
85
+ <h2 className="text-lg font-semibold text-ink">{t("about_company", { company: job.company })}</h2>
86
+ <p className="mt-1 text-sm text-muted">{[job.industry, job.company_size && `${job.company_size}`, job.company_location].filter(Boolean).join(" · ")}</p>
87
+ {job.company_about && <p className="mt-3 whitespace-pre-line text-[15px] text-ink">{job.company_about}</p>}
88
+ {job.website && <a href={job.website} target="_blank" rel="noreferrer" className="mt-3 inline-flex items-center gap-1.5 text-sm text-nile hover:underline"><Globe size={15} /> {job.website.replace(/^https?:\/\//, "")}</a>}
89
+ </div>
90
+ )}
91
+ </div>
92
+ );
93
+
94
+ if (profile?.role === "candidate") return <AppShell>{content}</AppShell>;
95
  return (
96
  <div className="min-h-screen">
97
  <PublicHeader />
98
+ <main className="mx-auto max-w-3xl px-4 pb-16 pt-6">{content}</main>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
99
  </div>
100
  );
101
  }
frontend/src/pages/Board.tsx ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Search } from "lucide-react";
2
+ import { useEffect, useMemo, useState } from "react";
3
+ import { Link, useSearchParams } from "react-router-dom";
4
+ import { JobCard } from "../components/jobs";
5
+ import { PublicHeader } from "../components/Shell";
6
+ import { Input, Select, Spinner, useCatalog } from "../components/ui";
7
+ import { api, type Application, type PublicJob } from "../lib/api";
8
+ import { useAuth } from "../lib/auth";
9
+ import { useI18n } from "../lib/i18n";
10
+
11
+ const LEVELS = ["intern", "junior", "mid", "senior", "lead"];
12
+
13
+ /** The public job board. Rendered inside the app shell for signed-in candidates, and as a public page otherwise. */
14
+ export default function Board({ inApp = false }: { inApp?: boolean }) {
15
+ const { t, lang } = useI18n();
16
+ const { profile } = useAuth();
17
+ const catalog = useCatalog();
18
+ const [params, setParams] = useSearchParams();
19
+ const [search, setSearch] = useState(params.get("q") || "");
20
+ const [jobs, setJobs] = useState<PublicJob[] | null>(null);
21
+ const [applied, setApplied] = useState<Set<string>>(new Set());
22
+ const f = { track: params.get("track") || "", level: params.get("level") || "", work_mode: params.get("mode") || "", employment_type: params.get("type") || "" };
23
+
24
+ const setF = (k: string, v: string) => {
25
+ const n = new URLSearchParams(params);
26
+ if (v) n.set(k, v); else n.delete(k);
27
+ setParams(n, { replace: true });
28
+ };
29
+
30
+ useEffect(() => {
31
+ const h = setTimeout(() => {
32
+ const qs = new URLSearchParams({ search: params.get("q") || "", ...f });
33
+ api.get<PublicJob[]>(`/board?${qs}`).then(setJobs).catch(() => setJobs([]));
34
+ }, 200);
35
+ return () => clearTimeout(h);
36
+ // eslint-disable-next-line react-hooks/exhaustive-deps
37
+ }, [params]);
38
+
39
+ useEffect(() => {
40
+ if (profile?.role === "candidate") api.get<Application[]>("/applications").then((a) => setApplied(new Set(a.map((x) => x.share_token)))).catch(() => undefined);
41
+ }, [profile]);
42
+
43
+ useEffect(() => {
44
+ const h = setTimeout(() => setF("q", search.trim()), 350);
45
+ return () => clearTimeout(h);
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ }, [search]);
48
+
49
+ const trackOpts = useMemo(() => [{ value: "", label: t("any_track") }, ...(catalog?.tracks ?? []).map((x) => ({ value: x.id, label: lang === "ar" ? x.ar : x.en }))], [catalog, lang, t]);
50
+
51
+ const body = (
52
+ <div className="space-y-6">
53
+ <div>
54
+ <h1 className="text-3xl font-semibold text-ink">{t("board_title")}</h1>
55
+ <p className="mt-1 max-w-2xl text-muted">{t("board_sub")}</p>
56
+ </div>
57
+ <div className="space-y-3 rounded-2xl border border-line bg-surface p-4">
58
+ <label className="relative block">
59
+ <Search size={18} className="pointer-events-none absolute start-3.5 top-1/2 -translate-y-1/2 text-muted" />
60
+ <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("board_search")} className="ps-10" aria-label={t("board_search")} />
61
+ </label>
62
+ <div className="grid grid-cols-2 gap-2 md:grid-cols-4">
63
+ <Select value={f.track} onChange={(v) => setF("track", v)} options={trackOpts} />
64
+ <Select value={f.level} onChange={(v) => setF("level", v)} options={[{ value: "", label: t("any_level") }, ...LEVELS.map((l) => ({ value: l, label: t(`lv_${l}` as "lv_junior") }))]} />
65
+ <Select value={f.work_mode} onChange={(v) => setF("mode", v)} options={[{ value: "", label: t("any_mode") }, ...["onsite", "remote", "hybrid"].map((m) => ({ value: m, label: t(`wm_${m}` as "wm_onsite") }))]} />
66
+ <Select value={f.employment_type} onChange={(v) => setF("type", v)} options={[{ value: "", label: t("any_type") }, ...["full_time", "part_time", "internship", "contract"].map((m) => ({ value: m, label: t(`et_${m}` as "et_full_time") }))]} />
67
+ </div>
68
+ </div>
69
+ {!jobs ? <Spinner /> : jobs.length === 0 ? (
70
+ <div className="rounded-2xl border border-dashed border-line p-8 text-center">
71
+ <p className="text-muted">{t("empty_board")}</p>
72
+ {profile?.role !== "recruiter" && <Link to={profile ? "/app/new" : "/signup"} className="mt-4 inline-block font-medium text-nile hover:underline">{t("cta_candidate")}</Link>}
73
+ </div>
74
+ ) : (
75
+ <ul className="space-y-3">{jobs.map((j) => <JobCard key={j.id} job={j} applied={applied.has(j.share_token)} />)}</ul>
76
+ )}
77
+ </div>
78
+ );
79
+
80
+ if (inApp) return body;
81
+ return (
82
+ <div className="min-h-screen">
83
+ <PublicHeader />
84
+ <main className="mx-auto max-w-5xl px-4 pb-16 pt-4 sm:px-6">{body}</main>
85
+ </div>
86
+ );
87
+ }
frontend/src/pages/CompanyDashboard.tsx ADDED
@@ -0,0 +1,96 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { Plus } from "lucide-react";
2
+ import { useEffect, useState } from "react";
3
+ import { Link, Navigate } from "react-router-dom";
4
+ import { Distribution, Funnel, KpiGrid, Signals, SkillsPanel, Timeline, TopCandidates, TraitsPanel } from "../components/hiring";
5
+ import { Badge, Panel, Spinner, Waveform, fmtDate, scoreColor } from "../components/ui";
6
+ import { api, type HiringStats } from "../lib/api";
7
+ import { useAuth } from "../lib/auth";
8
+ import { useI18n } from "../lib/i18n";
9
+
10
+ export default function CompanyDashboard() {
11
+ const { t, lang } = useI18n();
12
+ const { profile } = useAuth();
13
+ const [s, setS] = useState<HiringStats | null>(null);
14
+ useEffect(() => {
15
+ if (profile?.company_id) api.get<HiringStats>(`/company/stats?lang=${lang}`).then(setS).catch(() => undefined);
16
+ }, [profile, lang]);
17
+ if (profile && !profile.company_id) return <Navigate to="/app/welcome?as=company" replace />;
18
+ if (!s) return <Spinner />;
19
+ const company = profile?.company?.name || "";
20
+
21
+ return (
22
+ <div className="space-y-6">
23
+ <div className="flex flex-col gap-4 rounded-3xl bg-ink p-6 text-paper sm:flex-row sm:items-center sm:justify-between sm:p-8 dark:bg-sunken dark:text-ink">
24
+ <div>
25
+ <h1 className="text-3xl font-semibold">{t("co_hello", { company })}</h1>
26
+ <p className="mt-1 opacity-80">{t("co_sub")}</p>
27
+ <p className="mt-3 flex flex-wrap gap-x-5 gap-y-1 text-sm opacity-90">
28
+ <span><b className="tabular">{s.totals.open_jobs}</b> {t("k_open_jobs")}</span>
29
+ <span><b className="tabular">{s.totals.people}</b> {t("k_applicants")}</span>
30
+ {s.totals.avg_minutes != null && <span>{t("k_minutes")}: <b className="tabular">{Math.round(s.totals.avg_minutes)}</b> {t("min")}</span>}
31
+ </p>
32
+ </div>
33
+ <div className="flex items-center gap-5">
34
+ <div className="hidden h-12 w-36 md:block"><Waveform bars={22} color="var(--color-amber)" /></div>
35
+ <Link to="/app/jobs/new" className="inline-flex items-center gap-2 rounded-xl bg-amber px-5 py-3 font-medium text-ink hover:opacity-90"><Plus size={18} /> {t("co_new_job_cta")}</Link>
36
+ </div>
37
+ </div>
38
+
39
+ {s.totals.jobs === 0 ? (
40
+ <Panel><p className="text-muted">{t("empty_company")}</p></Panel>
41
+ ) : (
42
+ <>
43
+ <KpiGrid s={s.totals} />
44
+ <div className="grid gap-6 lg:grid-cols-[1.5fr_1fr]">
45
+ <Timeline data={s.timeline} />
46
+ <Funnel data={s.funnel} />
47
+ </div>
48
+ <TopCandidates rows={s.top} />
49
+ <div className="grid gap-6 lg:grid-cols-2">
50
+ <Distribution data={s.distribution} />
51
+ <Signals data={s.signals} />
52
+ </div>
53
+ <div className="grid gap-6 lg:grid-cols-2">
54
+ <TraitsPanel data={s.traits} />
55
+ <SkillsPanel data={s.skills} />
56
+ </div>
57
+ <Panel title={t("jobs_table")} action={<Link to="/app/jobs" className="text-sm font-medium text-nile hover:underline">{t("nav_jobs")}</Link>}>
58
+ <div className="-mx-5 overflow-x-auto sm:-mx-6">
59
+ <table className="w-full min-w-[640px] text-[15px]">
60
+ <thead>
61
+ <tr className="border-b border-line text-sm text-muted">
62
+ <th className="px-5 py-2 text-start font-medium sm:px-6">{t("c_job")}</th>
63
+ <th className="py-2 text-start font-medium">{t("k_views")}</th>
64
+ <th className="py-2 text-start font-medium">{t("k_applicants")}</th>
65
+ <th className="py-2 text-start font-medium">{t("completed_n")}</th>
66
+ <th className="py-2 text-start font-medium">{t("k_avg")}</th>
67
+ <th className="px-5 py-2 text-start font-medium sm:px-6">{t("k_shortlisted")}</th>
68
+ </tr>
69
+ </thead>
70
+ <tbody>
71
+ {s.jobs.map((j) => (
72
+ <tr key={j.id} className="border-b border-line last:border-0">
73
+ <td className="px-5 py-3 sm:px-6">
74
+ <Link to={`/app/jobs/${j.id}`} className="font-medium text-ink hover:text-nile">{j.title}</Link>
75
+ <p className="mt-0.5 flex items-center gap-2 text-xs text-muted">
76
+ <Badge tone={j.accepting ? "good" : j.status === "draft" ? "neutral" : "bad"}>{t(`jst_${j.accepting ? "open" : j.status === "draft" ? "draft" : "closed"}`)}</Badge>
77
+ {fmtDate(j.created_at, lang)}
78
+ </p>
79
+ </td>
80
+ <td className="py-3 tabular text-muted">{j.views}</td>
81
+ <td className="py-3 tabular text-ink">{j.applicants}</td>
82
+ <td className="py-3 tabular text-ink">{j.completed}</td>
83
+ <td className="py-3"><span className="font-semibold tabular" style={{ color: scoreColor(j.avg_score) }}>{j.avg_score != null ? Math.round(j.avg_score) : "–"}</span></td>
84
+ <td className="px-5 py-3 tabular text-ink sm:px-6">{j.shortlisted}{j.hired ? ` (${j.hired} ${t("k_hired")})` : ""}</td>
85
+ </tr>
86
+ ))}
87
+ </tbody>
88
+ </table>
89
+ </div>
90
+ </Panel>
91
+ <p className="text-sm text-muted">{t("fairness")}</p>
92
+ </>
93
+ )}
94
+ </div>
95
+ );
96
+ }
frontend/src/pages/Dashboard.tsx CHANGED
@@ -1,10 +1,11 @@
1
  import { useEffect, useState } from "react";
2
- import { Link, Navigate } from "react-router-dom";
3
  import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4
  import { Meter, Panel, Spinner, Waveform } from "../components/ui";
5
  import { api, type SessionRow } from "../lib/api";
6
  import { useAuth } from "../lib/auth";
7
  import { useI18n } from "../lib/i18n";
 
8
  import { SessionList } from "./History";
9
 
10
  type Stats = {
@@ -13,6 +14,11 @@ type Stats = {
13
  };
14
 
15
  export default function Dashboard() {
 
 
 
 
 
16
  const { t } = useI18n();
17
  const { profile } = useAuth();
18
  const [stats, setStats] = useState<Stats | null>(null);
@@ -21,7 +27,6 @@ export default function Dashboard() {
21
  api.get<Stats>("/stats").then(setStats).catch(() => undefined);
22
  api.get<SessionRow[]>("/sessions").then(setRows).catch(() => setRows([]));
23
  }, []);
24
- if (profile?.role === "recruiter" && !profile.company_id) return <Navigate to="/app/welcome?as=company" replace />;
25
  if (!stats || !rows) return <Spinner />;
26
  const first = (profile?.full_name || "").split(" ")[0] || "";
27
  const tile = (label: string, value: string | number | null) => (
@@ -41,6 +46,7 @@ export default function Dashboard() {
41
  </div>
42
  <div className="flex items-center gap-5">
43
  <div className="hidden h-12 w-36 md:block"><Waveform bars={22} color="var(--color-amber)" /></div>
 
44
  <Link to="/app/new" className="rounded-xl bg-white px-5 py-3 font-medium text-nile-deep hover:bg-amber-soft dark:bg-paper">{t("nav_new")}</Link>
45
  </div>
46
  </div>
 
1
  import { useEffect, useState } from "react";
2
+ import { Link } from "react-router-dom";
3
  import { CartesianGrid, Line, LineChart, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4
  import { Meter, Panel, Spinner, Waveform } from "../components/ui";
5
  import { api, type SessionRow } from "../lib/api";
6
  import { useAuth } from "../lib/auth";
7
  import { useI18n } from "../lib/i18n";
8
+ import CompanyDashboard from "./CompanyDashboard";
9
  import { SessionList } from "./History";
10
 
11
  type Stats = {
 
14
  };
15
 
16
  export default function Dashboard() {
17
+ const { profile } = useAuth();
18
+ return profile?.role === "recruiter" ? <CompanyDashboard /> : <CandidateDashboard />;
19
+ }
20
+
21
+ function CandidateDashboard() {
22
  const { t } = useI18n();
23
  const { profile } = useAuth();
24
  const [stats, setStats] = useState<Stats | null>(null);
 
27
  api.get<Stats>("/stats").then(setStats).catch(() => undefined);
28
  api.get<SessionRow[]>("/sessions").then(setRows).catch(() => setRows([]));
29
  }, []);
 
30
  if (!stats || !rows) return <Spinner />;
31
  const first = (profile?.full_name || "").split(" ")[0] || "";
32
  const tile = (label: string, value: string | number | null) => (
 
46
  </div>
47
  <div className="flex items-center gap-5">
48
  <div className="hidden h-12 w-36 md:block"><Waveform bars={22} color="var(--color-amber)" /></div>
49
+ <Link to="/app/board" className="rounded-xl border border-white/40 px-5 py-3 font-medium hover:bg-white/10">{t("browse_jobs")}</Link>
50
  <Link to="/app/new" className="rounded-xl bg-white px-5 py-3 font-medium text-nile-deep hover:bg-amber-soft dark:bg-paper">{t("nav_new")}</Link>
51
  </div>
52
  </div>
frontend/src/pages/JobDetail.tsx CHANGED
@@ -1,8 +1,10 @@
1
- import { Pencil, Star } from "lucide-react";
2
  import { useEffect, useState } from "react";
3
  import { Link, useNavigate, useParams } from "react-router-dom";
4
- import { Badge, Panel, Spinner, StatusBadge, fmtDate, scoreColor, useCatalog } from "../components/ui";
5
- import { api, type CandidateRow, type Job } from "../lib/api";
 
 
6
  import { trackName, useI18n } from "../lib/i18n";
7
  import { CopyLink } from "./Jobs";
8
 
@@ -16,14 +18,37 @@ export default function JobDetail() {
16
  const [job, setJob] = useState<Job | null>(null);
17
  const [rows, setRows] = useState<CandidateRow[] | null>(null);
18
  const [filter, setFilter] = useState<(typeof FILTERS)[number]>("all");
 
 
 
 
19
  useEffect(() => {
20
  api.get<Job>(`/jobs/${id}`).then(setJob);
21
  api.get<CandidateRow[]>(`/jobs/${id}/candidates`).then(setRows);
22
  }, [id]);
 
 
 
23
  if (!job || !rows) return <Spinner />;
24
- const shown = rows.filter((r) => filter === "all" || r.review_status === filter);
25
- const sigTone = (s: string | null) => (s === "strong_yes" || s === "yes" ? "good" : s === "maybe" ? "warn" : s === "no" ? "bad" : "neutral");
26
- const rvTone = { new: "neutral", shortlisted: "info", rejected: "bad", hired: "good" } as const;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
 
28
  return (
29
  <div className="space-y-6">
@@ -37,9 +62,34 @@ export default function JobDetail() {
37
  <Link to={`/app/jobs/${job.id}/edit`} className="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-sm text-ink hover:border-nile"><Pencil size={14} /> {t("edit")}</Link>
38
  </div>
39
  </div>
40
- <p className="text-sm text-muted">{t("share_hint")}</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  <Panel title={t("candidates_title")} action={
 
 
 
 
 
 
 
 
 
43
  <div className="flex flex-wrap gap-1">
44
  {FILTERS.map((f) => (
45
  <button key={f} onClick={() => setFilter(f)} aria-pressed={filter === f}
@@ -48,7 +98,7 @@ export default function JobDetail() {
48
  </button>
49
  ))}
50
  </div>
51
- }>
52
  {shown.length === 0 ? (
53
  <p className="py-6 text-center text-muted">{t("empty_candidates")}</p>
54
  ) : (
@@ -87,6 +137,12 @@ export default function JobDetail() {
87
  </div>
88
  )}
89
  </Panel>
 
 
 
 
 
 
90
  <p className="text-sm text-muted">{t("fairness")}</p>
91
  </div>
92
  );
 
1
+ import { Download, Pencil, Search, Star } from "lucide-react";
2
  import { useEffect, useState } from "react";
3
  import { Link, useNavigate, useParams } from "react-router-dom";
4
+ import { Distribution, Funnel, KpiGrid, Signals, SkillsPanel, TraitsPanel, reviewTone, signalTone } from "../components/hiring";
5
+ import { JobFacts, Skills } from "../components/jobs";
6
+ import { Badge, Button, Input, Panel, Select, Spinner, StatusBadge, fmtDate, scoreColor, useCatalog } from "../components/ui";
7
+ import { api, type CandidateRow, type HiringStats, type Job } from "../lib/api";
8
  import { trackName, useI18n } from "../lib/i18n";
9
  import { CopyLink } from "./Jobs";
10
 
 
18
  const [job, setJob] = useState<Job | null>(null);
19
  const [rows, setRows] = useState<CandidateRow[] | null>(null);
20
  const [filter, setFilter] = useState<(typeof FILTERS)[number]>("all");
21
+ const [stats, setStats] = useState<HiringStats | null>(null);
22
+ const [search, setSearch] = useState("");
23
+ const [minScore, setMinScore] = useState("0");
24
+ const [exporting, setExporting] = useState(false);
25
  useEffect(() => {
26
  api.get<Job>(`/jobs/${id}`).then(setJob);
27
  api.get<CandidateRow[]>(`/jobs/${id}/candidates`).then(setRows);
28
  }, [id]);
29
+ useEffect(() => {
30
+ api.get<HiringStats>(`/jobs/${id}/stats?lang=${lang}`).then(setStats).catch(() => undefined);
31
+ }, [id, lang]);
32
  if (!job || !rows) return <Spinner />;
33
+ const needle = search.trim().toLowerCase();
34
+ const shown = rows.filter((r) => (filter === "all" || r.review_status === filter)
35
+ && (!needle || `${r.full_name} ${r.email}`.toLowerCase().includes(needle))
36
+ && (+minScore === 0 || (r.overall_score ?? -1) >= +minScore));
37
+ const sigTone = signalTone;
38
+ const rvTone = reviewTone;
39
+ const exportCsv = async () => {
40
+ setExporting(true);
41
+ try {
42
+ const blob = await api.blob(`/jobs/${id}/candidates.csv`);
43
+ const a = document.createElement("a");
44
+ a.href = URL.createObjectURL(blob);
45
+ a.download = `evalbot-${job.title.replace(/[^\w]+/g, "-")}-candidates.csv`;
46
+ a.click();
47
+ URL.revokeObjectURL(a.href);
48
+ } finally {
49
+ setExporting(false);
50
+ }
51
+ };
52
 
53
  return (
54
  <div className="space-y-6">
 
62
  <Link to={`/app/jobs/${job.id}/edit`} className="inline-flex items-center gap-1.5 rounded-lg border border-line px-3 py-1.5 text-sm text-ink hover:border-nile"><Pencil size={14} /> {t("edit")}</Link>
63
  </div>
64
  </div>
65
+ <div className="space-y-3">
66
+ <JobFacts job={job} />
67
+ <Skills skills={job.skills} />
68
+ <p className="text-sm text-muted">{job.visibility === "public" ? t("vis_public") : t("vis_link")}. {t("share_hint")}</p>
69
+ </div>
70
+
71
+ {stats && stats.totals.applicants > 0 && (
72
+ <>
73
+ <h2 className="pt-2 text-xl font-semibold text-ink">{t("job_stats")}</h2>
74
+ <KpiGrid s={stats.totals} />
75
+ <div className="grid gap-6 lg:grid-cols-3">
76
+ <Funnel data={stats.funnel} />
77
+ <Distribution data={stats.distribution} />
78
+ <Signals data={stats.signals} />
79
+ </div>
80
+ </>
81
+ )}
82
 
83
  <Panel title={t("candidates_title")} action={
84
+ <Button variant="outline" onClick={exportCsv} busy={exporting} disabled={!rows.length}><Download size={16} /> {t("export_csv")}</Button>
85
+ }>
86
+ <div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center">
87
+ <label className="relative block md:w-64">
88
+ <Search size={16} className="pointer-events-none absolute start-3 top-1/2 -translate-y-1/2 text-muted" />
89
+ <Input value={search} onChange={(e) => setSearch(e.target.value)} placeholder={t("search_candidates")} className="ps-9" aria-label={t("search_candidates")} />
90
+ </label>
91
+ <Select className="md:w-44" value={minScore} onChange={setMinScore}
92
+ options={[0, 50, 60, 70, 80].map((v) => ({ value: String(v), label: v ? `${t("min_score")}: ${v}+` : `${t("min_score")}: –` }))} />
93
  <div className="flex flex-wrap gap-1">
94
  {FILTERS.map((f) => (
95
  <button key={f} onClick={() => setFilter(f)} aria-pressed={filter === f}
 
98
  </button>
99
  ))}
100
  </div>
101
+ </div>
102
  {shown.length === 0 ? (
103
  <p className="py-6 text-center text-muted">{t("empty_candidates")}</p>
104
  ) : (
 
137
  </div>
138
  )}
139
  </Panel>
140
+ {stats && stats.totals.completed > 0 && (
141
+ <div className="grid gap-6 lg:grid-cols-2">
142
+ <TraitsPanel data={stats.traits} />
143
+ <SkillsPanel data={stats.skills} />
144
+ </div>
145
+ )}
146
  <p className="text-sm text-muted">{t("fairness")}</p>
147
  </div>
148
  );
frontend/src/pages/JobEditor.tsx CHANGED
@@ -18,6 +18,13 @@ export default function JobEditor() {
18
  const [secs, setSecs] = useState(120);
19
  const [status, setStatus] = useState<Job["status"]>("open");
20
  const [qs, setQs] = useState<PlanQuestion[]>([{ text: "" }]);
 
 
 
 
 
 
 
21
  const [busy, setBusy] = useState(false);
22
  const [suggesting, setSuggesting] = useState(false);
23
  const [error, setError] = useState<string | null>(null);
@@ -27,6 +34,8 @@ export default function JobEditor() {
27
  api.get<Job>(`/jobs/${id}`).then((j) => {
28
  setTitle(j.title); setTrack(j.track); setLevel(j.level); setDesc(j.description || "");
29
  setSecs(j.answer_seconds); setStatus(j.status); setQs(j.questions.length ? j.questions : [{ text: "" }]);
 
 
30
  setLoaded(true);
31
  });
32
  }, [id]);
@@ -56,7 +65,11 @@ export default function JobEditor() {
56
  setBusy(true);
57
  setError(null);
58
  try {
59
- const body = { title, track, level, description: desc, answer_seconds: secs, status, questions: qs.filter((x) => x.text.trim()) };
 
 
 
 
60
  const j = id ? await api.put<Job>(`/jobs/${id}`, body) : await api.post<Job>("/jobs", body);
61
  nav(`/app/jobs/${j.id}`);
62
  } catch (e: any) {
@@ -70,7 +83,7 @@ export default function JobEditor() {
70
  return (
71
  <div className="space-y-6">
72
  <h1 className="text-3xl font-semibold text-ink">{id ? title : t("new_job")}</h1>
73
- <Panel>
74
  <div className="grid gap-4 md:grid-cols-2">
75
  <Field label={t("job_title")}><Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Junior Data Engineer" /></Field>
76
  <Field label={t("track")}><Select value={track} onChange={setTrack} options={catalog.tracks.map((x) => ({ value: x.id, label: lang === "ar" ? x.ar : x.en }))} /></Field>
@@ -83,11 +96,34 @@ export default function JobEditor() {
83
  <Select value={status} onChange={(v) => setStatus(v as Job["status"])} options={(["open", "draft", "closed"] as const).map((s) => ({ value: s, label: t(`jst_${s}`) }))} />
84
  </Field>
85
  </div>
86
- <div className="md:col-span-2"><Field label={t("job_desc")}><Textarea value={desc} onChange={(e) => setDesc(e.target.value)} /></Field></div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  </div>
88
  </Panel>
89
 
90
- <Panel title={t("questions")} action={<Button variant="outline" onClick={suggest} busy={suggesting}><Sparkles size={16} /> {suggesting ? t("suggesting") : t("suggest")}</Button>}>
91
  <ol className="space-y-3">
92
  {qs.map((q, i) => (
93
  <li key={i} className="flex items-start gap-2">
 
18
  const [secs, setSecs] = useState(120);
19
  const [status, setStatus] = useState<Job["status"]>("open");
20
  const [qs, setQs] = useState<PlanQuestion[]>([{ text: "" }]);
21
+ const [location, setLocation] = useState("");
22
+ const [workMode, setWorkMode] = useState<Job["work_mode"]>("onsite");
23
+ const [empType, setEmpType] = useState<Job["employment_type"]>("full_time");
24
+ const [salary, setSalary] = useState("");
25
+ const [skills, setSkills] = useState("");
26
+ const [deadline, setDeadline] = useState("");
27
+ const [visibility, setVisibility] = useState<Job["visibility"]>("public");
28
  const [busy, setBusy] = useState(false);
29
  const [suggesting, setSuggesting] = useState(false);
30
  const [error, setError] = useState<string | null>(null);
 
34
  api.get<Job>(`/jobs/${id}`).then((j) => {
35
  setTitle(j.title); setTrack(j.track); setLevel(j.level); setDesc(j.description || "");
36
  setSecs(j.answer_seconds); setStatus(j.status); setQs(j.questions.length ? j.questions : [{ text: "" }]);
37
+ setLocation(j.location || ""); setWorkMode(j.work_mode); setEmpType(j.employment_type); setSalary(j.salary || "");
38
+ setSkills((j.skills || []).join(", ")); setDeadline(j.deadline || ""); setVisibility(j.visibility);
39
  setLoaded(true);
40
  });
41
  }, [id]);
 
65
  setBusy(true);
66
  setError(null);
67
  try {
68
+ const body = {
69
+ title, track, level, description: desc, answer_seconds: secs, status, questions: qs.filter((x) => x.text.trim()),
70
+ location: location.trim() || null, work_mode: workMode, employment_type: empType, salary: salary.trim() || null,
71
+ skills: skills.split(/[,،\n]/).map((x) => x.trim()).filter(Boolean), deadline: deadline || null, visibility,
72
+ };
73
  const j = id ? await api.put<Job>(`/jobs/${id}`, body) : await api.post<Job>("/jobs", body);
74
  nav(`/app/jobs/${j.id}`);
75
  } catch (e: any) {
 
83
  return (
84
  <div className="space-y-6">
85
  <h1 className="text-3xl font-semibold text-ink">{id ? title : t("new_job")}</h1>
86
+ <Panel title={t("job_basics")}>
87
  <div className="grid gap-4 md:grid-cols-2">
88
  <Field label={t("job_title")}><Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Junior Data Engineer" /></Field>
89
  <Field label={t("track")}><Select value={track} onChange={setTrack} options={catalog.tracks.map((x) => ({ value: x.id, label: lang === "ar" ? x.ar : x.en }))} /></Field>
 
96
  <Select value={status} onChange={(v) => setStatus(v as Job["status"])} options={(["open", "draft", "closed"] as const).map((s) => ({ value: s, label: t(`jst_${s}`) }))} />
97
  </Field>
98
  </div>
99
+ <Field label={t("location")}><Input value={location} onChange={(e) => setLocation(e.target.value)} placeholder={t("location_ph")} /></Field>
100
+ <div className="grid grid-cols-2 gap-4">
101
+ <Field label={t("work_mode")}>
102
+ <Select value={workMode} onChange={(v) => setWorkMode(v as Job["work_mode"])} options={(["onsite", "remote", "hybrid"] as const).map((m) => ({ value: m, label: t(`wm_${m}`) }))} />
103
+ </Field>
104
+ <Field label={t("employment_type")}>
105
+ <Select value={empType} onChange={(v) => setEmpType(v as Job["employment_type"])} options={(["full_time", "part_time", "internship", "contract"] as const).map((m) => ({ value: m, label: t(`et_${m}`) }))} />
106
+ </Field>
107
+ </div>
108
+ <Field label={`${t("salary")} (${t("optional")})`}><Input value={salary} onChange={(e) => setSalary(e.target.value)} placeholder={t("salary_ph")} /></Field>
109
+ <Field label={`${t("deadline_l")} (${t("optional")})`}><Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} /></Field>
110
+ <div className="md:col-span-2"><Field label={t("skills_req")}><Input value={skills} onChange={(e) => setSkills(e.target.value)} placeholder={t("skills_ph")} dir="ltr" /></Field></div>
111
+ <div className="md:col-span-2"><Field label={t("job_desc")}><Textarea value={desc} onChange={(e) => setDesc(e.target.value)} className="min-h-40" /></Field></div>
112
+ <div className="md:col-span-2">
113
+ <span className="mb-1.5 block text-sm font-medium text-ink">{t("visibility")}</span>
114
+ <div className="grid gap-2 sm:grid-cols-2">
115
+ {(["public", "link"] as const).map((v) => (
116
+ <button key={v} type="button" onClick={() => setVisibility(v)} aria-pressed={visibility === v}
117
+ className={`rounded-xl border px-4 py-3 text-start text-[15px] ${visibility === v ? "border-nile bg-nile-soft text-nile-deep" : "border-line text-ink hover:border-nile"}`}>
118
+ {t(v === "public" ? "vis_public" : "vis_link")}
119
+ </button>
120
+ ))}
121
+ </div>
122
+ </div>
123
  </div>
124
  </Panel>
125
 
126
+ <Panel title={`${t("job_interview")}: ${t("questions")}`} action={<Button variant="outline" onClick={suggest} busy={suggesting}><Sparkles size={16} /> {suggesting ? t("suggesting") : t("suggest")}</Button>}>
127
  <ol className="space-y-3">
128
  {qs.map((q, i) => (
129
  <li key={i} className="flex items-start gap-2">
frontend/src/pages/Jobs.tsx CHANGED
@@ -49,10 +49,12 @@ export default function Jobs() {
49
  <p className="mt-0.5 flex flex-wrap items-center gap-2 text-sm text-muted">
50
  <span>{trackName(catalog?.tracks, j.track, lang)}</span>
51
  <Badge tone={tone[j.status]}>{t(`jst_${j.status}`)}</Badge>
 
52
  <span>{fmtDate(j.created_at, lang)}</span>
53
  </p>
54
  </Link>
55
  <div className="flex items-center gap-6">
 
56
  <div className="text-center"><p className="font-display text-xl font-semibold tabular text-ink">{j.applicants ?? 0}</p><p className="text-xs text-muted">{t("applicants")}</p></div>
57
  <div className="text-center"><p className="font-display text-xl font-semibold tabular text-ink">{j.completed ?? 0}</p><p className="text-xs text-muted">{t("completed_n")}</p></div>
58
  <CopyLink token={j.share_token} />
 
49
  <p className="mt-0.5 flex flex-wrap items-center gap-2 text-sm text-muted">
50
  <span>{trackName(catalog?.tracks, j.track, lang)}</span>
51
  <Badge tone={tone[j.status]}>{t(`jst_${j.status}`)}</Badge>
52
+ {j.visibility === "link" && <Badge>{t("vis_link").split(":")[0]}</Badge>}
53
  <span>{fmtDate(j.created_at, lang)}</span>
54
  </p>
55
  </Link>
56
  <div className="flex items-center gap-6">
57
+ <div className="text-center"><p className="font-display text-xl font-semibold tabular text-ink">{j.views ?? 0}</p><p className="text-xs text-muted">{t("k_views")}</p></div>
58
  <div className="text-center"><p className="font-display text-xl font-semibold tabular text-ink">{j.applicants ?? 0}</p><p className="text-xs text-muted">{t("applicants")}</p></div>
59
  <div className="text-center"><p className="font-display text-xl font-semibold tabular text-ink">{j.completed ?? 0}</p><p className="text-xs text-muted">{t("completed_n")}</p></div>
60
  <CopyLink token={j.share_token} />
frontend/src/pages/Landing.tsx CHANGED
@@ -1,9 +1,10 @@
1
  import { useEffect, useState } from "react";
2
  import { Link } from "react-router-dom";
3
  import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
 
4
  import { PublicHeader } from "../components/Shell";
5
  import { Waveform, useCatalog } from "../components/ui";
6
- import { api } from "../lib/api";
7
  import { useI18n } from "../lib/i18n";
8
 
9
  type Card = { traits: { trait: string; label: string; model_r: number; human_r: number }[]; reference?: { interviews: number } };
@@ -41,6 +42,10 @@ export default function Landing() {
41
  const { t, lang } = useI18n();
42
  const catalog = useCatalog();
43
  const [card, setCard] = useState<Card | null>(null);
 
 
 
 
44
  useEffect(() => {
45
  api.get<Card>(`/model-card?lang=${lang}`).then(setCard).catch(() => setCard(null));
46
  }, [lang]);
@@ -101,6 +106,17 @@ export default function Landing() {
101
  </div>
102
  </section>
103
 
 
 
 
 
 
 
 
 
 
 
 
104
  {/* science */}
105
  {chart.length > 0 && (
106
  <section className="bg-ink text-paper dark:bg-surface dark:text-ink">
 
1
  import { useEffect, useState } from "react";
2
  import { Link } from "react-router-dom";
3
  import { Bar, BarChart, CartesianGrid, Legend, ResponsiveContainer, Tooltip, XAxis, YAxis } from "recharts";
4
+ import { JobCard } from "../components/jobs";
5
  import { PublicHeader } from "../components/Shell";
6
  import { Waveform, useCatalog } from "../components/ui";
7
+ import { api, type PublicJob } from "../lib/api";
8
  import { useI18n } from "../lib/i18n";
9
 
10
  type Card = { traits: { trait: string; label: string; model_r: number; human_r: number }[]; reference?: { interviews: number } };
 
42
  const { t, lang } = useI18n();
43
  const catalog = useCatalog();
44
  const [card, setCard] = useState<Card | null>(null);
45
+ const [jobs, setJobs] = useState<PublicJob[]>([]);
46
+ useEffect(() => {
47
+ api.get<PublicJob[]>("/board").then((j) => setJobs(j.slice(0, 4))).catch(() => undefined);
48
+ }, []);
49
  useEffect(() => {
50
  api.get<Card>(`/model-card?lang=${lang}`).then(setCard).catch(() => setCard(null));
51
  }, [lang]);
 
106
  </div>
107
  </section>
108
 
109
+ {/* open roles from companies on the platform */}
110
+ {jobs.length > 0 && (
111
+ <section className="mx-auto max-w-6xl px-4 pb-16 sm:px-6">
112
+ <div className="mb-5 flex items-end justify-between gap-3">
113
+ <h2 className="text-2xl font-semibold text-ink">{t("open_roles")}</h2>
114
+ <Link to="/jobs" className="font-medium text-nile hover:underline">{t("see_all_jobs")}</Link>
115
+ </div>
116
+ <ul className="grid gap-3 lg:grid-cols-2">{jobs.map((j) => <JobCard key={j.id} job={j} />)}</ul>
117
+ </section>
118
+ )}
119
+
120
  {/* science */}
121
  {chart.length > 0 && (
122
  <section className="bg-ink text-paper dark:bg-surface dark:text-ink">
frontend/src/pages/Onboarding.tsx CHANGED
@@ -2,11 +2,13 @@ import { Building2, Mic } from "lucide-react";
2
  import { useState } from "react";
3
  import { useNavigate, useSearchParams } from "react-router-dom";
4
  import { PublicHeader } from "../components/Shell";
5
- import { Button, ErrorNote, Field, Input } from "../components/ui";
6
  import { api } from "../lib/api";
7
  import { useAuth } from "../lib/auth";
8
  import { useI18n } from "../lib/i18n";
9
 
 
 
10
  export default function Onboarding() {
11
  const { t } = useI18n();
12
  const { refresh } = useAuth();
@@ -15,6 +17,10 @@ export default function Onboarding() {
15
  const [choice, setChoice] = useState<"candidate" | "recruiter">(params.get("as") === "company" ? "recruiter" : "candidate");
16
  const [company, setCompany] = useState("");
17
  const [website, setWebsite] = useState("");
 
 
 
 
18
  const [busy, setBusy] = useState(false);
19
  const [error, setError] = useState<string | null>(null);
20
 
@@ -23,13 +29,13 @@ export default function Onboarding() {
23
  setError(null);
24
  try {
25
  if (choice === "recruiter") {
26
- await api.post("/companies", { name: company, website: website || null });
27
  await refresh();
28
  nav("/app/jobs");
29
  } else {
30
  await api.patch("/me", { role: "candidate" });
31
  await refresh();
32
- nav("/app/new");
33
  }
34
  } catch (e: any) {
35
  setError(e.message);
@@ -61,7 +67,13 @@ export default function Onboarding() {
61
  {choice === "recruiter" && (
62
  <div className="mt-6 space-y-4">
63
  <Field label={t("company_name")}><Input value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
64
- <Field label={`${t("website")} (${t("optional")})`}><Input value={website} onChange={(e) => setWebsite(e.target.value)} type="url" placeholder="https://" /></Field>
 
 
 
 
 
 
65
  </div>
66
  )}
67
  {error && <div className="mt-4"><ErrorNote>{error}</ErrorNote></div>}
 
2
  import { useState } from "react";
3
  import { useNavigate, useSearchParams } from "react-router-dom";
4
  import { PublicHeader } from "../components/Shell";
5
+ import { Button, ErrorNote, Field, Input, Select, Textarea } from "../components/ui";
6
  import { api } from "../lib/api";
7
  import { useAuth } from "../lib/auth";
8
  import { useI18n } from "../lib/i18n";
9
 
10
+ export const SIZES = ["1-10", "11-50", "51-200", "201-1000", "1000+"];
11
+
12
  export default function Onboarding() {
13
  const { t } = useI18n();
14
  const { refresh } = useAuth();
 
17
  const [choice, setChoice] = useState<"candidate" | "recruiter">(params.get("as") === "company" ? "recruiter" : "candidate");
18
  const [company, setCompany] = useState("");
19
  const [website, setWebsite] = useState("");
20
+ const [industry, setIndustry] = useState("");
21
+ const [size, setSize] = useState("11-50");
22
+ const [location, setLocation] = useState("");
23
+ const [about, setAbout] = useState("");
24
  const [busy, setBusy] = useState(false);
25
  const [error, setError] = useState<string | null>(null);
26
 
 
29
  setError(null);
30
  try {
31
  if (choice === "recruiter") {
32
+ await api.post("/companies", { name: company, website: website || null, industry: industry || null, size, location: location || null, about: about || null });
33
  await refresh();
34
  nav("/app/jobs");
35
  } else {
36
  await api.patch("/me", { role: "candidate" });
37
  await refresh();
38
+ nav("/app");
39
  }
40
  } catch (e: any) {
41
  setError(e.message);
 
67
  {choice === "recruiter" && (
68
  <div className="mt-6 space-y-4">
69
  <Field label={t("company_name")}><Input value={company} onChange={(e) => setCompany(e.target.value)} required /></Field>
70
+ <div className="grid gap-4 sm:grid-cols-2">
71
+ <Field label={t("industry")}><Input value={industry} onChange={(e) => setIndustry(e.target.value)} placeholder="Software, Banking, Telecom…" /></Field>
72
+ <Field label={t("company_size")}><Select value={size} onChange={setSize} options={SIZES.map((x) => ({ value: x, label: x }))} /></Field>
73
+ <Field label={t("company_loc")}><Input value={location} onChange={(e) => setLocation(e.target.value)} placeholder={t("location_ph")} /></Field>
74
+ <Field label={`${t("website")} (${t("optional")})`}><Input value={website} onChange={(e) => setWebsite(e.target.value)} type="url" placeholder="https://" /></Field>
75
+ </div>
76
+ <Field label={`${t("company_about")} (${t("optional")})`}><Textarea value={about} onChange={(e) => setAbout(e.target.value)} /></Field>
77
  </div>
78
  )}
79
  {error && <div className="mt-4"><ErrorNote>{error}</ErrorNote></div>}
frontend/src/pages/Settings.tsx CHANGED
@@ -1,5 +1,6 @@
1
  import { useEffect, useState } from "react";
2
- import { Button, ErrorNote, Field, Input, Panel, Select, useCatalog } from "../components/ui";
 
3
  import { api } from "../lib/api";
4
  import { updatePassword, useAuth } from "../lib/auth";
5
  import { useI18n } from "../lib/i18n";
@@ -12,6 +13,7 @@ export default function SettingsPage() {
12
  const [headline, setHeadline] = useState("");
13
  const [track, setTrack] = useState("");
14
  const [company, setCompany] = useState("");
 
15
  const [pw, setPw] = useState("");
16
  const [msg, setMsg] = useState<string | null>(null);
17
  const [error, setError] = useState<string | null>(null);
@@ -23,6 +25,8 @@ export default function SettingsPage() {
23
  setHeadline(profile.headline || "");
24
  setTrack(profile.target_track || "");
25
  setCompany(profile.company?.name || "");
 
 
26
  }, [profile]);
27
 
28
  const save = async () => {
@@ -30,7 +34,7 @@ export default function SettingsPage() {
30
  setError(null);
31
  try {
32
  await api.patch("/me", { full_name: name, headline, target_track: track || null });
33
- if (profile?.role === "recruiter" && company.trim()) await api.post("/companies", { name: company.trim() });
34
  await refresh();
35
  setMsg(t("saved"));
36
  } catch (e: any) {
@@ -61,10 +65,17 @@ export default function SettingsPage() {
61
  <Field label={t("target_track")}>
62
  <Select value={track} onChange={setTrack} options={[{ value: "", label: "—" }, ...(catalog?.tracks ?? []).map((x) => ({ value: x.id, label: lang === "ar" ? x.ar : x.en }))]} />
63
  </Field>
64
- {profile?.role === "recruiter" && (
65
- <Field label={t("company")}><Input value={company} onChange={(e) => setCompany(e.target.value)} /></Field>
66
- )}
67
  </div>
 
 
 
 
 
 
 
 
 
 
68
  <div className="mt-5 flex items-center gap-3">
69
  <Button onClick={save} busy={busy}>{t("save")}</Button>
70
  {msg && <span className="text-sm text-nile">{msg}</span>}
 
1
  import { useEffect, useState } from "react";
2
+ import { Button, ErrorNote, Field, Input, Panel, Select, Textarea, useCatalog } from "../components/ui";
3
+ import { SIZES } from "./Onboarding";
4
  import { api } from "../lib/api";
5
  import { updatePassword, useAuth } from "../lib/auth";
6
  import { useI18n } from "../lib/i18n";
 
13
  const [headline, setHeadline] = useState("");
14
  const [track, setTrack] = useState("");
15
  const [company, setCompany] = useState("");
16
+ const [co, setCo] = useState({ website: "", industry: "", size: "11-50", location: "", about: "" });
17
  const [pw, setPw] = useState("");
18
  const [msg, setMsg] = useState<string | null>(null);
19
  const [error, setError] = useState<string | null>(null);
 
25
  setHeadline(profile.headline || "");
26
  setTrack(profile.target_track || "");
27
  setCompany(profile.company?.name || "");
28
+ const c = profile.company;
29
+ if (c) setCo({ website: c.website || "", industry: c.industry || "", size: c.size || "11-50", location: c.location || "", about: c.about || "" });
30
  }, [profile]);
31
 
32
  const save = async () => {
 
34
  setError(null);
35
  try {
36
  await api.patch("/me", { full_name: name, headline, target_track: track || null });
37
+ if (profile?.role === "recruiter" && company.trim()) await api.post("/companies", { name: company.trim(), ...co });
38
  await refresh();
39
  setMsg(t("saved"));
40
  } catch (e: any) {
 
65
  <Field label={t("target_track")}>
66
  <Select value={track} onChange={setTrack} options={[{ value: "", label: "—" }, ...(catalog?.tracks ?? []).map((x) => ({ value: x.id, label: lang === "ar" ? x.ar : x.en }))]} />
67
  </Field>
 
 
 
68
  </div>
69
+ {profile?.role === "recruiter" && (
70
+ <div className="mt-6 grid gap-4 border-t border-line pt-6 sm:grid-cols-2">
71
+ <Field label={t("company_name")}><Input value={company} onChange={(e) => setCompany(e.target.value)} /></Field>
72
+ <Field label={t("website")}><Input value={co.website} onChange={(e) => setCo({ ...co, website: e.target.value })} type="url" placeholder="https://" /></Field>
73
+ <Field label={t("industry")}><Input value={co.industry} onChange={(e) => setCo({ ...co, industry: e.target.value })} /></Field>
74
+ <Field label={t("company_size")}><Select value={co.size} onChange={(v) => setCo({ ...co, size: v })} options={SIZES.map((x) => ({ value: x, label: x }))} /></Field>
75
+ <Field label={t("company_loc")}><Input value={co.location} onChange={(e) => setCo({ ...co, location: e.target.value })} /></Field>
76
+ <div className="sm:col-span-2"><Field label={t("company_about")}><Textarea value={co.about} onChange={(e) => setCo({ ...co, about: e.target.value })} /></Field></div>
77
+ </div>
78
+ )}
79
  <div className="mt-5 flex items-center gap-3">
80
  <Button onClick={save} busy={busy}>{t("save")}</Button>
81
  {msg && <span className="text-sm text-nile">{msg}</span>}
platform_app/core.py CHANGED
@@ -135,7 +135,9 @@ def recruiter(user: Dict = Depends(current_user)) -> Dict:
135
  # File storage (Supabase Storage, or a local folder for development)
136
  # --------------------------------------------------------------------------------------
137
  def _storage_headers(content_type: Optional[str] = None) -> Dict[str, str]:
138
- h = {"Authorization": f"Bearer {SUPABASE_SERVICE_ROLE_KEY}", "apikey": SUPABASE_SERVICE_ROLE_KEY}
 
 
139
  if content_type:
140
  h["Content-Type"] = content_type
141
  return h
 
135
  # File storage (Supabase Storage, or a local folder for development)
136
  # --------------------------------------------------------------------------------------
137
  def _storage_headers(content_type: Optional[str] = None) -> Dict[str, str]:
138
+ h = {"apikey": SUPABASE_SERVICE_ROLE_KEY}
139
+ if not SUPABASE_SERVICE_ROLE_KEY.startswith("sb_"): # legacy JWT keys also go in Authorization; new sb_secret keys must not
140
+ h["Authorization"] = f"Bearer {SUPABASE_SERVICE_ROLE_KEY}"
141
  if content_type:
142
  h["Content-Type"] = content_type
143
  return h
platform_app/routes.py CHANGED
@@ -5,6 +5,9 @@ import os
5
  import tempfile
6
  import time
7
  from concurrent.futures import ThreadPoolExecutor
 
 
 
8
  from typing import Dict, List, Optional
9
 
10
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
@@ -41,16 +44,40 @@ def catalog():
41
  return ai.catalog()
42
 
43
 
 
 
 
 
 
 
 
44
  @router.get("/public/jobs/{token}")
45
  def public_job(token: str):
46
- j = q1("select j.id, j.title, j.track, j.level, j.description, j.answer_seconds, j.status, "
47
- "jsonb_array_length(j.questions) as question_count, c.name as company, c.website "
48
- "from jobs j join companies c on c.id = j.company_id where j.share_token = %s", [token])
49
  if not j:
50
  raise HTTPException(404, "This job link is not valid")
51
  return j
52
 
53
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  # --------------------------------------------------------------------------------------
55
  # Profile & company
56
  # --------------------------------------------------------------------------------------
@@ -71,8 +98,9 @@ def me(user: Dict = Depends(current_user)):
71
  @router.patch("/me")
72
  def update_me(body: ProfileIn, user: Dict = Depends(current_user)):
73
  fields = {k: v for k, v in body.model_dump().items() if v is not None}
74
- if "role" in fields and fields["role"] not in ("candidate", "recruiter"):
75
- raise HTTPException(400, "Invalid role")
 
76
  if fields:
77
  sets = ", ".join(f"{k} = %({k})s" for k in fields)
78
  q(f"update profiles set {sets} where id = %(id)s", {**fields, "id": user["id"]})
@@ -81,18 +109,25 @@ def update_me(body: ProfileIn, user: Dict = Depends(current_user)):
81
 
82
  class CompanyIn(BaseModel):
83
  name: str = Field(min_length=2, max_length=120)
84
- website: Optional[str] = None
85
- about: Optional[str] = None
 
 
 
86
 
87
 
88
  @router.post("/companies")
89
  def create_company(body: CompanyIn, user: Dict = Depends(current_user)):
90
- if user.get("company_id"):
91
- c = q1("update companies set name=%s, website=%s, about=%s where id=%s returning *",
92
- [body.name, body.website, body.about, user["company_id"]])
 
93
  else:
94
- c = q1("insert into companies (name, website, about, created_by) values (%s, %s, %s, %s) returning *",
95
- [body.name, body.website, body.about, user["id"]])
 
 
 
96
  q("update profiles set role='recruiter', company_id=%s where id=%s", [c["id"], user["id"]])
97
  return c
98
 
@@ -159,12 +194,16 @@ class ApplyIn(BaseModel):
159
 
160
  @router.post("/jobs/apply/{token}")
161
  def apply_job(token: str, body: ApplyIn, user: Dict = Depends(current_user)):
162
- j = q1("select * from jobs where share_token = %s", [token])
163
- if not j or j["status"] != "open":
 
 
164
  raise HTTPException(404, "This job is not accepting interviews")
165
  existing = q1("select * from sessions where job_id=%s and user_id=%s order by created_at desc limit 1", [j["id"], user["id"]])
166
  if existing:
167
  return existing
 
 
168
  plan = [{"text": x["text"], "kind": x.get("kind", "custom"), "skill": x.get("skill", ""),
169
  "rubric": x.get("rubric", "")} for x in j["questions"]]
170
  ctx = {"plan": plan, "role": j["title"], "answer_seconds": j["answer_seconds"], "followups": 0,
@@ -184,6 +223,16 @@ def list_sessions(user: Dict = Depends(current_user)):
184
  [user["id"]])
185
 
186
 
 
 
 
 
 
 
 
 
 
 
187
  @router.get("/sessions/{sid}")
188
  def get_session(sid: str, user: Dict = Depends(current_user)):
189
  s = _session(sid, user)
@@ -428,6 +477,27 @@ class JobIn(BaseModel):
428
  questions: List[Dict] = Field(min_length=1, max_length=15)
429
  answer_seconds: int = Field(120, ge=30, le=600)
430
  status: str = "open"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
431
 
432
 
433
  def _clean_questions(qs: List[Dict]) -> List[Dict]:
@@ -444,10 +514,11 @@ def list_jobs(user: Dict = Depends(recruiter)):
444
 
445
  @router.post("/jobs")
446
  def create_job(body: JobIn, user: Dict = Depends(recruiter)):
447
- return q1("insert into jobs (company_id, created_by, title, track, level, description, questions, answer_seconds, status) "
448
- "values (%s,%s,%s,%s,%s,%s,%s,%s,%s) returning *",
449
- [user["company_id"], user["id"], body.title, body.track, body.level, body.description,
450
- _clean_questions(body.questions), body.answer_seconds, body.status if body.status in ("draft", "open", "closed") else "open"])
 
451
 
452
 
453
  def _job(jid: str, user: Dict) -> Dict:
@@ -465,9 +536,9 @@ def get_job(jid: str, user: Dict = Depends(recruiter)):
465
  @router.put("/jobs/{jid}")
466
  def update_job(jid: str, body: JobIn, user: Dict = Depends(recruiter)):
467
  _job(jid, user)
468
- return q1("update jobs set title=%s, track=%s, level=%s, description=%s, questions=%s, answer_seconds=%s, status=%s "
469
- "where id=%s returning *", [body.title, body.track, body.level, body.description, _clean_questions(body.questions),
470
- body.answer_seconds, body.status, jid])
471
 
472
 
473
  @router.get("/jobs/{jid}/candidates")
@@ -498,3 +569,125 @@ def review(sid: str, body: ReviewIn, user: Dict = Depends(recruiter)):
498
  "on conflict (session_id) do update set status=excluded.status, rating=excluded.rating, notes=excluded.notes, "
499
  "reviewer_id=excluded.reviewer_id, updated_at=now() returning *",
500
  [sid, user["id"], body.status, body.rating, body.notes])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  import tempfile
6
  import time
7
  from concurrent.futures import ThreadPoolExecutor
8
+ import csv
9
+ import io
10
+ from datetime import date, timedelta
11
  from typing import Dict, List, Optional
12
 
13
  from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
 
44
  return ai.catalog()
45
 
46
 
47
+ JOB_PUBLIC_COLS = ("j.id, j.title, j.track, j.level, j.location, j.work_mode, j.employment_type, j.salary, j.skills, "
48
+ "j.deadline, j.share_token, j.created_at, j.answer_seconds, jsonb_array_length(j.questions) as question_count, "
49
+ "c.name as company, c.website, c.industry, c.size as company_size, c.about as company_about, "
50
+ "c.location as company_location")
51
+ ACCEPTING = "(j.status = 'open' and (j.deadline is null or j.deadline >= current_date))"
52
+
53
+
54
  @router.get("/public/jobs/{token}")
55
  def public_job(token: str):
56
+ j = q1(f"update jobs j set views = views + 1 from companies c where c.id = j.company_id and j.share_token = %s "
57
+ f"returning {JOB_PUBLIC_COLS}, j.description, j.status, {ACCEPTING} as accepting", [token])
 
58
  if not j:
59
  raise HTTPException(404, "This job link is not valid")
60
  return j
61
 
62
 
63
+ @router.get("/board")
64
+ def job_board(search: str = "", track: str = "", level: str = "", work_mode: str = "", employment_type: str = ""):
65
+ """Open public jobs from all companies (no sign-in needed)."""
66
+ where, params = [ACCEPTING, "j.visibility = 'public'"], {}
67
+ if search.strip():
68
+ where.append("(j.title ilike %(s)s or j.description ilike %(s)s or c.name ilike %(s)s or j.skills::text ilike %(s)s "
69
+ "or j.location ilike %(s)s)")
70
+ params["s"] = f"%{search.strip()[:80]}%"
71
+ for col, val in (("track", track), ("level", level), ("work_mode", work_mode), ("employment_type", employment_type)):
72
+ if val:
73
+ where.append(f"j.{col} = %({col})s")
74
+ params[col] = val
75
+ return q(f"select {JOB_PUBLIC_COLS}, left(j.description, 260) as snippet, "
76
+ f"(select count(*) from sessions s where s.job_id = j.id) as applicants "
77
+ f"from jobs j join companies c on c.id = j.company_id where {' and '.join(where)} "
78
+ f"order by j.created_at desc limit 200", params)
79
+
80
+
81
  # --------------------------------------------------------------------------------------
82
  # Profile & company
83
  # --------------------------------------------------------------------------------------
 
98
  @router.patch("/me")
99
  def update_me(body: ProfileIn, user: Dict = Depends(current_user)):
100
  fields = {k: v for k, v in body.model_dump().items() if v is not None}
101
+ if "role" in fields and fields["role"] != user["role"]:
102
+ if fields["role"] != "candidate" or user.get("company_id"):
103
+ raise HTTPException(400, "Company accounts are created from the company setup page")
104
  if fields:
105
  sets = ", ".join(f"{k} = %({k})s" for k in fields)
106
  q(f"update profiles set {sets} where id = %(id)s", {**fields, "id": user["id"]})
 
109
 
110
  class CompanyIn(BaseModel):
111
  name: str = Field(min_length=2, max_length=120)
112
+ website: Optional[str] = Field(None, max_length=200)
113
+ about: Optional[str] = Field(None, max_length=2000)
114
+ industry: Optional[str] = Field(None, max_length=80)
115
+ size: Optional[str] = Field(None, max_length=20)
116
+ location: Optional[str] = Field(None, max_length=120)
117
 
118
 
119
  @router.post("/companies")
120
  def create_company(body: CompanyIn, user: Dict = Depends(current_user)):
121
+ if user.get("company_id"): # fields that are not sent keep their current value
122
+ c = q1("update companies set name=%s, website=coalesce(%s, website), about=coalesce(%s, about), "
123
+ "industry=coalesce(%s, industry), size=coalesce(%s, size), location=coalesce(%s, location) where id=%s returning *",
124
+ [body.name, body.website, body.about, body.industry, body.size, body.location, user["company_id"]])
125
  else:
126
+ if user["role"] == "candidate" and q1("select 1 from sessions where user_id=%s limit 1", [user["id"]]):
127
+ raise HTTPException(400, "This account is already used for interviews. Create a separate account for your company.")
128
+ c = q1("insert into companies (name, website, about, industry, size, location, created_by) "
129
+ "values (%s, %s, %s, %s, %s, %s, %s) returning *",
130
+ [body.name, body.website, body.about, body.industry, body.size, body.location, user["id"]])
131
  q("update profiles set role='recruiter', company_id=%s where id=%s", [c["id"], user["id"]])
132
  return c
133
 
 
194
 
195
  @router.post("/jobs/apply/{token}")
196
  def apply_job(token: str, body: ApplyIn, user: Dict = Depends(current_user)):
197
+ if user["role"] == "recruiter":
198
+ raise HTTPException(403, "Company accounts can't apply to jobs. Use a personal account to apply.")
199
+ j = q1(f"select j.*, {ACCEPTING} as accepting from jobs j where j.share_token = %s", [token])
200
+ if not j:
201
  raise HTTPException(404, "This job is not accepting interviews")
202
  existing = q1("select * from sessions where job_id=%s and user_id=%s order by created_at desc limit 1", [j["id"], user["id"]])
203
  if existing:
204
  return existing
205
+ if not j["accepting"]:
206
+ raise HTTPException(400, "This job is not accepting interviews")
207
  plan = [{"text": x["text"], "kind": x.get("kind", "custom"), "skill": x.get("skill", ""),
208
  "rubric": x.get("rubric", "")} for x in j["questions"]]
209
  ctx = {"plan": plan, "role": j["title"], "answer_seconds": j["answer_seconds"], "followups": 0,
 
223
  [user["id"]])
224
 
225
 
226
+ @router.get("/applications")
227
+ def my_applications(user: Dict = Depends(current_user)):
228
+ return q("select s.id, s.status, s.overall_score, s.created_at, s.completed_at, j.title, j.share_token, j.location, "
229
+ "j.work_mode, j.employment_type, c.name as company, coalesce(r.status, 'new') as review_status, "
230
+ "(select count(*) from answers a where a.session_id = s.id) as answered, "
231
+ "jsonb_array_length(s.context->'plan') as planned "
232
+ "from sessions s join jobs j on j.id = s.job_id join companies c on c.id = j.company_id "
233
+ "left join reviews r on r.session_id = s.id where s.user_id = %s order by s.created_at desc", [user["id"]])
234
+
235
+
236
  @router.get("/sessions/{sid}")
237
  def get_session(sid: str, user: Dict = Depends(current_user)):
238
  s = _session(sid, user)
 
477
  questions: List[Dict] = Field(min_length=1, max_length=15)
478
  answer_seconds: int = Field(120, ge=30, le=600)
479
  status: str = "open"
480
+ location: Optional[str] = Field(None, max_length=120)
481
+ work_mode: str = "onsite"
482
+ employment_type: str = "full_time"
483
+ salary: Optional[str] = Field(None, max_length=80)
484
+ skills: List[str] = Field(default_factory=list, max_length=20)
485
+ deadline: Optional[date] = None
486
+ visibility: str = "public"
487
+
488
+ def clean(self) -> Dict:
489
+ d = self.model_dump()
490
+ d["status"] = d["status"] if d["status"] in ("draft", "open", "closed") else "open"
491
+ d["work_mode"] = d["work_mode"] if d["work_mode"] in ("onsite", "remote", "hybrid") else "onsite"
492
+ d["employment_type"] = d["employment_type"] if d["employment_type"] in ("full_time", "part_time", "internship", "contract") else "full_time"
493
+ d["visibility"] = "link" if d["visibility"] == "link" else "public"
494
+ d["skills"] = [x.strip()[:40] for x in d["skills"] if x and x.strip()][:20]
495
+ d["questions"] = _clean_questions(d["questions"])
496
+ return d
497
+
498
+
499
+ JOB_FIELDS = ["title", "track", "level", "description", "questions", "answer_seconds", "status", "location", "work_mode",
500
+ "employment_type", "salary", "skills", "deadline", "visibility"]
501
 
502
 
503
  def _clean_questions(qs: List[Dict]) -> List[Dict]:
 
514
 
515
  @router.post("/jobs")
516
  def create_job(body: JobIn, user: Dict = Depends(recruiter)):
517
+ d = body.clean()
518
+ cols = ", ".join(JOB_FIELDS)
519
+ vals = ", ".join(f"%({k})s" for k in JOB_FIELDS)
520
+ return q1(f"insert into jobs (company_id, created_by, {cols}) values (%(company_id)s, %(created_by)s, {vals}) returning *",
521
+ {**d, "company_id": user["company_id"], "created_by": user["id"]})
522
 
523
 
524
  def _job(jid: str, user: Dict) -> Dict:
 
536
  @router.put("/jobs/{jid}")
537
  def update_job(jid: str, body: JobIn, user: Dict = Depends(recruiter)):
538
  _job(jid, user)
539
+ d = body.clean()
540
+ sets = ", ".join(f"{k} = %({k})s" for k in JOB_FIELDS)
541
+ return q1(f"update jobs set {sets} where id = %(jid)s returning *", {**d, "jid": jid})
542
 
543
 
544
  @router.get("/jobs/{jid}/candidates")
 
569
  "on conflict (session_id) do update set status=excluded.status, rating=excluded.rating, notes=excluded.notes, "
570
  "reviewer_id=excluded.reviewer_id, updated_at=now() returning *",
571
  [sid, user["id"], body.status, body.rating, body.notes])
572
+
573
+
574
+ # --------------------------------------------------------------------------------------
575
+ # Recruiters: analytics
576
+ # --------------------------------------------------------------------------------------
577
+ def _f(v, nd=1):
578
+ return None if v is None else round(float(v), nd)
579
+
580
+
581
+ def _analytics(company_id: str, job_id: Optional[str], lang: str) -> Dict:
582
+ """Hiring analytics for a whole company, or for one job when job_id is given."""
583
+ F = "j.company_id = %(c)s" + (" and j.id = %(j)s" if job_id else "")
584
+ p = {"c": company_id, "j": job_id}
585
+ jobs = q1(f"select count(*) as jobs, count(*) filter (where {ACCEPTING}) as open_jobs, coalesce(sum(views), 0) as views "
586
+ f"from jobs j where {F}", p)
587
+ t = q1(f"select count(*) as applicants, count(distinct s.user_id) as people, "
588
+ f"count(*) filter (where s.status = 'completed') as completed, "
589
+ f"count(*) filter (where s.status in ('in_progress', 'processing')) as in_progress, "
590
+ f"avg(s.overall_score) filter (where s.status = 'completed') as avg_score, "
591
+ f"percentile_cont(0.5) within group (order by s.overall_score) filter (where s.status = 'completed') as median_score, "
592
+ f"max(s.overall_score) as best_score, "
593
+ f"count(*) filter (where r.status in ('shortlisted', 'hired')) as shortlisted, "
594
+ f"count(*) filter (where r.status = 'hired') as hired, count(*) filter (where r.status = 'rejected') as rejected, "
595
+ f"count(*) filter (where s.status = 'completed' and coalesce(r.status, 'new') = 'new') as to_review, "
596
+ f"percentile_cont(0.5) within group (order by extract(epoch from (s.completed_at - s.created_at)) / 60) "
597
+ f"filter (where s.status = 'completed' and s.completed_at - s.created_at between interval '0' and interval '1 day') as avg_minutes "
598
+ f"from sessions s join jobs j on j.id = s.job_id left join reviews r on r.session_id = s.id where {F}", p)
599
+
600
+ # applications per day, last 30 days
601
+ since = date.today() - timedelta(days=29)
602
+ days = {r["d"]: r for r in q(f"select s.created_at::date as d, count(*) as n, count(*) filter (where s.status = 'completed') as c "
603
+ f"from sessions s join jobs j on j.id = s.job_id where {F} and s.created_at >= %(since)s "
604
+ f"group by 1", {**p, "since": since})}
605
+ timeline = [{"date": (since + timedelta(days=i)).isoformat(),
606
+ "applicants": days.get(since + timedelta(days=i), {}).get("n", 0),
607
+ "completed": days.get(since + timedelta(days=i), {}).get("c", 0)} for i in range(30)]
608
+
609
+ done = q(f"select s.overall_score as score, s.report->'summary'->>'hiring_signal' as signal, "
610
+ f"s.report->'calibrated'->'traits' as traits from sessions s join jobs j on j.id = s.job_id "
611
+ f"where {F} and s.status = 'completed'", p)
612
+ buckets = [{"range": f"{b}-{b + 9 if b < 90 else 100}", "count": 0} for b in range(0, 100, 10)]
613
+ signals = {"strong_yes": 0, "yes": 0, "maybe": 0, "no": 0}
614
+ trait_sum: Dict[str, List[float]] = {}
615
+ for d in done:
616
+ if d["score"] is not None:
617
+ buckets[min(int(d["score"] // 10), 9)]["count"] += 1
618
+ if d["signal"] in signals:
619
+ signals[d["signal"]] += 1
620
+ for tr in d["traits"] or []:
621
+ trait_sum.setdefault(tr["trait"], []).append(tr["percentile"])
622
+ labels = cal.TRAIT_LABELS.get(lang, cal.TRAIT_LABELS["en"])
623
+ traits = sorted([{"trait": k, "label": labels.get(k, k), "avg_percentile": round(sum(v) / len(v)), "n": len(v)}
624
+ for k, v in trait_sum.items()], key=lambda x: -x["avg_percentile"])
625
+
626
+ skills = q(f"select a.feedback->>'skill' as skill, round(avg(a.score)::numeric, 1) as avg, count(*) as n "
627
+ f"from answers a join sessions s on s.id = a.session_id join jobs j on j.id = s.job_id "
628
+ f"where {F} and a.score is not null and coalesce(a.feedback->>'skill', '') <> '' "
629
+ f"group by 1 order by n desc, avg desc limit 10", p)
630
+
631
+ top = q(f"select s.id, s.overall_score, s.completed_at, s.report->>'content_score' as content_score, "
632
+ f"s.report->>'delivery_score' as delivery_score, s.report->'summary'->>'hiring_signal' as hiring_signal, "
633
+ f"s.report->'summary'->>'headline' as headline, p.full_name, p.email, p.headline as candidate_headline, "
634
+ f"j.id as job_id, j.title as job_title, coalesce(r.status, 'new') as review_status, r.rating "
635
+ f"from sessions s join jobs j on j.id = s.job_id join profiles p on p.id = s.user_id "
636
+ f"left join reviews r on r.session_id = s.id where {F} and s.status = 'completed' "
637
+ f"order by s.overall_score desc nulls last limit 10", p)
638
+
639
+ per_job = [] if job_id else q(
640
+ f"select j.id, j.title, j.status, j.views, j.created_at, j.deadline, {ACCEPTING} as accepting, "
641
+ f"count(s.id) as applicants, count(s.id) filter (where s.status = 'completed') as completed, "
642
+ f"round(avg(s.overall_score) filter (where s.status = 'completed')::numeric, 1) as avg_score, "
643
+ f"max(s.overall_score) as best_score, count(r.session_id) filter (where r.status in ('shortlisted', 'hired')) as shortlisted, "
644
+ f"count(r.session_id) filter (where r.status = 'hired') as hired "
645
+ f"from jobs j left join sessions s on s.job_id = j.id left join reviews r on r.session_id = s.id "
646
+ f"where {F} group by j.id order by j.created_at desc", p)
647
+
648
+ applicants = t["applicants"] or 0
649
+ views = max(int(jobs["views"]), applicants) # people who apply from a shared link may never load the job page
650
+ return {
651
+ "totals": {"jobs": jobs["jobs"], "open_jobs": jobs["open_jobs"], "views": views,
652
+ "applicants": applicants, "people": t["people"], "completed": t["completed"],
653
+ "in_progress": t["in_progress"], "shortlisted": t["shortlisted"], "hired": t["hired"],
654
+ "rejected": t["rejected"], "to_review": t["to_review"],
655
+ "avg_score": _f(t["avg_score"]), "median_score": _f(t["median_score"]), "best_score": _f(t["best_score"]),
656
+ "completion_rate": round(100 * t["completed"] / applicants) if applicants else None,
657
+ "avg_minutes": _f(t["avg_minutes"])},
658
+ "funnel": [{"stage": "views", "count": views}, {"stage": "started", "count": applicants},
659
+ {"stage": "completed", "count": t["completed"]}, {"stage": "shortlisted", "count": t["shortlisted"]},
660
+ {"stage": "hired", "count": t["hired"]}],
661
+ "timeline": timeline, "distribution": buckets, "signals": signals, "traits": traits,
662
+ "skills": [{"skill": r["skill"], "avg": float(r["avg"]), "n": r["n"]} for r in skills],
663
+ "top": top, "jobs": per_job,
664
+ }
665
+
666
+
667
+ @router.get("/company/stats")
668
+ def company_stats(lang: str = "en", user: Dict = Depends(recruiter)):
669
+ return _analytics(user["company_id"], None, "ar" if lang == "ar" else "en")
670
+
671
+
672
+ @router.get("/jobs/{jid}/stats")
673
+ def job_stats(jid: str, lang: str = "en", user: Dict = Depends(recruiter)):
674
+ _job(jid, user)
675
+ return _analytics(user["company_id"], jid, "ar" if lang == "ar" else "en")
676
+
677
+
678
+ @router.get("/jobs/{jid}/candidates.csv")
679
+ def job_candidates_csv(jid: str, user: Dict = Depends(recruiter)):
680
+ j = _job(jid, user)
681
+ rows = job_candidates(jid, user)
682
+ buf = io.StringIO()
683
+ w = csv.writer(buf)
684
+ w.writerow(["rank", "name", "email", "interview_status", "overall_score", "content_score", "delivery_score",
685
+ "evalbot_signal", "decision", "rating", "notes", "applied_at", "report_url"])
686
+ for i, r in enumerate(rows, 1):
687
+ w.writerow([i, r["full_name"] or "", r["email"] or "", r["status"], _f(r["overall_score"]) or "",
688
+ r["content_score"] or "", r["delivery_score"] or "", r["hiring_signal"] or "", r["review_status"],
689
+ r["rating"] or "", (r["notes"] or "").replace("\n", " "), r["created_at"].strftime("%Y-%m-%d %H:%M"),
690
+ f"/app/report/{r['id']}"])
691
+ name = "".join(ch if ch.isalnum() else "-" for ch in j["title"])[:40] or "job"
692
+ return Response("" + buf.getvalue(), media_type="text/csv; charset=utf-8",
693
+ headers={"Content-Disposition": f'attachment; filename="evalbot-{name}-candidates.csv"'})
platform_app/schema.sql CHANGED
@@ -86,3 +86,27 @@ create table if not exists reviews (
86
  notes text,
87
  updated_at timestamptz not null default now()
88
  );
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  notes text,
87
  updated_at timestamptz not null default now()
88
  );
89
+
90
+ -- v2: job board, richer job posts and company profiles (idempotent migrations)
91
+ alter table companies add column if not exists industry text;
92
+ alter table companies add column if not exists size text;
93
+ alter table companies add column if not exists location text;
94
+
95
+ alter table jobs add column if not exists location text;
96
+ alter table jobs add column if not exists work_mode text not null default 'onsite'; -- onsite | remote | hybrid
97
+ alter table jobs add column if not exists employment_type text not null default 'full_time'; -- full_time | part_time | internship | contract
98
+ alter table jobs add column if not exists salary text;
99
+ alter table jobs add column if not exists skills jsonb not null default '[]'::jsonb;
100
+ alter table jobs add column if not exists deadline date;
101
+ alter table jobs add column if not exists visibility text not null default 'public'; -- public (job board) | link (only via link)
102
+ alter table jobs add column if not exists views int not null default 0;
103
+ create index if not exists jobs_board_idx on jobs(status, visibility, created_at desc);
104
+
105
+ -- Lock tables against Supabase's public Data API: the app server connects as the database owner
106
+ -- (which bypasses RLS), while the anon/authenticated API roles get no rows at all (no policies).
107
+ alter table companies enable row level security;
108
+ alter table profiles enable row level security;
109
+ alter table jobs enable row level security;
110
+ alter table sessions enable row level security;
111
+ alter table answers enable row level security;
112
+ alter table reviews enable row level security;