Files
edr-platform/apps/edr-hr-web/src/features/recruitment/OpeningDetailPage.tsx
2026-08-25 00:11:39 +03:00

441 lines
14 KiB
TypeScript

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<ApplicationStage, ApplicationStage[]> = {
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<ApplicationStage | null>(null);
const [addingApplicant, setAddingApplicant] = useState(false);
const [openApplication, setOpenApplication] = useState<string | null>(null);
const [rejecting, setRejecting] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState("");
const [error, setError] = useState<string | null>(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 <Text>Loading</Text>;
const data = opening.data;
const items = applications.data?.items ?? [];
return (
<>
<Button
variant="subtle"
size="compact-sm"
leftSection={<IconArrowLeft size={14} />}
mb="sm"
onClick={() => navigate("/recruitment")}
>
All vacancies
</Button>
<PageHeader
title={localized(data.title, i18n.language) || data.reference}
description={`${data.reference} · ${data.filled} of ${data.openings} filled`}
actions={
data.status === "OPEN" ? (
<Can permission={HR_PERMS.recruitment.viewApplication}>
<Button
leftSection={<IconUserPlus size={16} />}
onClick={() => setAddingApplicant(true)}
>
Add applicant
</Button>
</Can>
) : undefined
}
/>
<Group mb="md" gap="xs">
<OpeningBadge status={data.status} />
{data.closesOn && (
<Badge variant="light" color="gray" size="sm">
closes {data.closesOn}
</Badge>
)}
{data.status !== "OPEN" && (
<Text size="xs" c="dimmed">
Applications are only accepted while a vacancy is open.
</Text>
)}
</Group>
{error && (
<Alert
color="red"
mb="md"
withCloseButton
onClose={() => setError(null)}
icon={<IconAlertTriangle size={18} />}
>
{error}
</Alert>
)}
<PipelineBar
counts={pipeline.data ?? {}}
activeStage={stageFilter}
onSelect={setStageFilter}
/>
<Title order={5} mt="lg" mb="sm">
{stageFilter ? `${stageFilter.toLowerCase()} candidates` : "All candidates"}
</Title>
<Card withBorder radius="md" p={0}>
<Table.ScrollContainer minWidth={820}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Candidate</Table.Th>
<Table.Th>Applied</Table.Th>
<Table.Th>Stage</Table.Th>
<Table.Th>Score</Table.Th>
<Table.Th>Next</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((application) => (
<Table.Tr key={application.id}>
<Table.Td
style={{ cursor: "pointer" }}
onClick={() => setOpenApplication(application.id)}
>
<Text size="sm" fw={500}>
{application.applicant
? localized(application.applicant.fullName, i18n.language)
: "—"}
</Text>
<Text size="xs" c="dimmed">
{application.applicant?.phoneNumber}
{application.applicant?.educationLevel &&
` · ${application.applicant.educationLevel}`}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{application.appliedOn}</Text>
</Table.Td>
<Table.Td>
<StageBadge stage={application.stage} />
{application.rejectionReason && (
<Text size="xs" c="dimmed" mt={2}>
{application.rejectionReason}
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm">
{application.screeningScore
? Number(application.screeningScore)
: "—"}
</Text>
</Table.Td>
<Table.Td>
{/* Only the moves the server would actually accept. */}
<Group gap={4}>
{(NEXT[application.stage] ?? []).map((stage) => (
<Can
key={stage}
permission={HR_PERMS.recruitment.screenApplication}
>
<Button
size="compact-xs"
variant={stage === "REJECTED" ? "subtle" : "light"}
color={stage === "REJECTED" ? "red" : undefined}
loading={advance.isPending}
onClick={() =>
stage === "REJECTED"
? setRejecting(application.id)
: advance.mutate({ id: application.id, stage })
}
>
{stage.toLowerCase()}
</Button>
</Can>
))}
</Group>
</Table.Td>
</Table.Tr>
))}
{!applications.isLoading && items.length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="lg">
No candidates {stageFilter ? "at this stage" : "yet"}.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
<AddApplicantModal
opened={addingApplicant}
onClose={() => setAddingApplicant(false)}
jobOpeningId={id!}
onDone={invalidate}
/>
<ApplicationDrawer
applicationId={openApplication}
onClose={() => setOpenApplication(null)}
openingPositionId={data.positionId ?? null}
/>
<Modal
opened={Boolean(rejecting)}
onClose={() => setRejecting(null)}
title="Reject this candidate"
centered
>
<Stack>
<Text size="sm">
The reason is required. It is the record of why, and candidates ask.
</Text>
<Textarea
autosize
minRows={2}
value={rejectReason}
onChange={(event) => setRejectReason(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={() => setRejecting(null)}>
Cancel
</Button>
<Button
color="red"
disabled={!rejectReason.trim()}
loading={advance.isPending}
onClick={() =>
rejecting &&
advance.mutate({
id: rejecting,
stage: "REJECTED",
reason: rejectReason,
})
}
>
Reject
</Button>
</Group>
</Stack>
</Modal>
</>
);
}
function AddApplicantModal({
opened,
onClose,
jobOpeningId,
onDone,
}: {
opened: boolean;
onClose: () => void;
jobOpeningId: string;
onDone: () => void;
}) {
const [fullName, setFullName] = useState({ am: "", en: "" });
const [phoneNumber, setPhoneNumber] = useState("");
const [email, setEmail] = useState("");
const [educationLevel, setEducationLevel] = useState("");
const [yearsExperience, setYearsExperience] = useState<number | "">(0);
const [source, setSource] = useState("DIRECT");
const submit = useMutation({
mutationFn: async () => {
// Two calls on purpose: the applicant may already exist from an earlier
// vacancy, and the API matches on phone number rather than duplicating.
const applicant = await upsertApplicant({
fullName,
phoneNumber,
email: email || undefined,
educationLevel: educationLevel || undefined,
yearsExperience: yearsExperience ? String(yearsExperience) : undefined,
source,
});
return apply({ jobOpeningId, applicantId: applicant.id });
},
onSuccess: () => {
onDone();
setFullName({ am: "", en: "" });
setPhoneNumber("");
setEmail("");
onClose();
},
});
return (
<Modal opened={opened} onClose={onClose} title="Add a candidate" centered size="lg">
<Stack>
{submit.isError && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(submit.error)}
</Alert>
)}
<Alert color="blue" variant="light">
No login is created. An applicant becomes an IAM account only if they
are hired.
</Alert>
<BilingualTextInput
label="Full name"
required
value={fullName}
onChange={setFullName}
/>
<Group grow>
<TextInput
label="Phone"
required
placeholder="+251911000000"
description="How applicants are matched — a returning candidate is not duplicated."
value={phoneNumber}
onChange={(event) => setPhoneNumber(event.currentTarget.value)}
/>
<TextInput
label="Email"
type="email"
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
</Group>
<Group grow>
<TextInput
label="Education"
placeholder="BSc"
value={educationLevel}
onChange={(event) => setEducationLevel(event.currentTarget.value)}
/>
<NumberInput
label="Years of experience"
min={0}
max={60}
value={yearsExperience}
onChange={(value) => setYearsExperience(value === "" ? "" : Number(value))}
/>
<Select
label="Source"
allowDeselect={false}
value={source}
onChange={(value) => setSource(value ?? "DIRECT")}
data={[
"DIRECT",
"REFERRAL",
"AGENCY",
"WEBSITE",
"NEWSPAPER",
"INTERNAL",
"OTHER",
].map((value) => ({ value, label: value.toLowerCase() }))}
/>
</Group>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={submit.isPending}
disabled={!fullName.en || !phoneNumber}
onClick={() => submit.mutate()}
>
Add and apply
</Button>
</Group>
</Stack>
</Modal>
);
}