/** * Job Description Management (manage/jds/*, jd_management.py). * * Persona: the existing Recruiter (jds.create) creates/uploads/AI-generates/ * edits/submits JDs; the existing Recruitment Administrator and Admin * (jds.approve) approve, reject, publish, and archive them -- the same * propose/approve split already established for bulk candidate decisions * (screening.propose/screening.approve), not a new pattern invented for * this feature. Hiring Manager gets read-only jds.read for context. * * PUBLISH is the only action that makes a JD live for candidate matching -- * it syncs into jd_folder/{jd_id}.txt, which the existing, already- * verified resume-scoring pipeline (jd_manager.py) reads unchanged. Draft/ * pending/rejected/archived JDs never reach that file, so they never * affect scoring. */ var JD_STATUS_BADGE = { draft: "badge-neutral", pending_approval: "badge-neutral", approved: "badge-good", published: "badge-good", rejected: "badge-bad", archived: "badge-neutral", }; var JD_STATUS_LABEL = { draft: "Draft", pending_approval: "Pending Approval", approved: "Approved", published: "Published", rejected: "Rejected", archived: "Archived", }; var JD_EMPTY_FORM = { title: "", description: "", responsibilities: "", required_qualifications: "", preferred_qualifications: "", required_skills: "", preferred_skills: "", required_experience: "", seniority_level: "", employment_type: "", location: "", work_arrangement: "", department: "", openings_count: "", salary_range: "", hiring_contact: "", application_deadline: "", additional_requirements: "", }; function _jdLinesToList(text) { return (text || "").split("\n").map(function (l) { return l.trim(); }).filter(Boolean); } function _jdListToLines(list) { return (list || []).join("\n"); } function JDManagement(props) { var api = props.api; var permissions = props.permissions || []; var canCreate = permissions.indexOf("jds.create") !== -1; var canApprove = permissions.indexOf("jds.approve") !== -1; var tabState = React.useState("board"); // "board" | "create" | "detail" var tab = tabState[0]; var setTab = tabState[1]; var selectedIdState = React.useState(null); var selectedId = selectedIdState[0]; var setSelectedId = selectedIdState[1]; function openDetail(jdId) { setSelectedId(jdId); setTab("detail"); } function backToBoard() { setSelectedId(null); setTab("board"); } return (

Job Descriptions

Create, upload, AI-generate, and manage job descriptions. Publishing a JD is the only action that makes it live for candidate matching.

{canCreate && ( )}
{tab === "board" && } {tab === "create" && } {tab === "detail" && selectedId && ( )}
); } function JDBoard(props) { var api = props.api; var statusState = React.useState(""); var status = statusState[0]; var setStatus = statusState[1]; var searchState = React.useState(""); var search = searchState[0]; var setSearch = searchState[1]; var dataState = React.useState({ loading: true, error: null, jds: [] }); var data = dataState[0]; var setData = dataState[1]; function load() { setData(function (d) { return Object.assign({}, d, { loading: true, error: null }); }); api.listJdRecords(status || undefined) .then(function (result) { setData({ loading: false, error: null, jds: result.jds || [] }); }) .catch(function (err) { setData({ loading: false, error: err.message, jds: [] }); }); } React.useEffect(function () { load(); }, [status]); var visible = data.jds.filter(function (jd) { return !search || (jd.title || "").toLowerCase().indexOf(search.toLowerCase()) !== -1; }); return (
{data.error &&
{data.error}
} {!data.loading && visible.length === 0 &&

No job descriptions found.

} {visible.length > 0 && (
{visible.map(function (jd) { return ( ); })}
TitleStatusDepartmentCreated byUpdated
{jd.title} {JD_STATUS_LABEL[jd.status] || jd.status} {jd.department || "—"} {jd.created_by} {jd.updated_at || jd.created_at}
)}
); } function JDCreateForm(props) { var api = props.api; var formState = React.useState(JD_EMPTY_FORM); var form = formState[0]; var setForm = formState[1]; var savingState = React.useState(false); var saving = savingState[0]; var setSaving = savingState[1]; var errorState = React.useState(null); var error = errorState[0]; var setError = errorState[1]; var uploadBusyState = React.useState(false); var uploadBusy = uploadBusyState[0]; var setUploadBusy = uploadBusyState[1]; var uploadResultState = React.useState(null); var uploadResult = uploadResultState[0]; var setUploadResult = uploadResultState[1]; var genState = React.useState({ title: "", role_purpose: "", responsibilities: "", required_skills: "", experience: "", seniority: "", department: "" }); var gen = genState[0]; var setGen = genState[1]; var genBusyState = React.useState(false); var genBusy = genBusyState[0]; var setGenBusy = genBusyState[1]; var genErrorState = React.useState(null); var genError = genErrorState[0]; var setGenError = genErrorState[1]; var sourceState = React.useState("manual"); // "manual" | "uploaded" | "ai_generated" var source = sourceState[0]; var setSource = sourceState[1]; var aiMetaState = React.useState(null); var aiMeta = aiMetaState[0]; var setAiMeta = aiMetaState[1]; var uploadBlobPathState = React.useState(null); var uploadBlobPath = uploadBlobPathState[0]; var setUploadBlobPath = uploadBlobPathState[1]; function setField(name) { return function (e) { var v = e.target.value; setForm(function (f) { var u = Object.assign({}, f); u[name] = v; return u; }); }; } function applyExtractedFields(fields) { setForm(function (f) { var u = Object.assign({}, f); ["title", "description", "required_experience", "seniority_level", "employment_type", "location", "department"].forEach(function (k) { if (fields[k]) u[k] = fields[k]; }); ["responsibilities", "required_qualifications", "preferred_qualifications", "required_skills", "preferred_skills"].forEach(function (k) { if (fields[k]) u[k] = _jdListToLines(fields[k]); }); return u; }); } function handleUpload(e) { var file = e.target.files && e.target.files[0]; if (!file) return; setUploadBusy(true); setUploadResult(null); setError(null); var reader = new FileReader(); reader.onload = function () { var base64 = reader.result.slice(reader.result.indexOf(",") + 1); api.uploadJdDocument(base64, file.name) .then(function (result) { setUploadResult(result); setUploadBlobPath(result.source_document_blob_path); if (result.extraction_succeeded) { applyExtractedFields(result.extracted_fields); setSource("uploaded"); } }) .catch(function (err) { setError(err.message); }) .then(function () { setUploadBusy(false); }); }; reader.onerror = function () { setUploadBusy(false); setError("Could not read the selected file."); }; reader.readAsDataURL(file); } function handleGenerate() { if (!gen.title.trim()) { setGenError("Title is required to generate a JD."); return; } setGenBusy(true); setGenError(null); api.generateJdWithAi({ title: gen.title, role_purpose: gen.role_purpose, responsibilities: _jdLinesToList(gen.responsibilities), required_skills: _jdLinesToList(gen.required_skills), experience: gen.experience, seniority: gen.seniority, department: gen.department, }) .then(function (result) { applyExtractedFields(result.fields); setForm(function (f) { return Object.assign({}, f, { title: result.fields.title || gen.title, seniority_level: gen.seniority, department: gen.department }); }); setAiMeta(result.metadata); setSource("ai_generated"); }) .catch(function (err) { setGenError(err.message); }) .then(function () { setGenBusy(false); }); } function handleSave(andSubmit) { setSaving(true); setError(null); var payload = { title: form.title, description: form.description, responsibilities: _jdLinesToList(form.responsibilities), required_qualifications: _jdLinesToList(form.required_qualifications), preferred_qualifications: _jdLinesToList(form.preferred_qualifications), required_skills: _jdLinesToList(form.required_skills), preferred_skills: _jdLinesToList(form.preferred_skills), required_experience: form.required_experience, seniority_level: form.seniority_level, employment_type: form.employment_type, location: form.location, work_arrangement: form.work_arrangement, department: form.department, openings_count: form.openings_count || undefined, salary_range: form.salary_range, hiring_contact: form.hiring_contact, application_deadline: form.application_deadline, additional_requirements: form.additional_requirements, source: source, }; if (uploadBlobPath) payload.source_document_blob_path = uploadBlobPath; if (aiMeta) payload.ai_generation_metadata = aiMeta; api.createJd(payload) .then(function (jd) { if (andSubmit) return api.submitJd(jd.jd_id).then(function () { return jd; }); return jd; }) .then(function (jd) { props.onCreated(jd.jd_id); }) .catch(function (err) { setError(err.message); }) .then(function () { setSaving(false); }); } return (

AI-Assisted Generation (optional)

Real, billable LLM call -- fills the form below as a draft for you to review and edit.