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

384 lines
13 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,
Stack,
Table,
Tabs,
Text,
TextInput,
Tooltip,
} from "@mantine/core";
import { IconAlertTriangle, IconPlus, IconSeeding } 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 { localized } from "@/shared/lib/localizedName";
import {
createWorkSchedule,
listOvertimeRates,
listWorkSchedules,
seedOvertimeRates,
updateOvertimeRate,
} from "./api";
import { formatMinutes } from "./components/AttendanceStatusBadge";
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
export function WorkSchedulesPage() {
const queryClient = useQueryClient();
const { i18n } = useTranslation();
const [adding, setAdding] = useState(false);
const schedules = useQuery({
queryKey: ["work-schedules"],
queryFn: listWorkSchedules,
});
const rates = useQuery({ queryKey: ["overtime-rates"], queryFn: listOvertimeRates });
const seed = useMutation({
mutationFn: seedOvertimeRates,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["overtime-rates"] }),
});
const updateRate = useMutation({
mutationFn: (payload: { id: string; multiplier: string }) =>
updateOvertimeRate(payload.id, { multiplier: payload.multiplier }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ["overtime-rates"] }),
});
return (
<>
<PageHeader
title="Attendance settings"
description="Shift patterns and overtime premiums"
/>
<Tabs defaultValue="schedules">
<Tabs.List mb="md">
<Tabs.Tab value="schedules">Shift patterns</Tabs.Tab>
<Tabs.Tab value="overtime">Overtime premiums</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="schedules">
<Group justify="flex-end" mb="sm">
<Can permission={HR_PERMS.attendance.manageWorkSchedule}>
<Button leftSection={<IconPlus size={16} />} onClick={() => setAdding(true)}>
Add pattern
</Button>
</Can>
</Group>
<Card withBorder radius="md" p={0}>
<Table.ScrollContainer minWidth={820}>
<Table striped highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Pattern</Table.Th>
<Table.Th>Hours</Table.Th>
<Table.Th>Break</Table.Th>
<Table.Th>Grace</Table.Th>
<Table.Th>Days</Table.Th>
<Table.Th>Full day at</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(schedules.data ?? []).map((schedule) => (
<Table.Tr key={schedule.id}>
<Table.Td>
<Group gap={6}>
<Text size="sm" fw={500}>
{localized(schedule.name, i18n.language) || schedule.code}
</Text>
{schedule.isDefault && (
<Badge size="xs" variant="light">
default
</Badge>
)}
{schedule.crossesMidnight && (
<Badge size="xs" variant="light" color="grape">
night
</Badge>
)}
{!schedule.isActive && (
<Badge size="xs" variant="light" color="gray">
inactive
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{schedule.code}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">
{schedule.startTime.slice(0, 5)} {" "}
{schedule.endTime.slice(0, 5)}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{schedule.breakMinutes}m</Text>
</Table.Td>
<Table.Td>
<Tooltip label="Arrive within this and nothing is recorded">
<Text size="sm">{schedule.gracePeriodMinutes}m</Text>
</Tooltip>
</Table.Td>
<Table.Td>
{schedule.workingDays?.length ? (
<Group gap={2}>
{schedule.workingDays.map((day) => (
<Badge key={day} size="xs" variant="default">
{DAY_NAMES[day]}
</Badge>
))}
</Group>
) : (
<Text size="xs" c="dimmed">
company week
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{formatMinutes(schedule.minMinutesFullDay)}</Text>
<Text size="xs" c="dimmed">
half at {formatMinutes(schedule.minMinutesHalfDay)}
</Text>
</Table.Td>
</Table.Tr>
))}
{!schedules.isLoading && (schedules.data ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={6}>
<Text size="sm" c="dimmed" ta="center" py="lg">
No shift patterns yet. Without one, punches are recorded
but lateness cannot be judged.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
</Tabs.Panel>
<Tabs.Panel value="overtime">
<Group justify="flex-end" mb="sm">
<Can permission={HR_PERMS.attendance.approveOvertime}>
<Button
variant="default"
leftSection={<IconSeeding size={16} />}
loading={seed.isPending}
onClick={() => seed.mutate()}
>
Add statutory premiums
</Button>
</Can>
</Group>
<Alert color="blue" variant="light" mb="md">
Proclamation 1156/2019 Art. 68 sets these as a <strong>floor</strong>.
You may pay more; the API refuses less.
</Alert>
{updateRate.isError && (
<Alert color="red" mb="md" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(updateRate.error)}
</Alert>
)}
<Card withBorder radius="md" p={0}>
<Table striped verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>When</Table.Th>
<Table.Th>Multiplier</Table.Th>
<Table.Th>Cap per day</Table.Th>
<Table.Th>Basis</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(rates.data ?? []).map((rate) => (
<Table.Tr key={rate.id}>
<Table.Td>
<Text size="sm" tt="capitalize">
{rate.category.replace("_", " ").toLowerCase()}
</Text>
</Table.Td>
<Table.Td>
<Can permission={HR_PERMS.attendance.approveOvertime}>
<NumberInput
size="xs"
w={110}
min={1}
max={10}
step={0.25}
decimalScale={2}
suffix="×"
defaultValue={Number(rate.multiplier)}
onBlur={(event) => {
const value = event.currentTarget.value.replace("×", "");
if (Number(value) !== Number(rate.multiplier)) {
updateRate.mutate({
id: rate.id,
multiplier: Number(value).toFixed(2),
});
}
}}
/>
</Can>
</Table.Td>
<Table.Td>
<Text size="sm">
{rate.maxHoursPerDay ? `${Number(rate.maxHoursPerDay)}h` : "—"}
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{rate.statuteReference ?? "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
{(rates.data ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={4}>
<Text size="sm" c="dimmed" ta="center" py="lg">
No premiums configured overtime cannot be claimed until
there are. "Add statutory premiums" creates them.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
</Tabs>
<AddScheduleModal opened={adding} onClose={() => setAdding(false)} />
</>
);
}
function AddScheduleModal({
opened,
onClose,
}: {
opened: boolean;
onClose: () => void;
}) {
const queryClient = useQueryClient();
const [code, setCode] = useState("");
const [name, setName] = useState({ am: "", en: "" });
const [startTime, setStartTime] = useState("08:30");
const [endTime, setEndTime] = useState("17:30");
const [breakMinutes, setBreakMinutes] = useState(60);
const [grace, setGrace] = useState(10);
const create = useMutation({
mutationFn: () =>
createWorkSchedule({
code,
name,
startTime,
endTime,
breakMinutes,
gracePeriodMinutes: grace,
// A shift finishing before it starts must be a night shift; saying so
// here avoids a refusal the user cannot act on.
crossesMidnight: endTime <= startTime,
}),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["work-schedules"] });
setCode("");
setName({ am: "", en: "" });
onClose();
},
});
return (
<Modal opened={opened} onClose={onClose} title="Add a shift pattern" centered>
<Stack>
{create.isError && (
<Alert color="red" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(create.error)}
</Alert>
)}
<TextInput
label="Code"
required
placeholder="STANDARD"
value={code}
onChange={(event) => setCode(event.currentTarget.value.toUpperCase())}
/>
<BilingualTextInput label="Name" required value={name} onChange={setName} />
<Group grow>
<TextInput
label="Starts"
required
type="time"
value={startTime}
onChange={(event) => setStartTime(event.currentTarget.value)}
/>
<TextInput
label="Ends"
required
type="time"
value={endTime}
onChange={(event) => setEndTime(event.currentTarget.value)}
/>
</Group>
{endTime <= startTime && (
<Alert color="grape" variant="light">
This ends before it starts, so it will be treated as a night shift
running into the next day.
</Alert>
)}
<Group grow>
<NumberInput
label="Break"
suffix=" min"
min={0}
max={480}
value={breakMinutes}
onChange={(value) => setBreakMinutes(Number(value) || 0)}
/>
<NumberInput
label="Grace period"
suffix=" min"
min={0}
max={240}
description="Later than this counts as late"
value={grace}
onChange={(value) => setGrace(Number(value) || 0)}
/>
</Group>
<Group justify="flex-end">
<Button variant="default" onClick={onClose}>
Cancel
</Button>
<Button
loading={create.isPending}
disabled={!code || !name.en}
onClick={() => create.mutate()}
>
Add
</Button>
</Group>
</Stack>
</Modal>
);
}