Spaces:
Running
Running
Company and candidate workspaces, job board, applications, hiring analytics
Browse files- README.md +6 -1
- frontend/src/components/Shell.tsx +5 -4
- frontend/src/components/hiring.tsx +212 -0
- frontend/src/components/jobs.tsx +76 -0
- frontend/src/lib/api.ts +52 -1
- frontend/src/lib/i18n.tsx +66 -0
- frontend/src/main.tsx +21 -7
- frontend/src/pages/Applications.tsx +63 -0
- frontend/src/pages/Apply.tsx +68 -32
- frontend/src/pages/Board.tsx +87 -0
- frontend/src/pages/CompanyDashboard.tsx +96 -0
- frontend/src/pages/Dashboard.tsx +8 -2
- frontend/src/pages/JobDetail.tsx +64 -8
- frontend/src/pages/JobEditor.tsx +40 -4
- frontend/src/pages/Jobs.tsx +2 -0
- frontend/src/pages/Landing.tsx +17 -1
- frontend/src/pages/Onboarding.tsx +16 -4
- frontend/src/pages/Settings.tsx +16 -5
- platform_app/core.py +3 -1
- platform_app/routes.py +214 -21
- platform_app/schema.sql +24 -0
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 |
-
-
|
|
|
|
|
|
|
|
|
|
| 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?:
|
| 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 |
-
|
|
|
|
| 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 {
|
| 5 |
-
import {
|
| 6 |
-
import {
|
|
|
|
| 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 |
-
|
| 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-
|
| 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
|
| 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 {
|
| 5 |
-
import {
|
|
|
|
|
|
|
| 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
|
| 25 |
-
const
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 = {"
|
|
|
|
|
|
|
| 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("
|
| 47 |
-
"
|
| 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"]
|
| 75 |
-
|
|
|
|
| 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
|
| 92 |
-
|
|
|
|
| 93 |
else:
|
| 94 |
-
|
| 95 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 163 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
|
|
|
| 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 |
-
|
| 469 |
-
|
| 470 |
-
|
| 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;
|