184 lines
6.7 KiB
JavaScript
184 lines
6.7 KiB
JavaScript
import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient'
|
||
import { toDate } from '../lib/format'
|
||
|
||
/**
|
||
* Job requisitions — backend/job/app.py `GET /jobs/fetch`.
|
||
*
|
||
* Distinct from api/jobPosts.js on purpose: that serves the Matching and CV-import
|
||
* PICKERS off /job/fetch (job_board.view). This is the requisition list behind
|
||
* jobs.view, and carries department / vacancies / requisition_status, which the
|
||
* picker payload does not.
|
||
*/
|
||
export function list({ search, department, requisitionStatus, employmentType,
|
||
hiringManagerId, top, skip, activeOnly } = {}) {
|
||
return request('/jobs/fetch', {
|
||
params: {
|
||
search,
|
||
department,
|
||
requisition_status: requisitionStatus,
|
||
employment_type: employmentType,
|
||
hiring_manager_id: hiringManagerId,
|
||
top,
|
||
skip,
|
||
active_only: activeOnly,
|
||
},
|
||
})
|
||
}
|
||
|
||
/* requisition_status is the HIRING lifecycle. The row's separate `status` field is
|
||
the Buffer publishing lifecycle — never map the two onto one badge. Fallback
|
||
matches GET /jobs/requisition-statuses/fetch so the dropdown still works if
|
||
that call 403s. */
|
||
export const REQUISITION_STATUSES = [
|
||
{ value: 'open', label: 'Open' },
|
||
{ value: 'on_hold', label: 'On Hold' },
|
||
{ value: 'closed', label: 'Closed' },
|
||
{ value: 'completed', label: 'Completed' },
|
||
]
|
||
export const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label]))
|
||
const LABEL_TO_STATUS = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.label, s.value]))
|
||
export const JOB_STATUSES = REQUISITION_STATUSES.map((s) => s.label)
|
||
|
||
export function listRequisitionStatuses() {
|
||
return request('/jobs/requisition-statuses/fetch')
|
||
}
|
||
|
||
/**
|
||
* Distinct departments across all requisitions (closed included — a past
|
||
* hiring department is a legitimate lens on a past window). The shared option
|
||
* list for the Dashboard and Analytics department filters: both screens cache
|
||
* it under qk.jobs.list({ scope: 'departments' }), and React Query caches on
|
||
* the key alone, so every consumer of that key must use THIS fetcher — two
|
||
* queryFns returning different shapes on one key overwrite each other (the
|
||
* React error #31 failure documented in api/inbox.js).
|
||
*/
|
||
export async function fetchDepartmentOptions() {
|
||
const res = await list({ top: 500, activeOnly: false })
|
||
const rows = Array.isArray(res?.data) ? res.data : []
|
||
return [...new Set(rows.map((r) => r.department).filter(Boolean))].sort()
|
||
}
|
||
|
||
function experienceLabel(min, max) {
|
||
if (min == null && max == null) return null
|
||
if (min != null && max != null) return `${min}–${max} years`
|
||
return `${min ?? max}+ years`
|
||
}
|
||
|
||
function recruiterIdsFrom(row) {
|
||
const ids = Array.isArray(row?.current_recruiter_ids)
|
||
? row.current_recruiter_ids.filter(Boolean).map(String)
|
||
: []
|
||
if (ids.length) return ids
|
||
return row?.current_recruiter_id ? [String(row.current_recruiter_id)] : []
|
||
}
|
||
|
||
function recruiterNamesFrom(row) {
|
||
if (Array.isArray(row?.recruiter_names) && row.recruiter_names.length) {
|
||
return row.recruiter_names.filter(Boolean)
|
||
}
|
||
if (Array.isArray(row?.recruiters) && row.recruiters.length) {
|
||
return row.recruiters.map((r) => r?.name).filter(Boolean)
|
||
}
|
||
return row?.recruiter_name ? [row.recruiter_name] : []
|
||
}
|
||
|
||
/** API row -> what the Jobs table and detail modal render. */
|
||
export function toJobView(row) {
|
||
const recruiterIds = recruiterIdsFrom(row)
|
||
const recruiterNames = recruiterNamesFrom(row)
|
||
return {
|
||
id: row.id,
|
||
title: row.title,
|
||
department: row.department,
|
||
location: row.location,
|
||
type: row.employment_type,
|
||
vacancies: row.vacancies,
|
||
platform: row.platform || null,
|
||
status: REQ_STATUS_LABEL[row.requisition_status] ?? row.requisition_status,
|
||
publishStatus: row.status,
|
||
recruiter: recruiterNames.join(', ') || null,
|
||
recruiterId: recruiterIds[0] || null,
|
||
recruiterIds,
|
||
recruiterNames,
|
||
hiringManager: row.hiring_manager_name,
|
||
hiringManagerId: row.hiring_manager_id,
|
||
createdByName: row.created_by_name,
|
||
applicantCount: row.applicant_count ?? 0,
|
||
// A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working.
|
||
created: toDate(row.created_at),
|
||
closedAt: toDate(row.closed_at),
|
||
requisitionStatus: row.requisition_status,
|
||
experienceMin: row.experience_min,
|
||
experienceMax: row.experience_max,
|
||
experience: experienceLabel(row.experience_min, row.experience_max),
|
||
skills: row.requirements ?? [],
|
||
optionalSkills: row.optional_skills ?? [],
|
||
description: row.description,
|
||
requisitionId: row.requisition_id || null,
|
||
requisitionTitle: row.requisition_title || '',
|
||
requisitionDepartment: row.requisition_department || '',
|
||
requisitionLabel: row.requisition_id
|
||
? `${(row.requisition_title || 'Untitled').trim() || 'Untitled'} - ${(row.requisition_department || '—').trim() || '—'}`
|
||
: null,
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Styled .xlsx download of the requisition list — GET /jobs/export
|
||
* (jobs.export). Same filters as list(); `status` takes the UI label.
|
||
* downloadFile triggers the browser save from the Content-Disposition name.
|
||
*/
|
||
export function exportXlsx({ search, department, status, employmentType } = {}) {
|
||
return downloadFile('/jobs/export', {
|
||
params: {
|
||
search: search || undefined,
|
||
department: department || undefined,
|
||
requisition_status: status ? (LABEL_TO_STATUS[status] ?? status) : undefined,
|
||
employment_type: employmentType || undefined,
|
||
},
|
||
})
|
||
}
|
||
|
||
export function update(jobPostId, body) {
|
||
return request('/jobs/update', {
|
||
method: 'PATCH',
|
||
params: { job_post_id: jobPostId },
|
||
body,
|
||
})
|
||
}
|
||
|
||
export function remove(jobPostId) {
|
||
return request('/jobs/delete', {
|
||
method: 'DELETE',
|
||
params: { job_post_id: jobPostId },
|
||
})
|
||
}
|
||
|
||
/** Attach or replace a job post's cover image — POST /job/image/upload (multipart). */
|
||
export function uploadImage(jobPostId, file) {
|
||
const fd = new FormData()
|
||
fd.append('job_post_id', jobPostId)
|
||
fd.append('file', file)
|
||
return request('/job/image/upload', { method: 'POST', body: fd })
|
||
}
|
||
|
||
/** Object URL of the cover image, or null when the post has none. Caller revokes. */
|
||
export function fetchImageUrl(jobPostId) {
|
||
return fetchBlobUrl('/job/image/fetch', { params: { job_post_id: jobPostId } })
|
||
}
|
||
|
||
/** `status` is the Jobs UI label (Open / Closed / On Hold) or a raw requisition_status. */
|
||
export function setStatus(jobPostId, status) {
|
||
const requisition_status = LABEL_TO_STATUS[status] ?? status
|
||
return request('/jobs/status', {
|
||
method: 'PATCH',
|
||
params: { job_post_id: jobPostId },
|
||
body: { requisition_status },
|
||
})
|
||
}
|
||
|
||
/** Status-change audit for one requisition — GET /jobs/status-history/fetch. */
|
||
export function listStatusHistory(jobPostId) {
|
||
return request('/jobs/status-history/fetch', { params: { job_post_id: jobPostId } })
|
||
}
|