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

296 lines
11 KiB
TypeScript

import {
Alert,
Badge,
Button,
Card,
Table,
Tabs,
Text,
} from "@mantine/core";
import { IconAlertTriangle, IconSeeding } from "@tabler/icons-react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
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 { localized } from "@/shared/lib/localizedName";
import {
listComponents,
listStatutoryRates,
listStructures,
listTaxBrackets,
seedPayrollStatutory,
} from "./api";
import { money } from "./components/PayslipView";
export function PayrollSettingsPage() {
const queryClient = useQueryClient();
const { i18n } = useTranslation();
const components = useQuery({
queryKey: ["payroll-components"],
queryFn: listComponents,
});
const structures = useQuery({
queryKey: ["payroll-structures"],
queryFn: listStructures,
});
const brackets = useQuery({ queryKey: ["tax-brackets"], queryFn: listTaxBrackets });
const rates = useQuery({ queryKey: ["statutory-rates"], queryFn: listStatutoryRates });
const seed = useMutation({
mutationFn: seedPayrollStatutory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["payroll-components"] });
queryClient.invalidateQueries({ queryKey: ["tax-brackets"] });
queryClient.invalidateQueries({ queryKey: ["statutory-rates"] });
},
});
return (
<>
<PageHeader
title="Payroll settings"
description="Components, structures and the statutory tables"
actions={
<Can permission={HR_PERMS.payroll.manageIncomeTaxTable}>
<Button
variant="default"
leftSection={<IconSeeding size={16} />}
loading={seed.isPending}
onClick={() => seed.mutate()}
>
Seed statutory configuration
</Button>
</Can>
}
/>
{seed.isError && (
<Alert color="red" mb="md" icon={<IconAlertTriangle size={18} />}>
{apiErrorMessage(seed.error)}
</Alert>
)}
{seed.data && (
<Alert color="green" variant="light" mb="md">
Components: {seed.data.components.created.length || "none"} added. Tax
bands: {seed.data.taxBands}. Rates:{" "}
{seed.data.rates.created.join(", ") || "none added"}.
</Alert>
)}
<Tabs defaultValue="tax">
<Tabs.List mb="md">
<Tabs.Tab value="tax">Income tax</Tabs.Tab>
<Tabs.Tab value="components">Components</Tabs.Tab>
<Tabs.Tab value="structures">Structures</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="tax">
{/* The single most important caveat in the module. These bands decide
every employee's tax and this codebase cannot know whether a later
schedule has been enacted. */}
<Alert color="yellow" variant="light" mb="md" icon={<IconAlertTriangle size={18} />}>
<Text size="sm" fw={600}>
Verify these bands before running real payroll.
</Text>
<Text size="sm">
They are seeded from Income Tax Proclamation 979/2016 and decide
every employee's tax. The table is effective-dated, so a newer
schedule is added as rows with a later start date payslips for
earlier months keep resolving the bands that applied to them.
</Text>
</Alert>
<Card withBorder radius="md" p={0}>
<Table striped verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>From</Table.Th>
<Table.Th>To</Table.Th>
<Table.Th ta="right">Rate</Table.Th>
<Table.Th ta="right">Less deduction</Table.Th>
<Table.Th>In force from</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(brackets.data ?? []).map((band) => (
<Table.Tr key={band.id}>
<Table.Td>{money(band.lowerBound)}</Table.Td>
<Table.Td>
{band.upperBound ? money(band.upperBound) : "and above"}
</Table.Td>
<Table.Td ta="right">
{(Number(band.rate) * 100).toFixed(0)}%
</Table.Td>
<Table.Td ta="right">{money(band.deduction)}</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{band.effectiveFrom} · {band.statuteReference ?? "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
{(brackets.data ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={5}>
<Text size="sm" c="dimmed" ta="center" py="lg">
No bands configured payroll cannot be calculated until
there are.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Card>
<Text size="sm" fw={600} mt="lg" mb="xs">
Statutory rates
</Text>
<Card withBorder radius="md" p={0}>
<Table striped verticalSpacing="sm">
<Table.Tbody>
{(rates.data ?? []).map((rate) => (
<Table.Tr key={rate.id}>
<Table.Td>
<Text size="sm">
{localized(rate.name, i18n.language) || rate.code}
</Text>
</Table.Td>
<Table.Td ta="right">
<Text size="sm" fw={600}>
{(Number(rate.rate) * 100).toFixed(0)}%
</Text>
</Table.Td>
<Table.Td>
<Text size="xs" c="dimmed">
{rate.statuteReference ?? "—"}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
<Tabs.Panel value="components">
<Card withBorder radius="md" p={0}>
<Table.ScrollContainer minWidth={860}>
<Table striped verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Component</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>How it is computed</Table.Th>
<Table.Th>Taxable</Table.Th>
<Table.Th>Pensionable</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(components.data ?? []).map((component) => (
<Table.Tr key={component.id}>
<Table.Td>
<Text size="sm" fw={500}>
{localized(component.name, i18n.language) || component.code}
</Text>
<Text size="xs" c="dimmed">
{component.code}
{component.statuteReference && ` · ${component.statuteReference}`}
</Text>
</Table.Td>
<Table.Td>
<Badge
size="sm"
variant="light"
color={
component.componentType === "DEDUCTION"
? "red"
: component.componentType === "EMPLOYER_CONTRIBUTION"
? "gray"
: "green"
}
>
{component.componentType.replace("_", " ").toLowerCase()}
</Badge>
</Table.Td>
<Table.Td>
<Text size="xs">
{component.calculation === "STATUTORY"
? "from the statutory table"
: component.calculation.replace(/_/g, " ").toLowerCase()}
</Text>
{component.taxExemptAmount && (
<Text size="xs" c="dimmed">
exempt to the lower of {money(component.taxExemptAmount)} and{" "}
{component.taxExemptRateOfBasic
? `${(Number(component.taxExemptRateOfBasic) * 100).toFixed(0)}% of basic`
: "—"}
</Text>
)}
</Table.Td>
<Table.Td>
<Text size="sm">{component.isTaxable ? "yes" : "no"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{component.isPensionable ? "yes" : "no"}</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
</Card>
</Tabs.Panel>
<Tabs.Panel value="structures">
<Card withBorder radius="md" p={0}>
<Table striped verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Structure</Table.Th>
<Table.Th>Lines</Table.Th>
<Table.Th>Active</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{(structures.data ?? []).map((structure) => (
<Table.Tr key={structure.id}>
<Table.Td>
<Text size="sm" fw={500}>
{localized(structure.name, i18n.language) || structure.code}
</Text>
<Text size="xs" c="dimmed">
{structure.code}
</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{structure.lines?.length ?? "—"}</Text>
</Table.Td>
<Table.Td>
<Text size="sm">{structure.isActive ? "yes" : "no"}</Text>
</Table.Td>
</Table.Tr>
))}
{(structures.data ?? []).length === 0 && (
<Table.Tr>
<Table.Td colSpan={3}>
<Text size="sm" c="dimmed" ta="center" py="lg">
No structures yet. Without one an employee is paid basic
plus the statutory lines only.
</Text>
</Table.Td>
</Table.Tr>
)}
</Table.Tbody>
</Table>
</Card>
</Tabs.Panel>
</Tabs>
</>
);
}