mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
711 lines
22 KiB
TypeScript
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>
|
|
);
|
|
}
|