import { Alert, Badge, Button, Card, Group, Modal, NumberInput, Select, Stack, Table, Text, TextInput, Textarea, Title, } from "@mantine/core"; import { IconAlertTriangle, IconArrowLeft, IconUserPlus } from "@tabler/icons-react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { useTranslation } from "react-i18next"; import { Can } from "@/auth/Can"; import { HR_PERMS } from "@/auth/permissions"; import { apiErrorMessage } from "@/auth/http"; import { PageHeader } from "@/shared/components/PageHeader"; import { BilingualTextInput } from "@/shared/components/BilingualTextInput"; import { localized } from "@/shared/lib/localizedName"; import { apply, getOpening, getPipeline, listApplications, moveStage, upsertApplicant, } from "./api"; import { PipelineBar } from "./components/PipelineBar"; import { OpeningBadge, StageBadge } from "./components/StageBadge"; import { ApplicationDrawer } from "./components/ApplicationDrawer"; import type { ApplicationStage } from "./types"; /** What each stage may become — mirrors the server's state machine exactly. */ const NEXT: Record = { APPLIED: ["SCREENING", "REJECTED"], SCREENING: ["SHORTLISTED", "REJECTED"], SHORTLISTED: ["INTERVIEW", "OFFER", "REJECTED"], INTERVIEW: ["OFFER", "REJECTED"], OFFER: ["REJECTED"], HIRED: [], REJECTED: [], WITHDRAWN: [], }; export function OpeningDetailPage() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const queryClient = useQueryClient(); const { i18n } = useTranslation(); const [stageFilter, setStageFilter] = useState(null); const [addingApplicant, setAddingApplicant] = useState(false); const [openApplication, setOpenApplication] = useState(null); const [rejecting, setRejecting] = useState(null); const [rejectReason, setRejectReason] = useState(""); const [error, setError] = useState(null); const opening = useQuery({ queryKey: ["job-opening", id], queryFn: () => getOpening(id!), enabled: Boolean(id), }); const pipeline = useQuery({ queryKey: ["pipeline", id], queryFn: () => getPipeline(id!), enabled: Boolean(id), }); const applications = useQuery({ queryKey: ["applications", id, stageFilter], queryFn: () => listApplications({ jobOpeningId: id!, stage: stageFilter ?? undefined, limit: 100, }), enabled: Boolean(id), }); const invalidate = () => { queryClient.invalidateQueries({ queryKey: ["applications"] }); queryClient.invalidateQueries({ queryKey: ["pipeline"] }); queryClient.invalidateQueries({ queryKey: ["job-opening"] }); }; const advance = useMutation({ mutationFn: (payload: { id: string; stage: ApplicationStage; reason?: string; }) => moveStage(payload.id, { stage: payload.stage, reason: payload.reason }), onSuccess: () => { invalidate(); setRejecting(null); setRejectReason(""); }, onError: (err) => setError(apiErrorMessage(err)), }); if (!opening.data) return Loading…; const data = opening.data; const items = applications.data?.items ?? []; return ( <> ) : undefined } /> {data.closesOn && ( closes {data.closesOn} )} {data.status !== "OPEN" && ( Applications are only accepted while a vacancy is open. )} {error && ( setError(null)} icon={} > {error} )} {stageFilter ? `${stageFilter.toLowerCase()} candidates` : "All candidates"} Candidate Applied Stage Score Next {items.map((application) => ( setOpenApplication(application.id)} > {application.applicant ? localized(application.applicant.fullName, i18n.language) : "—"} {application.applicant?.phoneNumber} {application.applicant?.educationLevel && ` · ${application.applicant.educationLevel}`} {application.appliedOn} {application.rejectionReason && ( {application.rejectionReason} )} {application.screeningScore ? Number(application.screeningScore) : "—"} {/* Only the moves the server would actually accept. */} {(NEXT[application.stage] ?? []).map((stage) => ( ))} ))} {!applications.isLoading && items.length === 0 && ( No candidates {stageFilter ? "at this stage" : "yet"}. )}
setAddingApplicant(false)} jobOpeningId={id!} onDone={invalidate} /> setOpenApplication(null)} openingPositionId={data.positionId ?? null} /> setRejecting(null)} title="Reject this candidate" centered > The reason is required. It is the record of why, and candidates ask.