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(null); const [error, setError] = useState(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 ( {!data ? null : ( {error && ( setError(null)} icon={} > {error} )} applied {data.appliedOn} {(data.interviews ?? []).length === 0 ? ( None scheduled. ) : ( {(data.interviews ?? []).map((interview) => ( } title={`Round ${interview.round} — ${interview.interviewType.toLowerCase()}`} > {new Date(interview.scheduledAt).toLocaleString()} {interview.location && ` · ${interview.location}`} {interview.status === "COMPLETED" ? `${interview.recommendation?.toLowerCase()}${ interview.score ? ` · ${Number(interview.score)}` : "" }` : interview.status.toLowerCase()} {interview.status === "SCHEDULED" && ( )} {interview.feedback && ( “{interview.feedback}” )} ))} )} {(data.stage === "SHORTLISTED" || data.stage === "INTERVIEW") && ( )} {(offers.data ?? []).length === 0 && ( No offer made. )} {(offers.data ?? []).map((offer) => ( {Number(offer.offeredBasicSalary).toLocaleString()} basic starts {offer.proposedStartDate} {offer.probationEndDate && ` · probation to ${offer.probationEndDate}`} {offer.expiresOn && ` · expires ${offer.expiresOn}`} {offer.declineReason && ( declined: {offer.declineReason} )} {offer.hiredEmployeeId && ( hired — employee {offer.hiredEmployeeId.slice(0, 8)} )} {offer.status === "DRAFT" && ( )} {offer.status === "SENT" && ( )} {offer.status === "ACCEPTED" && !offer.hiredEmployeeId && ( )} ))} {!liveOffer && !acceptedOffer && (data.stage === "SHORTLISTED" || data.stage === "INTERVIEW") && ( )} )} setScheduling(false)} applicationId={applicationId} onDone={invalidate} /> setOutcomeFor(null)} onDone={invalidate} /> setOffering(false)} applicationId={applicationId} onDone={invalidate} /> setHiring(false)} offerId={acceptedOffer?.id ?? null} defaultEmail={data?.applicant?.email ?? ""} openingPositionId={openingPositionId} onDone={invalidate} /> ); } function Detail({ label, value }: { label: string; value?: string | null }) { return (
{label} {value || "—"}
); } function ScheduleModal({ opened, onClose, applicationId, onDone, }: { opened: boolean; onClose: () => void; applicationId: string | null; onDone: () => void; }) { const [date, setDate] = useState(); 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 ( {submit.isError && ( }> {apiErrorMessage(submit.error)} )} setTime(event.currentTarget.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." /> setScore(value === "" ? "" : Number(value))} />