mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
528 lines
17 KiB
TypeScript
528 lines
17 KiB
TypeScript
import {
|
||
Alert,
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Group,
|
||
Modal,
|
||
NumberInput,
|
||
Select,
|
||
SimpleGrid,
|
||
Stack,
|
||
Table,
|
||
Text,
|
||
TextInput,
|
||
} from "@mantine/core";
|
||
import { IconAlertTriangle, IconPlayerPlay, IconPlus } 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 { PageHeader } from "@/shared/components/PageHeader";
|
||
import { BilingualTextInput } from "@/shared/components/BilingualTextInput";
|
||
import { EthiopianDateInput } from "@/shared/components/EthiopianDateInput";
|
||
import { localized } from "@/shared/lib/localizedName";
|
||
import {
|
||
closeCycle,
|
||
createCycle,
|
||
createTemplate,
|
||
getCycleProgress,
|
||
listCycles,
|
||
listTemplates,
|
||
openCycle,
|
||
} from "./api";
|
||
import { CycleBadge } from "./components/AppraisalStatusBadge";
|
||
|
||
export function AppraisalCyclesPage() {
|
||
const queryClient = useQueryClient();
|
||
const { i18n } = useTranslation();
|
||
const [creatingCycle, setCreatingCycle] = useState(false);
|
||
const [creatingTemplate, setCreatingTemplate] = useState(false);
|
||
const [expanded, setExpanded] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [opened, setOpened] = useState<{ created: number; skipped: number } | null>(
|
||
null,
|
||
);
|
||
|
||
const cycles = useQuery({ queryKey: ["appraisal-cycles"], queryFn: listCycles });
|
||
const templates = useQuery({
|
||
queryKey: ["appraisal-templates"],
|
||
queryFn: listTemplates,
|
||
});
|
||
const progress = useQuery({
|
||
queryKey: ["cycle-progress", expanded],
|
||
queryFn: () => getCycleProgress(expanded!),
|
||
enabled: Boolean(expanded),
|
||
});
|
||
|
||
const invalidate = () => {
|
||
queryClient.invalidateQueries({ queryKey: ["appraisal-cycles"] });
|
||
queryClient.invalidateQueries({ queryKey: ["cycle-progress"] });
|
||
};
|
||
|
||
// Two mutations rather than one branching on an action: they return different
|
||
// shapes, and a union return type buys nothing here but casts.
|
||
const open = useMutation({
|
||
mutationFn: (id: string) => openCycle(id),
|
||
onSuccess: (result) => {
|
||
invalidate();
|
||
setOpened({ created: result.created, skipped: result.skipped });
|
||
},
|
||
onError: (err) => setError(apiErrorMessage(err)),
|
||
});
|
||
|
||
const close = useMutation({
|
||
mutationFn: (id: string) => closeCycle(id),
|
||
onSuccess: invalidate,
|
||
onError: (err) => setError(apiErrorMessage(err)),
|
||
});
|
||
|
||
return (
|
||
<>
|
||
<PageHeader
|
||
title="Appraisal cycles"
|
||
description="Forms, cycles and how far each has got"
|
||
actions={
|
||
<Group>
|
||
<Can permission={HR_PERMS.appraisal.manageTemplate}>
|
||
<Button variant="default" onClick={() => setCreatingTemplate(true)}>
|
||
New form
|
||
</Button>
|
||
</Can>
|
||
<Can permission={HR_PERMS.appraisal.manageCycle}>
|
||
<Button
|
||
leftSection={<IconPlus size={16} />}
|
||
disabled={(templates.data ?? []).length === 0}
|
||
onClick={() => setCreatingCycle(true)}
|
||
>
|
||
New cycle
|
||
</Button>
|
||
</Can>
|
||
</Group>
|
||
}
|
||
/>
|
||
|
||
{error && (
|
||
<Alert
|
||
color="red"
|
||
mb="md"
|
||
withCloseButton
|
||
onClose={() => setError(null)}
|
||
icon={<IconAlertTriangle size={18} />}
|
||
>
|
||
{error}
|
||
</Alert>
|
||
)}
|
||
|
||
{opened && (
|
||
<Alert color="green" variant="light" mb="md" withCloseButton onClose={() => setOpened(null)}>
|
||
{opened.created} appraisal(s) created
|
||
{opened.skipped > 0 && `, ${opened.skipped} already existed and were left alone`}.
|
||
</Alert>
|
||
)}
|
||
|
||
{(templates.data ?? []).length === 0 && (
|
||
<Alert color="blue" variant="light" mb="md">
|
||
No appraisal form yet. A cycle needs one — it defines what is scored and
|
||
how much each criterion is worth.
|
||
</Alert>
|
||
)}
|
||
|
||
<Stack>
|
||
{(cycles.data ?? []).map((cycle) => (
|
||
<Card key={cycle.id} withBorder radius="md" padding="md">
|
||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||
<Stack gap={4}>
|
||
<Group gap="xs">
|
||
<Text fw={600}>
|
||
{localized(cycle.name, i18n.language) || cycle.code}
|
||
</Text>
|
||
<CycleBadge status={cycle.status} />
|
||
</Group>
|
||
<Text size="xs" c="dimmed">
|
||
{cycle.periodStart} — {cycle.periodEnd}
|
||
{cycle.selfDueOn && ` · self due ${cycle.selfDueOn}`}
|
||
{cycle.managerDueOn && ` · manager due ${cycle.managerDueOn}`}
|
||
</Text>
|
||
</Stack>
|
||
<Group gap="xs">
|
||
{(cycle.status === "DRAFT" || cycle.status === "OPEN") && (
|
||
<Can permission={HR_PERMS.appraisal.manageCycle}>
|
||
<Button
|
||
size="compact-sm"
|
||
leftSection={<IconPlayerPlay size={14} />}
|
||
loading={open.isPending}
|
||
onClick={() => open.mutate(cycle.id)}
|
||
>
|
||
{cycle.status === "DRAFT" ? "Open" : "Add missing"}
|
||
</Button>
|
||
</Can>
|
||
)}
|
||
{cycle.status === "OPEN" && (
|
||
<Can permission={HR_PERMS.appraisal.manageCycle}>
|
||
<Button
|
||
size="compact-sm"
|
||
variant="default"
|
||
loading={close.isPending}
|
||
onClick={() => close.mutate(cycle.id)}
|
||
>
|
||
Close
|
||
</Button>
|
||
</Can>
|
||
)}
|
||
<Button
|
||
size="compact-sm"
|
||
variant="subtle"
|
||
onClick={() =>
|
||
setExpanded(expanded === cycle.id ? null : cycle.id)
|
||
}
|
||
>
|
||
{expanded === cycle.id ? "Hide" : "Progress"}
|
||
</Button>
|
||
</Group>
|
||
</Group>
|
||
|
||
{expanded === cycle.id && progress.data && (
|
||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md" mt="md">
|
||
{Object.entries(progress.data).map(([status, count]) => (
|
||
<div key={status}>
|
||
<Text size="xl" fw={700}>
|
||
{count}
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{status.toLowerCase().replace(/_/g, " ")}
|
||
</Text>
|
||
</div>
|
||
))}
|
||
{Object.keys(progress.data).length === 0 && (
|
||
<Text size="sm" c="dimmed">
|
||
No appraisals in this cycle yet.
|
||
</Text>
|
||
)}
|
||
</SimpleGrid>
|
||
)}
|
||
</Card>
|
||
))}
|
||
</Stack>
|
||
|
||
<Card withBorder radius="md" p={0} mt="lg">
|
||
<Table striped verticalSpacing="sm">
|
||
<Table.Thead>
|
||
<Table.Tr>
|
||
<Table.Th>Form</Table.Th>
|
||
<Table.Th>Criteria</Table.Th>
|
||
<Table.Th>Scale</Table.Th>
|
||
<Table.Th>Bands</Table.Th>
|
||
</Table.Tr>
|
||
</Table.Thead>
|
||
<Table.Tbody>
|
||
{(templates.data ?? []).map((template) => (
|
||
<Table.Tr key={template.id}>
|
||
<Table.Td>
|
||
<Text size="sm" fw={500}>
|
||
{localized(template.name, i18n.language) || template.code}
|
||
</Text>
|
||
<Text size="xs" c="dimmed">
|
||
{template.code}
|
||
</Text>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Group gap={4}>
|
||
{(template.criteria ?? []).map((criterion) => (
|
||
<Badge key={criterion.id} size="xs" variant="light">
|
||
{criterion.code} {Number(criterion.weight)}%
|
||
</Badge>
|
||
))}
|
||
</Group>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Text size="sm">0 – {Number(template.maxScore)}</Text>
|
||
</Table.Td>
|
||
<Table.Td>
|
||
<Group gap={4}>
|
||
{(template.ratingBands ?? []).map((band) => (
|
||
<Badge key={band.min} size="xs" variant="default">
|
||
{band.min}+ {localized(band.label, i18n.language)}
|
||
</Badge>
|
||
))}
|
||
</Group>
|
||
</Table.Td>
|
||
</Table.Tr>
|
||
))}
|
||
</Table.Tbody>
|
||
</Table>
|
||
</Card>
|
||
|
||
<NewCycleModal
|
||
opened={creatingCycle}
|
||
onClose={() => setCreatingCycle(false)}
|
||
templates={templates.data ?? []}
|
||
/>
|
||
<NewTemplateModal
|
||
opened={creatingTemplate}
|
||
onClose={() => setCreatingTemplate(false)}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function NewCycleModal({
|
||
opened,
|
||
onClose,
|
||
templates,
|
||
}: {
|
||
opened: boolean;
|
||
onClose: () => void;
|
||
templates: import("./types").AppraisalTemplate[];
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const { i18n } = useTranslation();
|
||
const [code, setCode] = useState("");
|
||
const [name, setName] = useState({ am: "", en: "" });
|
||
const [templateId, setTemplateId] = useState<string | null>(null);
|
||
const [periodStart, setPeriodStart] = useState<string | undefined>();
|
||
const [periodEnd, setPeriodEnd] = useState<string | undefined>();
|
||
const [selfDueOn, setSelfDueOn] = useState<string | undefined>();
|
||
const [managerDueOn, setManagerDueOn] = useState<string | undefined>();
|
||
|
||
const create = useMutation({
|
||
mutationFn: () =>
|
||
createCycle({
|
||
code,
|
||
name,
|
||
templateId,
|
||
periodStart,
|
||
periodEnd,
|
||
selfDueOn,
|
||
managerDueOn,
|
||
}),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["appraisal-cycles"] });
|
||
setCode("");
|
||
setName({ am: "", en: "" });
|
||
onClose();
|
||
},
|
||
});
|
||
|
||
return (
|
||
<Modal opened={opened} onClose={onClose} title="New appraisal cycle" centered>
|
||
<Stack>
|
||
{create.isError && (
|
||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||
{apiErrorMessage(create.error)}
|
||
</Alert>
|
||
)}
|
||
<TextInput
|
||
label="Code"
|
||
required
|
||
placeholder="FY2026"
|
||
value={code}
|
||
onChange={(event) => setCode(event.currentTarget.value.toUpperCase())}
|
||
/>
|
||
<BilingualTextInput label="Name" required value={name} onChange={setName} />
|
||
<Select
|
||
label="Form"
|
||
required
|
||
value={templateId}
|
||
onChange={setTemplateId}
|
||
data={templates.map((template) => ({
|
||
value: template.id,
|
||
label: localized(template.name, i18n.language) || template.code,
|
||
}))}
|
||
/>
|
||
<Group grow>
|
||
<EthiopianDateInput
|
||
label="Period starts"
|
||
required
|
||
value={periodStart}
|
||
onChange={setPeriodStart}
|
||
/>
|
||
<EthiopianDateInput
|
||
label="Period ends"
|
||
required
|
||
value={periodEnd}
|
||
onChange={setPeriodEnd}
|
||
/>
|
||
</Group>
|
||
<Group grow>
|
||
<EthiopianDateInput
|
||
label="Self assessment due"
|
||
value={selfDueOn}
|
||
onChange={setSelfDueOn}
|
||
/>
|
||
<EthiopianDateInput
|
||
label="Manager due"
|
||
value={managerDueOn}
|
||
onChange={setManagerDueOn}
|
||
/>
|
||
</Group>
|
||
<Group justify="flex-end">
|
||
<Button variant="default" onClick={onClose}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
loading={create.isPending}
|
||
disabled={!code || !name.en || !templateId || !periodStart || !periodEnd}
|
||
onClick={() => create.mutate()}
|
||
>
|
||
Create
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
);
|
||
}
|
||
|
||
interface DraftCriterion {
|
||
code: string;
|
||
en: string;
|
||
am: string;
|
||
weight: number;
|
||
}
|
||
|
||
function NewTemplateModal({
|
||
opened,
|
||
onClose,
|
||
}: {
|
||
opened: boolean;
|
||
onClose: () => void;
|
||
}) {
|
||
const queryClient = useQueryClient();
|
||
const [code, setCode] = useState("");
|
||
const [name, setName] = useState({ am: "", en: "" });
|
||
const [maxScore, setMaxScore] = useState(5);
|
||
const [criteria, setCriteria] = useState<DraftCriterion[]>([
|
||
{ code: "RESULTS", en: "Results delivered", am: "ውጤት", weight: 40 },
|
||
{ code: "QUALITY", en: "Quality of work", am: "ጥራት", weight: 30 },
|
||
{ code: "TEAMWORK", en: "Teamwork", am: "የቡድን ስራ", weight: 20 },
|
||
{ code: "CONDUCT", en: "Conduct", am: "ስነ ምግባር", weight: 10 },
|
||
]);
|
||
|
||
const total = criteria.reduce((sum, criterion) => sum + criterion.weight, 0);
|
||
|
||
const create = useMutation({
|
||
mutationFn: () =>
|
||
createTemplate({
|
||
code,
|
||
name,
|
||
maxScore: maxScore.toFixed(2),
|
||
ratingBands: [
|
||
{ min: 90, label: { am: "እጅግ በጣም ጥሩ", en: "Excellent" } },
|
||
{ min: 75, label: { am: "በጣም ጥሩ", en: "Very good" } },
|
||
{ min: 60, label: { am: "ጥሩ", en: "Good" } },
|
||
{ min: 50, label: { am: "አጥጋቢ", en: "Satisfactory" } },
|
||
{ min: 0, label: { am: "አጥጋቢ ያልሆነ", en: "Unsatisfactory" } },
|
||
],
|
||
criteria: criteria.map((criterion) => ({
|
||
code: criterion.code,
|
||
name: { am: criterion.am, en: criterion.en },
|
||
weight: criterion.weight.toFixed(2),
|
||
})),
|
||
}),
|
||
onSuccess: () => {
|
||
queryClient.invalidateQueries({ queryKey: ["appraisal-templates"] });
|
||
setCode("");
|
||
setName({ am: "", en: "" });
|
||
onClose();
|
||
},
|
||
});
|
||
|
||
return (
|
||
<Modal opened={opened} onClose={onClose} title="New appraisal form" centered size="lg">
|
||
<Stack>
|
||
{create.isError && (
|
||
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
|
||
{apiErrorMessage(create.error)}
|
||
</Alert>
|
||
)}
|
||
<TextInput
|
||
label="Code"
|
||
required
|
||
placeholder="ANNUAL-2026"
|
||
value={code}
|
||
onChange={(event) => setCode(event.currentTarget.value.toUpperCase())}
|
||
/>
|
||
<BilingualTextInput label="Name" required value={name} onChange={setName} />
|
||
<NumberInput
|
||
label="Top of the scale"
|
||
description="Each criterion is scored from 0 to this."
|
||
min={1}
|
||
max={100}
|
||
value={maxScore}
|
||
onChange={(value) => setMaxScore(Number(value) || 5)}
|
||
/>
|
||
|
||
<Text size="sm" fw={500}>
|
||
Criteria
|
||
</Text>
|
||
{criteria.map((criterion, index) => (
|
||
<Group key={index} grow align="flex-end">
|
||
<TextInput
|
||
label={index === 0 ? "Code" : undefined}
|
||
value={criterion.code}
|
||
onChange={(event) =>
|
||
setCriteria((current) =>
|
||
current.map((item, i) =>
|
||
i === index
|
||
? { ...item, code: event.currentTarget.value.toUpperCase() }
|
||
: item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<TextInput
|
||
label={index === 0 ? "Name" : undefined}
|
||
value={criterion.en}
|
||
onChange={(event) =>
|
||
setCriteria((current) =>
|
||
current.map((item, i) =>
|
||
i === index ? { ...item, en: event.currentTarget.value } : item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
<NumberInput
|
||
label={index === 0 ? "Weight %" : undefined}
|
||
min={0}
|
||
max={100}
|
||
value={criterion.weight}
|
||
onChange={(value) =>
|
||
setCriteria((current) =>
|
||
current.map((item, i) =>
|
||
i === index ? { ...item, weight: Number(value) || 0 } : item,
|
||
),
|
||
)
|
||
}
|
||
/>
|
||
</Group>
|
||
))}
|
||
|
||
{/* The server enforces this too, but a form that can only be rejected is
|
||
worse than one that says so while you are filling it in. */}
|
||
<Alert color={total === 100 ? "green" : "orange"} variant="light">
|
||
Weights total {total}%.{" "}
|
||
{total === 100
|
||
? "Good — the score reads directly as a percentage."
|
||
: "They must total 100, or scores cannot be compared between employees."}
|
||
</Alert>
|
||
|
||
<Group justify="flex-end">
|
||
<Button variant="default" onClick={onClose}>
|
||
Cancel
|
||
</Button>
|
||
<Button
|
||
loading={create.isPending}
|
||
disabled={!code || !name.en || total !== 100}
|
||
onClick={() => create.mutate()}
|
||
>
|
||
Create form
|
||
</Button>
|
||
</Group>
|
||
</Stack>
|
||
</Modal>
|
||
);
|
||
}
|