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

528 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}