mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-08 06:35:42 +00:00
Muluhabt ERP modules
This commit is contained in:
383
apps/edr-hr-web/src/features/attendance/WorkSchedulesPage.tsx
Normal file
383
apps/edr-hr-web/src/features/attendance/WorkSchedulesPage.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user