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

711 lines
22 KiB
TypeScript

import {
Alert,
Badge,
Button,
Card,
Divider,
Drawer,
Group,
Modal,
NumberInput,
Select,
Stack,
Text,
TextInput,
Textarea,
Timeline,
} from "@mantine/core";
import {
IconAlertTriangle,
IconCalendarEvent,
IconFileCheck,
IconUserCheck,
} from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Can } from "@/auth/Can";
import { HR_PERMS } from "@/auth/permissions";
import { apiErrorMessage } from "@/auth/http";
import { EthiopianDateInput } from "@/shared/components/EthiopianDateInput";
import { localized } from "@/shared/lib/localizedName";
import {
createOffer,
getApplication,
hireFromOffer,
listOffers,
recordInterviewOutcome,
respondToOffer,
scheduleInterview,
sendOffer,
} from "../api";
import { OfferBadge, StageBadge } from "./StageBadge";
/**
* Everything about one candidate: their details, their interviews, and the
* offer — including turning an accepted one into an actual employee.
*
* A drawer rather than a page because recruiters work down a list; sending them
* to a separate route and back for each candidate loses their place.
*/
export function ApplicationDrawer({
applicationId,
onClose,
openingPositionId,
}: {
applicationId: string | null;
onClose: () => void;
openingPositionId: string | null;
}) {
const queryClient = useQueryClient();
const { i18n } = useTranslation();
const [scheduling, setScheduling] = useState(false);
const [offering, setOffering] = useState(false);
const [hiring, setHiring] = useState(false);
const [outcomeFor, setOutcomeFor] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const application = useQuery({
queryKey: ["application", applicationId],
queryFn: () => getApplication(applicationId!),
enabled: Boolean(applicationId),
});
const offers = useQuery({
queryKey: ["offers", applicationId],
queryFn: () => listOffers(applicationId!),
enabled: Boolean(applicationId),
});
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ["application"] });
queryClient.invalidateQueries({ queryKey: ["offers"] });
queryClient.invalidateQueries({ queryKey: ["applications"] });
queryClient.invalidateQueries({ queryKey: ["pipeline"] });
queryClient.invalidateQueries({ queryKey: ["job-opening"] });
};
const act = useMutation({
mutationFn: async (payload: { kind: string; id: string; accepted?: boolean; reason?: string }) => {
if (payload.kind === "send") return sendOffer(payload.id);
return respondToOffer(payload.id, payload.accepted!, payload.reason);
},
onSuccess: invalidate,
onError: (err) => setError(apiErrorMessage(err)),
});
const data = application.data;
const liveOffer = (offers.data ?? []).find(
(offer) => offer.status === "DRAFT" || offer.status === "SENT",
);
const acceptedOffer = (offers.data ?? []).find(
(offer) => offer.status === "ACCEPTED",
);
return (
<Drawer
opened={Boolean(applicationId)}
onClose={onClose}
position="right"
size="xl"
title={
data?.applicant
? localized(data.applicant.fullName, i18n.language)
: "Candidate"
}
>
{!data ? null : (
<Stack>
{error && (
<Alert
color="red"
withCloseButton
onClose={() => setError(null)}
icon={<IconAlertTriangle size={18} />}
>
{error}
</Alert>
)}
<Group gap="xs">
<StageBadge stage={data.stage} />
<Text size="xs" c="dimmed">
applied {data.appliedOn}
</Text>
</Group>
<Card withBorder radius="md" padding="sm">
<Group gap="xl">
<Detail label="phone" value={data.applicant?.phoneNumber} />
<Detail label="email" value={data.applicant?.email} />
<Detail label="education" value={data.applicant?.educationLevel} />
<Detail
label="experience"
value={
data.applicant?.yearsExperience
? `${Number(data.applicant.yearsExperience)} years`
: undefined
}
/>
<Detail label="source" value={data.applicant?.source?.toLowerCase()} />
</Group>
</Card>
<Divider label="Interviews" labelPosition="left" />
{(data.interviews ?? []).length === 0 ? (
<Text size="sm" c="dimmed">
None scheduled.
</Text>
) : (
<Timeline bulletSize={20} lineWidth={2}>
{(data.interviews ?? []).map((interview) => (
<Timeline.Item
key={interview.id}
bullet={<IconCalendarEvent size={12} />}
title={`Round ${interview.round}${interview.interviewType.toLowerCase()}`}
>
<Text size="xs" c="dimmed">
{new Date(interview.scheduledAt).toLocaleString()}
{interview.location && ` · ${interview.location}`}
</Text>
<Group gap="xs" mt={4}>
<Badge
size="xs"
variant="light"
color={
interview.status === "COMPLETED"
? interview.recommendation === "ADVANCE"
? "green"
: interview.recommendation === "REJECT"
? "red"
: "yellow"
: "gray"
}
>
{interview.status === "COMPLETED"
? `${interview.recommendation?.toLowerCase()}${
interview.score ? ` · ${Number(interview.score)}` : ""
}`
: interview.status.toLowerCase()}
</Badge>
{interview.status === "SCHEDULED" && (
<Can permission={HR_PERMS.recruitment.scheduleInterview}>
<Button
size="compact-xs"
variant="subtle"
onClick={() => setOutcomeFor(interview.id)}
>
Record outcome
</Button>
</Can>
)}
</Group>
{interview.feedback && (
<Text size="xs" mt={4}>
{interview.feedback}
</Text>
)}
</Timeline.Item>
))}
</Timeline>
)}
{(data.stage === "SHORTLISTED" || data.stage === "INTERVIEW") && (
<Can permission={HR_PERMS.recruitment.scheduleInterview}>
<Button
variant="default"
leftSection={<IconCalendarEvent size={16} />}
onClick={() => setScheduling(true)}
>
Schedule an interview
</Button>
</Can>
)}
<Divider label="Offer" labelPosition="left" />
{(offers.data ?? []).length === 0 && (
<Text size="sm" c="dimmed">
No offer made.
</Text>
)}
{(offers.data ?? []).map((offer) => (
<Card key={offer.id} withBorder radius="md" padding="sm">
<Group justify="space-between" align="flex-start">
<Stack gap={2}>
<Group gap="xs">
<Text size="sm" fw={600}>
{Number(offer.offeredBasicSalary).toLocaleString()} basic
</Text>
<OfferBadge status={offer.status} />
</Group>
<Text size="xs" c="dimmed">
starts {offer.proposedStartDate}
{offer.probationEndDate &&
` · probation to ${offer.probationEndDate}`}
{offer.expiresOn && ` · expires ${offer.expiresOn}`}
</Text>
{offer.declineReason && (
<Text size="xs" c="red">
declined: {offer.declineReason}
</Text>
)}
{offer.hiredEmployeeId && (
<Text size="xs" c="green">
hired employee {offer.hiredEmployeeId.slice(0, 8)}
</Text>
)}
</Stack>
<Group gap="xs">
{offer.status === "DRAFT" && (
<Can permission={HR_PERMS.recruitment.makeOffer}>
<Button
size="compact-sm"
loading={act.isPending}
onClick={() => act.mutate({ kind: "send", id: offer.id })}
>
Send
</Button>
</Can>
)}
{offer.status === "SENT" && (
<Can permission={HR_PERMS.recruitment.makeOffer}>
<Button
size="compact-sm"
color="green"
loading={act.isPending}
onClick={() =>
act.mutate({ kind: "respond", id: offer.id, accepted: true })
}
>
Accepted
</Button>
<Button
size="compact-sm"
variant="subtle"
color="red"
onClick={() => {
const reason = window.prompt(
"Why did they decline? (required)",
);
if (reason?.trim()) {
act.mutate({
kind: "respond",
id: offer.id,
accepted: false,
reason,
});
}
}}
>
Declined
</Button>
</Can>
)}
{offer.status === "ACCEPTED" && !offer.hiredEmployeeId && (
<Can permission={HR_PERMS.recruitment.hire}>
<Button
size="compact-sm"
leftSection={<IconUserCheck size={14} />}
onClick={() => setHiring(true)}
>
Hire
</Button>
</Can>
)}
</Group>
</Group>
</Card>
))}
{!liveOffer &&
!acceptedOffer &&
(data.stage === "SHORTLISTED" || data.stage === "INTERVIEW") && (
<Can permission={HR_PERMS.recruitment.makeOffer}>
<Button
leftSection={<IconFileCheck size={16} />}
onClick={() => setOffering(true)}
>
Make an offer
</Button>
</Can>
)}
</Stack>
)}
<ScheduleModal
opened={scheduling}
onClose={() => setScheduling(false)}
applicationId={applicationId}
onDone={invalidate}
/>
<OutcomeModal
interviewId={outcomeFor}
onClose={() => setOutcomeFor(null)}
onDone={invalidate}
/>
<OfferModal
opened={offering}
onClose={() => setOffering(false)}
applicationId={applicationId}
onDone={invalidate}
/>
<HireModal
opened={hiring}
onClose={() => setHiring(false)}
offerId={acceptedOffer?.id ?? null}
defaultEmail={data?.applicant?.email ?? ""}
openingPositionId={openingPositionId}
onDone={invalidate}
/>
</Drawer>
);
}
function Detail({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<Text size="xs" c="dimmed">
{label}
</Text>
<Text size="sm">{value || "—"}</Text>
</div>
);
}
function ScheduleModal({
opened,
onClose,
applicationId,
onDone,
}: {
opened: boolean;
onClose: () => void;
applicationId: string | null;
onDone: () => void;
}) {
const [date, setDate] = useState<string | undefined>();
const [time, setTime] = useState("09:00");
const [interviewType, setInterviewType] = useState("PANEL");
const [location, setLocation] = useState("");
const submit = useMutation({
mutationFn: () =>
scheduleInterview({
applicationId: applicationId!,
scheduledAt: new Date(`${date}T${time}:00`).toISOString(),
interviewType,
location: location || undefined,
}),
onSuccess: () => {
onDone();
onClose();
},
});
return (
<Modal opened={opened} onClose={onClose} title="Schedule an interview" centered>
<Stack>
{submit.isError && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(submit.error)}
</Alert>
)}
<EthiopianDateInput label="Date" required value={date} onChange={setDate} />
<Group grow>
<TextInput
label="Time"
required
type="time"
value={time}
onChange={(event) => setTime(event.currentTarget.value)}
/>
<Select
label="Type"
allowDeselect={false}
value={interviewType}
onChange={(value) => setInterviewType(value ?? "PANEL")}
data={["PHONE", "PANEL", "TECHNICAL", "WRITTEN", "PRACTICAL", "FINAL"].map(
(value) => ({ value, label: value.toLowerCase() }),
)}
/>
</Group>
<TextInput
label="Where"
value={location}
onChange={(event) => setLocation(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={submit.isPending} disabled={!date} onClick={() => submit.mutate()}>
Schedule
</Button>
</Group>
</Stack>
</Modal>
);
}
function OutcomeModal({
interviewId,
onClose,
onDone,
}: {
interviewId: string | null;
onClose: () => void;
onDone: () => void;
}) {
const [score, setScore] = useState<number | "">(70);
const [recommendation, setRecommendation] = useState("ADVANCE");
const [feedback, setFeedback] = useState("");
const submit = useMutation({
mutationFn: () =>
recordInterviewOutcome(interviewId!, {
score: score === "" ? undefined : Number(score),
recommendation,
feedback: feedback || undefined,
}),
onSuccess: () => {
onDone();
setFeedback("");
onClose();
},
});
return (
<Modal
opened={Boolean(interviewId)}
onClose={onClose}
title="How did it go?"
centered
>
<Stack>
{submit.isError && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(submit.error)}
</Alert>
)}
<Select
label="Recommendation"
required
allowDeselect={false}
value={recommendation}
onChange={(value) => setRecommendation(value ?? "ADVANCE")}
data={[
{ value: "ADVANCE", label: "Advance" },
{ value: "HOLD", label: "Hold" },
{ value: "REJECT", label: "Reject" },
]}
description="Required — a completed interview with no verdict helps nobody."
/>
<NumberInput
label="Score"
min={0}
max={100}
value={score}
onChange={(value) => setScore(value === "" ? "" : Number(value))}
/>
<Textarea
label="Feedback"
autosize
minRows={3}
value={feedback}
onChange={(event) => setFeedback(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button loading={submit.isPending} onClick={() => submit.mutate()}>
Record
</Button>
</Group>
</Stack>
</Modal>
);
}
function OfferModal({
opened,
onClose,
applicationId,
onDone,
}: {
opened: boolean;
onClose: () => void;
applicationId: string | null;
onDone: () => void;
}) {
const [salary, setSalary] = useState("");
const [startDate, setStartDate] = useState<string | undefined>();
const [probationEnd, setProbationEnd] = useState<string | undefined>();
const [expiresOn, setExpiresOn] = useState<string | undefined>();
const submit = useMutation({
mutationFn: () =>
createOffer({
applicationId: applicationId!,
offeredBasicSalary: salary,
proposedStartDate: startDate!,
probationEndDate: probationEnd,
expiresOn,
}),
onSuccess: () => {
onDone();
setSalary("");
onClose();
},
});
return (
<Modal opened={opened} onClose={onClose} title="Make an offer" centered>
<Stack>
{submit.isError && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(submit.error)}
</Alert>
)}
<Alert color="blue" variant="light">
This figure becomes their basic salary if they accept and are hired
payroll reads it directly.
</Alert>
<TextInput
label="Basic salary"
required
placeholder="12000.00"
value={salary}
onChange={(event) => setSalary(event.currentTarget.value)}
/>
<EthiopianDateInput
label="Proposed start"
required
value={startDate}
onChange={setStartDate}
/>
<EthiopianDateInput
label="Probation ends"
description="Setting this starts them on PROBATION rather than ACTIVE."
value={probationEnd}
onChange={setProbationEnd}
/>
<EthiopianDateInput
label="Offer expires"
value={expiresOn}
onChange={setExpiresOn}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={submit.isPending}
disabled={!salary || !startDate}
onClick={() => submit.mutate()}
>
Draft offer
</Button>
</Group>
</Stack>
</Modal>
);
}
function HireModal({
opened,
onClose,
offerId,
defaultEmail,
openingPositionId,
onDone,
}: {
opened: boolean;
onClose: () => void;
offerId: string | null;
defaultEmail: string;
openingPositionId: string | null;
onDone: () => void;
}) {
const [username, setUsername] = useState("");
const [email, setEmail] = useState(defaultEmail);
const [positionId, setPositionId] = useState(openingPositionId ?? "");
const [employeeNumber, setEmployeeNumber] = useState("");
const submit = useMutation({
mutationFn: () =>
hireFromOffer(offerId!, {
username,
email,
positionId: positionId || undefined,
employeeNumber: employeeNumber || undefined,
}),
onSuccess: () => {
onDone();
onClose();
},
});
return (
<Modal opened={opened} onClose={onClose} title="Hire this candidate" centered>
<Stack>
{submit.isError && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(submit.error)}
</Alert>
)}
<Alert color="blue" variant="light">
This creates their IAM account, employee record, position assignment,
HR profile and salary the same flow used to hire anyone else.
</Alert>
<TextInput
label="Username"
required
value={username}
onChange={(event) => setUsername(event.currentTarget.value)}
/>
<TextInput
label="Email"
required
type="email"
value={email}
onChange={(event) => setEmail(event.currentTarget.value)}
/>
<TextInput
label="IAM position id"
required={!openingPositionId}
description={
openingPositionId
? "Taken from the vacancy. Override only if they are joining a different post."
: "The vacancy has no position recorded, so one is needed here."
}
value={positionId}
onChange={(event) => setPositionId(event.currentTarget.value)}
/>
<TextInput
label="Employee number"
description="Generated if left blank."
value={employeeNumber}
onChange={(event) => setEmployeeNumber(event.currentTarget.value)}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={submit.isPending}
disabled={!username || !email || !positionId}
onClick={() => submit.mutate()}
>
Hire
</Button>
</Group>
</Stack>
</Modal>
);
}