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

288 lines
9.4 KiB
TypeScript

import {
Alert,
Badge,
Button,
Card,
Group,
Modal,
NumberInput,
Select,
Stack,
Table,
Text,
TextInput,
} from "@mantine/core";
import { IconAlertTriangle, IconPlus } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { useNavigate } 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 { EthiopianDateInput } from "@/shared/components/EthiopianDateInput";
import { localized } from "@/shared/lib/localizedName";
import { createOpening, listOpenings, publishOpening } from "./api";
import { OpeningBadge } from "./components/StageBadge";
export function OpeningsPage() {
const queryClient = useQueryClient();
const navigate = useNavigate();
const { i18n } = useTranslation();
const [creating, setCreating] = useState(false);
const [error, setError] = useState<string | null>(null);
const openings = useQuery({
queryKey: ["job-openings"],
queryFn: () => listOpenings({ limit: 50 }),
});
const publish = useMutation({
mutationFn: (payload: { id: string; closesOn?: string }) =>
publishOpening(payload.id, payload.closesOn),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["job-openings"] }),
onError: (err) => setError(apiErrorMessage(err)),
});
const items = openings.data?.items ?? [];
return (
<>
<PageHeader
title="Recruitment"
description="Vacancies and where each one has got to"
actions={
<Can permission={HR_PERMS.recruitment.manageJobOpening}>
<Button leftSection={<IconPlus size={16} />} onClick={() => setCreating(true)}>
New vacancy
</Button>
</Can>
}
/>
{error && (
<Alert
color="red"
mb="md"
withCloseButton
onClose={() => setError(null)}
icon={<IconAlertTriangle size={18} />}
>
{error}
</Alert>
)}
<Card withBorder radius="md" p={0}>
<Table.ScrollContainer minWidth={880}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Vacancy</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Seats</Table.Th>
<Table.Th>Salary range</Table.Th>
<Table.Th>Closes</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{items.map((opening) => (
<Table.Tr
key={opening.id}
style={{ cursor: "pointer" }}
onClick={() => navigate(`/recruitment/${opening.id}`)}
>
<Table.Td>
<Text size="sm" fw={500}>
{localized(opening.title, i18n.language) || opening.reference}
</Text>
<Group gap={4} mt={2}>
<Badge size="xs" variant="default">
{opening.reference}
</Badge>
<Badge size="xs" variant="light" color="gray">
{opening.visibility.toLowerCase()}
</Badge>
</Group>
</Table.Td>
<Table.Td>
<OpeningBadge status={opening.status} />
</Table.Td>
<Table.Td>
<Text size="sm">
{opening.filled} of {opening.openings}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{opening.salaryRangeMin && opening.salaryRangeMax
? `${Number(opening.salaryRangeMin).toLocaleString()}${Number(
opening.salaryRangeMax,
).toLocaleString()}`
: "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm" c="dimmed">
{opening.closesOn ?? "—"}
</Text>
</Table.Td>
<Table.Td onClick={(event) => event.stopPropagation()}>
{(opening.status === "DRAFT" || opening.status === "ON_HOLD") && (
<Can permission={HR_PERMS.recruitment.manageJobOpening}>
<Button
size="compact-xs"
loading={publish.isPending}
onClick={() =>
publish.mutate({
id: opening.id,
closesOn: opening.closesOn ?? undefined,
})
}
>
Publish
</Button>
</Can>
)}
</Table.Td>
</Table.Tr>
))}
{!openings.isLoading && items.length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text size="sm" c="dimmed" ta="center" py="lg">
No vacancies yet.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
<NewOpeningModal opened={creating} onClose={() => setCreating(false)} />
</>
);
}
function NewOpeningModal({
opened,
onClose,
}: {
opened: boolean;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [reference, setReference] = useState("");
const [title, setTitle] = useState({ am: "", en: "" });
const [positionId, setPositionId] = useState("");
const [openings, setOpenings] = useState(1);
const [visibility, setVisibility] = useState("INTERNAL");
const [closesOn, setClosesOn] = useState<string | undefined>();
const [salaryMin, setSalaryMin] = useState("");
const [salaryMax, setSalaryMax] = useState("");
const create = useMutation({
mutationFn: () =>
createOpening({
reference,
title,
positionId: positionId || undefined,
openings,
visibility,
closesOn,
salaryRangeMin: salaryMin || undefined,
salaryRangeMax: salaryMax || undefined,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["job-openings"] });
setReference("");
setTitle({ am: "", en: "" });
setPositionId("");
onClose();
},
});
return (
<Modal opened={opened} onClose={onClose} title="New vacancy" centered size="lg">
<Stack>
{create.isError && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(create.error)}
</Alert>
)}
<TextInput
label="Reference"
required
placeholder="VAC-2026-001"
value={reference}
onChange={(event) => setReference(event.currentTarget.value.toUpperCase())}
/>
<BilingualTextInput label="Title" required value={title} onChange={setTitle} />
<TextInput
label="IAM position id"
description="The post being filled. Optional now, but hiring needs one — the new employee has to have a place in the org chart."
value={positionId}
onChange={(event) => setPositionId(event.currentTarget.value)}
/>
<Group grow>
<NumberInput
label="Seats"
min={1}
max={999}
value={openings}
onChange={(value) => setOpenings(Number(value) || 1)}
/>
<Select
label="Visible to"
allowDeselect={false}
value={visibility}
onChange={(value) => setVisibility(value ?? "INTERNAL")}
data={[
{ value: "INTERNAL", label: "Internal only" },
{ value: "EXTERNAL", label: "External only" },
{ value: "BOTH", label: "Both" },
]}
/>
</Group>
<Group grow>
<TextInput
label="Salary from"
placeholder="8000.00"
value={salaryMin}
onChange={(event) => setSalaryMin(event.currentTarget.value)}
/>
<TextInput
label="to"
placeholder="14000.00"
value={salaryMax}
onChange={(event) => setSalaryMax(event.currentTarget.value)}
/>
</Group>
<EthiopianDateInput
label="Closes on"
description="Required before it can be published."
value={closesOn}
onChange={setClosesOn}
/>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={create.isPending}
disabled={!reference || !title.en}
onClick={() => create.mutate()}
>
Create
</Button>
</Group>
</Stack>
</Modal>
);
}