Audio-EvalBot / frontend /src /pages /JobEditor.tsx
norhan12's picture
Camera analysis, integrity flags, video interviews, soft skills, CV profiles
ec1a96a verified
Raw History Blame Contribute Delete
10.5 kB
import { ArrowDown, ArrowUp, Plus, Sparkles, Trash2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useNavigate, useParams } from "react-router-dom";
import { Button, ErrorNote, Field, Input, Panel, Select, Spinner, Textarea, useCatalog } from "../components/ui";
import { api, type Job, type PlanQuestion } from "../lib/api";
import { useI18n } from "../lib/i18n";
export default function JobEditor() {
const { id } = useParams();
const { t, lang } = useI18n();
const catalog = useCatalog();
const nav = useNavigate();
const [loaded, setLoaded] = useState(!id);
const [title, setTitle] = useState("");
const [track, setTrack] = useState("data_engineering");
const [level, setLevel] = useState("junior");
const [desc, setDesc] = useState("");
const [secs, setSecs] = useState(120);
const [status, setStatus] = useState<Job["status"]>("open");
const [qs, setQs] = useState<PlanQuestion[]>([{ text: "" }]);
const [location, setLocation] = useState("");
const [workMode, setWorkMode] = useState<Job["work_mode"]>("onsite");
const [empType, setEmpType] = useState<Job["employment_type"]>("full_time");
const [salary, setSalary] = useState("");
const [skills, setSkills] = useState("");
const [deadline, setDeadline] = useState("");
const [visibility, setVisibility] = useState<Job["visibility"]>("public");
const [requireCamera, setRequireCamera] = useState(false);
const [recordVideo, setRecordVideo] = useState(false);
const [busy, setBusy] = useState(false);
const [suggesting, setSuggesting] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!id) return;
api.get<Job>(`/jobs/${id}`).then((j) => {
setTitle(j.title); setTrack(j.track); setLevel(j.level); setDesc(j.description || "");
setSecs(j.answer_seconds); setStatus(j.status); setQs(j.questions.length ? j.questions : [{ text: "" }]);
setLocation(j.location || ""); setWorkMode(j.work_mode); setEmpType(j.employment_type); setSalary(j.salary || "");
setSkills((j.skills || []).join(", ")); setDeadline(j.deadline || ""); setVisibility(j.visibility);
setRequireCamera(!!j.require_camera); setRecordVideo(!!j.record_video);
setLoaded(true);
});
}, [id]);
const suggest = async () => {
setSuggesting(true);
setError(null);
try {
const r = await api.post<{ questions: PlanQuestion[] }>("/questions/generate", { track, level, interview_type: "mixed", count: 6, custom_role: title, jd_text: desc });
setQs((cur) => [...cur.filter((x) => x.text.trim()), ...r.questions]);
} catch (e: any) {
setError(e.message);
} finally {
setSuggesting(false);
}
};
const move = (i: number, d: number) => setQs((cur) => {
const n = [...cur];
const j = i + d;
if (j < 0 || j >= n.length) return n;
[n[i], n[j]] = [n[j], n[i]];
return n;
});
const save = async () => {
setBusy(true);
setError(null);
try {
const body = {
title, track, level, description: desc, answer_seconds: secs, status, questions: qs.filter((x) => x.text.trim()),
location: location.trim() || null, work_mode: workMode, employment_type: empType, salary: salary.trim() || null,
skills: skills.split(/[,،\n]/).map((x) => x.trim()).filter(Boolean), deadline: deadline || null, visibility,
require_camera: requireCamera || recordVideo, record_video: recordVideo,
};
const j = id ? await api.put<Job>(`/jobs/${id}`, body) : await api.post<Job>("/jobs", body);
nav(`/app/jobs/${j.id}`);
} catch (e: any) {
setError(e.message);
setBusy(false);
}
};
if (!loaded || !catalog) return <Spinner />;
const levels = ["intern", "junior", "mid", "senior", "lead"];
return (
<div className="space-y-6">
<h1 className="text-3xl font-semibold text-ink">{id ? title : t("new_job")}</h1>
<Panel title={t("job_basics")}>
<div className="grid gap-4 md:grid-cols-2">
<Field label={t("job_title")}><Input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Junior Data Engineer" /></Field>
<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>
<Field label={t("level")}><Select value={level} onChange={setLevel} options={levels.map((l) => ({ value: l, label: t(`lv_${l}` as "lv_junior") }))} /></Field>
<div className="grid grid-cols-2 gap-4">
<Field label={t("answer_time")}>
<Select value={String(secs)} onChange={(v) => setSecs(+v)} options={[60, 90, 120, 180, 240, 300].map((s) => ({ value: String(s), label: `${s / 60} ${t("min")}` }))} />
</Field>
<Field label={t("job_status")}>
<Select value={status} onChange={(v) => setStatus(v as Job["status"])} options={(["open", "draft", "closed"] as const).map((s) => ({ value: s, label: t(`jst_${s}`) }))} />
</Field>
</div>
<Field label={t("location")}><Input value={location} onChange={(e) => setLocation(e.target.value)} placeholder={t("location_ph")} /></Field>
<div className="grid grid-cols-2 gap-4">
<Field label={t("work_mode")}>
<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}`) }))} />
</Field>
<Field label={t("employment_type")}>
<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}`) }))} />
</Field>
</div>
<Field label={`${t("salary")} (${t("optional")})`}><Input value={salary} onChange={(e) => setSalary(e.target.value)} placeholder={t("salary_ph")} /></Field>
<Field label={`${t("deadline_l")} (${t("optional")})`}><Input type="date" value={deadline} onChange={(e) => setDeadline(e.target.value)} /></Field>
<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>
<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>
<div className="md:col-span-2">
<span className="mb-1.5 block text-sm font-medium text-ink">{t("visibility")}</span>
<div className="grid gap-2 sm:grid-cols-2">
{(["public", "link"] as const).map((v) => (
<button key={v} type="button" onClick={() => setVisibility(v)} aria-pressed={visibility === v}
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"}`}>
{t(v === "public" ? "vis_public" : "vis_link")}
</button>
))}
</div>
</div>
</div>
</Panel>
<Panel title={t("job_interview")}>
<div className="grid gap-3 md:grid-cols-2">
{([[requireCamera || recordVideo, (v: boolean) => { setRequireCamera(v); if (!v) setRecordVideo(false); }, t("require_camera"), t("require_camera_d")],
[recordVideo, (v: boolean) => { setRecordVideo(v); if (v) setRequireCamera(true); }, t("record_video"), t("record_video_d")]] as const).map(([on, set, title, desc]) => (
<label key={title} className={`flex cursor-pointer items-start gap-3 rounded-xl border p-4 ${on ? "border-nile bg-nile-soft" : "border-line hover:border-nile"}`}>
<input type="checkbox" checked={on} onChange={(e) => set(e.target.checked)} className="mt-1 h-4 w-4 accent-[var(--color-nile)]" />
<span><span className="block font-medium text-ink">{title}</span><span className="mt-0.5 block text-sm text-muted">{desc}</span></span>
</label>
))}
</div>
</Panel>
<Panel title={`${t("job_interview")}: ${t("questions")}`} action={<Button variant="outline" onClick={suggest} busy={suggesting}><Sparkles size={16} /> {suggesting ? t("suggesting") : t("suggest")}</Button>}>
<ol className="space-y-3">
{qs.map((q, i) => (
<li key={i} className="flex items-start gap-2">
<span className="mt-2.5 w-6 flex-none text-center text-sm tabular text-muted">{i + 1}</span>
<Textarea value={q.text} onChange={(e) => setQs((cur) => cur.map((x, k) => (k === i ? { ...x, text: e.target.value } : x)))}
placeholder={t("question_ph")} className="ltr min-h-16" dir="ltr" />
<div className="flex flex-col">
<button onClick={() => move(i, -1)} className="rounded p-1 text-muted hover:bg-sunken" aria-label="Move up"><ArrowUp size={16} /></button>
<button onClick={() => move(i, 1)} className="rounded p-1 text-muted hover:bg-sunken" aria-label="Move down"><ArrowDown size={16} /></button>
</div>
<button onClick={() => setQs((cur) => cur.filter((_, k) => k !== i))} className="mt-1 rounded-lg p-2 text-muted hover:bg-danger-soft hover:text-danger" aria-label={t("delete")}>
<Trash2 size={16} />
</button>
</li>
))}
</ol>
<Button variant="quiet" className="mt-3" onClick={() => setQs((cur) => [...cur, { text: "" }])}><Plus size={16} /> {t("add_question")}</Button>
</Panel>
{error && <ErrorNote>{error}</ErrorNote>}
<div className="flex flex-wrap justify-end gap-3">
{id && (
<Button variant="quiet" className="me-auto text-danger hover:bg-danger-soft" onClick={async () => {
if (!window.confirm(t("delete_job_confirm"))) return;
try { await api.del(`/jobs/${id}`); nav("/app/jobs"); } catch (e: any) { setError(e.message); }
}}><Trash2 size={16} /> {t("delete_job")}</Button>
)}
<Button variant="quiet" onClick={() => nav(-1)}>{t("cancel")}</Button>
<Button size="lg" onClick={save} busy={busy} disabled={title.trim().length < 2 || !qs.some((x) => x.text.trim())}>{t("save_job")}</Button>
</div>
</div>
);
}