mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-04 20:43:40 +00:00
441 lines
14 KiB
TypeScript
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>
|
|
);
|
|
}
|