mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 07:10:57 +00:00
384 lines
13 KiB
TypeScript
384 lines
13 KiB
TypeScript
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>
|
||
);
|
||
}
|