diff --git a/README.md b/README.md
index 86cb861..c7e6a7a 100644
--- a/README.md
+++ b/README.md
@@ -20,11 +20,15 @@ This project was heavily driven and refactored from scratch to production level
---
## ⚡ Features
-- **Workday Tracking**: Precise control of entry, lunch, and exit times, with real-time calculation of overtime (75% and 100%) and night shift bonus.
-- **Salary Calculator**: Exact calculation of INSS, IRRF, deductions, and extra gains (compatible with 2026 CLT rules).
+- **Workday Tracking**: Precise control of entry, lunch, and exit times, with real-time overtime calculation and the reduced night hour (CLT art. 73). Overtime rates are configurable and default to the statutory floor of 50%/100%.
+- **Work Regimes**: CLT, Empregado Público and Estatutário. The first two contribute under the RGPS table with its ceiling; Estatutário follows the federal RPPS ladder (7.5% to 22%, uncapped).
+- **Salary Calculator**: INSS, IRRF, dependents, deductions and extra gains, using the tables in force for 2026 — including the R$ 607,20 simplified deduction and the reduction that exempts income up to R$ 5.000,00.
+- **Any Period**: View your pay by hour, day, week, month or year, derived from your monthly workload and daily journey.
- **Mobile-First & Premium UI**: An amazing interface developed from scratch to provide the best experience, with native support for Dark/Light Mode.
- **Offline First**: Automatically saves all user preferences in `localStorage`.
+> Tax tables are the ones in force for 2026: **Portaria Interministerial MPS/MF nº 13 de 09/01/2026** for social security, and the IRRF table as amended by **Lei 15.270/2025**.
+
---
## 🏗️ Architecture & Best Practices
diff --git a/__tests__/payroll.test.ts b/__tests__/payroll.test.ts
index 20cbaf3..c9055d7 100644
--- a/__tests__/payroll.test.ts
+++ b/__tests__/payroll.test.ts
@@ -46,6 +46,42 @@ describe("calculateSocialSecurity", () => {
expect(calculateSocialSecurity(-1000)).toBe(0);
expect(calculateSocialSecurity(Number.NaN)).toBe(0);
});
+
+ it("treats empregado público exactly like CLT", () => {
+ for (const salary of [1000, 5000, 8475.55, 30000]) {
+ expect(calculateSocialSecurity(salary, "empregado-publico")).toBe(
+ calculateSocialSecurity(salary, "clt"),
+ );
+ }
+ });
+
+ it("matches CLT for estatutário up to the general regime ceiling", () => {
+ for (const salary of [1000, 5000, 8475.55]) {
+ expect(calculateSocialSecurity(salary, "estatutario")).toBe(
+ calculateSocialSecurity(salary, "clt"),
+ );
+ }
+ });
+
+ it("keeps charging estatutário past the ceiling that caps CLT", () => {
+ expect(calculateSocialSecurity(20000, "clt")).toBe(988.09);
+ expect(calculateSocialSecurity(20000, "estatutario")).toBeGreaterThan(
+ 988.09,
+ );
+ });
+
+ it("applies every civil service tier at its own boundary", () => {
+ expect(calculateSocialSecurity(14514.3, "estatutario")).toBe(1863.71);
+ expect(calculateSocialSecurity(29028.57, "estatutario")).toBe(4258.56);
+ expect(calculateSocialSecurity(56605.73, "estatutario")).toBe(9498.23);
+ });
+
+ it("charges the top civil service rate above the last boundary", () => {
+ const atBoundary = calculateSocialSecurity(56605.73, "estatutario");
+ const above = calculateSocialSecurity(66605.73, "estatutario");
+
+ expect(above - atBoundary).toBeCloseTo(10000 * 0.22, 2);
+ });
});
describe("calculateIncomeTax", () => {
@@ -93,4 +129,47 @@ describe("calculateIncomeTax", () => {
expect(calculateIncomeTax(-5000, 0)).toBe(0);
expect(calculateIncomeTax(Number.NaN, Number.NaN)).toBe(0);
});
+
+ it("deducts each dependent from the taxable base", () => {
+ const contribution = calculateSocialSecurity(10000);
+
+ expect(calculateIncomeTax(10000, contribution, 0)).toBe(1569.55);
+ expect(calculateIncomeTax(10000, contribution, 2)).toBe(1465.27);
+ });
+
+ it("tips the deduction away from the simplified one at the first dependent", () => {
+ const contribution = calculateSocialSecurity(5500);
+ expect(contribution).toBeLessThan(607.2);
+
+ expect(calculateIncomeTax(5500, contribution, 0)).toBe(
+ calculateIncomeTax(5500, 0, 0),
+ );
+ expect(calculateIncomeTax(5500, contribution, 1)).toBeLessThan(
+ calculateIncomeTax(5500, contribution, 0),
+ );
+ });
+
+ it("ignores fractional and negative dependent counts", () => {
+ const contribution = calculateSocialSecurity(10000);
+
+ expect(calculateIncomeTax(10000, contribution, 2.9)).toBe(
+ calculateIncomeTax(10000, contribution, 2),
+ );
+ expect(calculateIncomeTax(10000, contribution, -3)).toBe(
+ calculateIncomeTax(10000, contribution, 0),
+ );
+ });
+
+ it("taxes estatutário less because the contribution is deductible", () => {
+ const cltTax = calculateIncomeTax(
+ 20000,
+ calculateSocialSecurity(20000, "clt"),
+ );
+ const civilServiceTax = calculateIncomeTax(
+ 20000,
+ calculateSocialSecurity(20000, "estatutario"),
+ );
+
+ expect(civilServiceTax).toBeLessThan(cltTax);
+ });
});
diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx
index 0ad69d3..0adba7b 100644
--- a/__tests__/salary-calculator.test.tsx
+++ b/__tests__/salary-calculator.test.tsx
@@ -20,10 +20,122 @@ describe("SalaryCalculator", () => {
).toBeInTheDocument();
expect(screen.getByLabelText("Salário Bruto (R$)")).toHaveValue("5.000,00");
expect(screen.getByLabelText("Carga Horária Mensal")).toHaveValue(220);
- expect(screen.getByText("Valor da Hora")).toBeInTheDocument();
+ expect(screen.getByText("Valor por Hora")).toBeInTheDocument();
expect(screen.getByText("Resumo Financeiro")).toBeInTheDocument();
});
+ it("defaults to the CLT regime and offers the public service ones", () => {
+ render();
+
+ const regime = screen.getByLabelText("Regime de Trabalho");
+ expect(regime).toHaveValue("clt");
+ expect(
+ screen.getByRole("option", { name: "Empregado Público" }),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("option", { name: "Estatutário" }),
+ ).toBeInTheDocument();
+ });
+
+ it("contributes the capped amount for CLT and empregado público alike", async () => {
+ const user = userEvent.setup();
+ localStorage.setItem("grossSalary", "20000");
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: /Impostos e Descontos/ }),
+ );
+ expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute(
+ "placeholder",
+ "988,09",
+ );
+
+ await user.selectOptions(
+ screen.getByLabelText("Regime de Trabalho"),
+ "empregado-publico",
+ );
+ expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute(
+ "placeholder",
+ "988,09",
+ );
+ });
+
+ it("keeps contributing past the ceiling for estatutário", async () => {
+ const user = userEvent.setup();
+ localStorage.setItem("grossSalary", "20000");
+ render();
+
+ await user.selectOptions(
+ screen.getByLabelText("Regime de Trabalho"),
+ "estatutario",
+ );
+ await user.click(
+ screen.getByRole("button", { name: /Impostos e Descontos/ }),
+ );
+
+ expect(screen.getByLabelText("INSS (R$)")).toHaveAttribute(
+ "placeholder",
+ "2.768,85",
+ );
+ });
+
+ it("deducts declared dependents from the income tax", async () => {
+ const user = userEvent.setup();
+ localStorage.setItem("grossSalary", "10000");
+ render();
+
+ await user.click(
+ screen.getByRole("button", { name: /Impostos e Descontos/ }),
+ );
+ expect(screen.getByLabelText("IRRF (R$)")).toHaveAttribute(
+ "placeholder",
+ "1.569,55",
+ );
+
+ await user.type(screen.getByLabelText("Dependentes"), "2");
+
+ expect(screen.getByLabelText("IRRF (R$)")).toHaveAttribute(
+ "placeholder",
+ "1.465,27",
+ );
+ });
+
+ it("switches the headline value between periods", async () => {
+ const user = userEvent.setup();
+ render();
+
+ expect(screen.getByText("Valor por Hora")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("radio", { name: "Mês" }));
+ expect(screen.getByText("Valor por Mês")).toBeInTheDocument();
+
+ await user.click(screen.getByRole("radio", { name: "Ano" }));
+ expect(screen.getByText("Valor por Ano")).toBeInTheDocument();
+ });
+
+ it("derives each period from the monthly net and the daily journey", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("radio", { name: "Dia" }));
+ expect(screen.getByText(/163,58/)).toBeInTheDocument();
+
+ const dailyHours = screen.getByLabelText("Jornada Diária (horas)");
+ await user.clear(dailyHours);
+ await user.type(dailyHours, "6");
+
+ expect(screen.getByText(/122,69/)).toBeInTheDocument();
+ });
+
+ it("counts thirteen paid months in the yearly view", async () => {
+ const user = userEvent.setup();
+ render();
+
+ await user.click(screen.getByRole("radio", { name: "Ano" }));
+
+ expect(screen.getByText(/58\.480,37/)).toBeInTheDocument();
+ });
+
it("summarises net salary, total received and total deductions", () => {
render();
diff --git a/__tests__/salary-period.test.ts b/__tests__/salary-period.test.ts
new file mode 100644
index 0000000..07117ea
--- /dev/null
+++ b/__tests__/salary-period.test.ts
@@ -0,0 +1,62 @@
+import { describe, expect, it } from "vitest";
+import {
+ amountForPeriod,
+ SALARY_PERIOD_LABELS,
+ SALARY_PERIODS,
+ type SalaryPeriod,
+} from "@/lib/salary-period";
+
+const MONTHLY_NET = 4498.49;
+const MONTHLY_HOURS = 220;
+const DAILY_HOURS = 8;
+
+const forPeriod = (period: SalaryPeriod) =>
+ amountForPeriod(MONTHLY_NET, period, MONTHLY_HOURS, DAILY_HOURS);
+
+describe("SALARY_PERIODS", () => {
+ it("lists every period from shortest to longest", () => {
+ expect(SALARY_PERIODS).toEqual(["hour", "day", "week", "month", "year"]);
+ });
+
+ it("labels every period", () => {
+ for (const period of SALARY_PERIODS) {
+ expect(SALARY_PERIOD_LABELS[period]).toBeTruthy();
+ }
+ });
+});
+
+describe("amountForPeriod", () => {
+ it("divides the monthly amount by the monthly hours", () => {
+ expect(forPeriod("hour")).toBeCloseTo(20.4477, 4);
+ });
+
+ it("scales the hourly amount by the daily hours", () => {
+ expect(forPeriod("day")).toBeCloseTo(forPeriod("hour") * 8, 6);
+ });
+
+ it("counts five working days in a week", () => {
+ expect(forPeriod("week")).toBeCloseTo(forPeriod("day") * 5, 6);
+ });
+
+ it("returns the monthly amount untouched", () => {
+ expect(forPeriod("month")).toBe(MONTHLY_NET);
+ });
+
+ it("counts thirteen paid months in a year", () => {
+ expect(forPeriod("year")).toBeCloseTo(MONTHLY_NET * 13, 6);
+ });
+
+ it("honours a shorter daily journey", () => {
+ expect(amountForPeriod(MONTHLY_NET, "day", MONTHLY_HOURS, 6)).toBeCloseTo(
+ forPeriod("hour") * 6,
+ 6,
+ );
+ });
+
+ it("avoids dividing by zero when the monthly hours are cleared", () => {
+ const hourly = amountForPeriod(MONTHLY_NET, "hour", 0, DAILY_HOURS);
+
+ expect(hourly).toBe(MONTHLY_NET);
+ expect(Number.isFinite(hourly)).toBe(true);
+ });
+});
diff --git a/__tests__/tax-details-panel.test.tsx b/__tests__/tax-details-panel.test.tsx
index a74cb2e..fd25bf2 100644
--- a/__tests__/tax-details-panel.test.tsx
+++ b/__tests__/tax-details-panel.test.tsx
@@ -9,6 +9,8 @@ vi.mock("@/lib/analytics", () => ({
}));
const baseProps = {
+ dependents: 0,
+ onDependentsChange: vi.fn(),
manualInss: null,
manualIrrf: null,
autoInss: 500,
diff --git a/components/molecules/period-selector.tsx b/components/molecules/period-selector.tsx
new file mode 100644
index 0000000..7e663cd
--- /dev/null
+++ b/components/molecules/period-selector.tsx
@@ -0,0 +1,34 @@
+import {
+ SALARY_PERIOD_LABELS,
+ SALARY_PERIODS,
+ type SalaryPeriod,
+} from "@/lib/salary-period";
+
+interface PeriodSelectorProps {
+ value: SalaryPeriod;
+ onChange: (period: SalaryPeriod) => void;
+}
+
+export function PeriodSelector({ value, onChange }: PeriodSelectorProps) {
+ return (
+
+ );
+}
diff --git a/components/molecules/select-field.tsx b/components/molecules/select-field.tsx
new file mode 100644
index 0000000..e216e0b
--- /dev/null
+++ b/components/molecules/select-field.tsx
@@ -0,0 +1,51 @@
+import type { ReactNode, SelectHTMLAttributes } from "react";
+import { cn } from "@/lib/utils";
+import { Label } from "../atoms/label";
+
+interface SelectOption {
+ value: TValue;
+ label: string;
+}
+
+interface SelectFieldProps
+ extends Omit, "onChange" | "value"> {
+ id: string;
+ label: string;
+ labelIcon?: ReactNode;
+ value: TValue;
+ options: readonly SelectOption[];
+ onValueChange: (value: TValue) => void;
+}
+
+export function SelectField({
+ id,
+ label,
+ labelIcon,
+ value,
+ options,
+ onValueChange,
+ className,
+ ...props
+}: SelectFieldProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/components/organisms/salary-calculator.tsx b/components/organisms/salary-calculator.tsx
index 584f6c2..153d535 100644
--- a/components/organisms/salary-calculator.tsx
+++ b/components/organisms/salary-calculator.tsx
@@ -1,16 +1,20 @@
"use client";
import {
+ Briefcase,
Calculator,
ChevronDown,
ChevronUp,
Clock,
+ Sun,
TrendingDown,
TrendingUp,
Wallet,
} from "lucide-react";
import { useState } from "react";
import { useSalaryCalculator } from "@/hooks/use-salary-calculator";
+import type { WorkRegime } from "@/lib/payroll";
+import { SALARY_PERIOD_LABELS } from "@/lib/salary-period";
import {
formatCurrency,
formatCurrencySimple,
@@ -19,18 +23,34 @@ import {
import { CollapsiblePanel } from "../molecules/collapsible-panel";
import { FormField } from "../molecules/form-field";
import { HeroPanel } from "../molecules/hero-panel";
+import { PeriodSelector } from "../molecules/period-selector";
+import { SelectField } from "../molecules/select-field";
import { StatBox } from "../molecules/stat-box";
import { CalculatorLayout } from "../templates/calculator-layout";
import { TaxDetailsPanel } from "./tax-details-panel";
const DETAILS_PANEL_ID = "tax-details";
+const REGIME_OPTIONS: readonly { value: WorkRegime; label: string }[] = [
+ { value: "clt", label: "CLT" },
+ { value: "empregado-publico", label: "Empregado Público" },
+ { value: "estatutario", label: "Estatutário" },
+];
+
export function SalaryCalculator() {
const {
grossSalary,
setGrossSalary,
monthlyHours,
setMonthlyHours,
+ dailyHours,
+ setDailyHours,
+ dependents,
+ setDependents,
+ regime,
+ setRegime,
+ period,
+ setPeriod,
manualInss,
setManualInss,
manualIrrf,
@@ -76,10 +96,19 @@ export function SalaryCalculator() {
setGrossSalary(parseCurrency(event.target.value))
}
/>
+ }
+ value={regime}
+ options={REGIME_OPTIONS}
+ onValueChange={setRegime}
+ />
}
placeholder="220"
value={monthlyHours || ""}
@@ -87,6 +116,16 @@ export function SalaryCalculator() {
setMonthlyHours(Number(event.target.value))
}
/>
+ }
+ placeholder="8"
+ value={dailyHours || ""}
+ onChange={(event) => setDailyHours(Number(event.target.value))}
+ />