mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 18:48:11 +00:00
288 lines
9.4 KiB
TypeScript
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>
|
|
);
|
|
}
|