mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-28 23:00:57 +00:00
296 lines
11 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|