Muluhabt ERP modules

This commit is contained in:
Mulu Mehari
2026-08-25 00:11:39 +03:00
parent 5c2100e76d
commit 70171fa9d8
441 changed files with 68587 additions and 214 deletions

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