diff --git a/__tests__/duration-field.test.tsx b/__tests__/duration-field.test.tsx
new file mode 100644
index 0000000..bc97dbe
--- /dev/null
+++ b/__tests__/duration-field.test.tsx
@@ -0,0 +1,63 @@
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it, vi } from "vitest";
+import { DurationField } from "@/components/molecules/duration-field";
+
+describe("DurationField", () => {
+ it("shows the minutes as a padded duration", () => {
+ render();
+
+ expect(screen.getByLabelText("Jornada Diária")).toHaveValue("08:48");
+ });
+
+ it("commits the typed duration as minutes", async () => {
+ const onMinutesChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const field = screen.getByLabelText("Jornada Diária");
+ await user.clear(field);
+ await user.type(field, "0730");
+
+ expect(onMinutesChange).toHaveBeenLastCalledWith(450);
+ });
+
+ it("keeps impossible durations out of the calculation", async () => {
+ const onMinutesChange = vi.fn();
+ const user = userEvent.setup();
+ render();
+
+ const field = screen.getByLabelText("Jornada Diária");
+ await user.clear(field);
+ await user.type(field, "2599");
+
+ expect(onMinutesChange).not.toHaveBeenCalled();
+ });
+
+ it("describes the field with the hint when one is given", () => {
+ render(
+ ,
+ );
+
+ expect(screen.getByLabelText("Jornada Diária")).toHaveAccessibleDescription("Ex.: 08:48");
+ });
+
+ it("renders both icons and merges the className", () => {
+ const { container } = render(
+ }
+ labelIcon={}
+ minutes={480}
+ onMinutesChange={vi.fn()}
+ />,
+ );
+
+ expect(screen.getByTestId("input-icon")).toBeInTheDocument();
+ expect(screen.getByTestId("label-icon")).toBeInTheDocument();
+ expect(container.firstElementChild?.className).toContain("my-custom");
+ expect(screen.getByLabelText("Jornada Diária")).not.toHaveAttribute("aria-describedby");
+ });
+});
diff --git a/__tests__/salary-calculator.test.tsx b/__tests__/salary-calculator.test.tsx
index 330be19..7f0fc04 100644
--- a/__tests__/salary-calculator.test.tsx
+++ b/__tests__/salary-calculator.test.tsx
@@ -84,11 +84,11 @@ describe("SalaryCalculator", () => {
render();
await user.click(screen.getByRole("radio", { name: "Dia" }));
- expect(screen.getByText(/163,58/)).toBeInTheDocument();
+ expect(screen.getByText(/179,94/)).toBeInTheDocument();
- const dailyHours = screen.getByLabelText("Jornada Diária (horas)");
- await user.clear(dailyHours);
- await user.type(dailyHours, "6");
+ const dailyJourney = screen.getByLabelText("Jornada Diária");
+ await user.clear(dailyJourney);
+ await user.type(dailyJourney, "0600");
expect(screen.getByText(/122,69/)).toBeInTheDocument();
});
diff --git a/__tests__/use-salary-calculator.test.ts b/__tests__/use-salary-calculator.test.ts
index 8f3f902..03347fd 100644
--- a/__tests__/use-salary-calculator.test.ts
+++ b/__tests__/use-salary-calculator.test.ts
@@ -203,6 +203,20 @@ describe("useSalaryCalculator", () => {
expect(result.current.autoIrrf).toBeGreaterThan(withAutoInss);
});
+ it("keeps the daily journey in minutes and scales the daily value by it", () => {
+ const { result } = renderHook(() => useSalaryCalculator());
+
+ expect(result.current.dailyMinutes).toBe(528);
+
+ act(() => {
+ result.current.setPeriod("day");
+ result.current.setDailyMinutes(450);
+ });
+
+ expect(localStorage.getItem("dailyMinutes")).toBe("450");
+ expect(result.current.stats.periodValue).toBeCloseTo(result.current.stats.hourlyRate * 7.5, 6);
+ });
+
it("avoids dividing by zero when the monthly hours are cleared", () => {
const { result } = renderHook(() => useSalaryCalculator());
diff --git a/components/molecules/duration-field.tsx b/components/molecules/duration-field.tsx
new file mode 100644
index 0000000..9f03d53
--- /dev/null
+++ b/components/molecules/duration-field.tsx
@@ -0,0 +1,52 @@
+import type { ReactNode } from "react";
+import { DURATION_GROUP_SIZES, formatPaddedDuration, isRealDuration, parsePaddedDuration } from "@/lib/duration";
+import { cn } from "@/lib/utils";
+import { Label } from "../atoms/label";
+import { MaskedInput } from "../atoms/masked-input";
+
+interface DurationFieldProps {
+ id: string;
+ label: string;
+ hint?: string;
+ icon?: ReactNode;
+ labelIcon?: ReactNode;
+ minutes: number;
+ onMinutesChange: (minutes: number) => void;
+ className?: string;
+}
+
+export function DurationField({
+ id,
+ label,
+ hint,
+ icon,
+ labelIcon,
+ minutes,
+ onMinutesChange,
+ className,
+}: DurationFieldProps) {
+ return (
+
+
+
onMinutesChange(parsePaddedDuration(duration))}
+ />
+ {hint ? (
+
+ {hint}
+
+ ) : null}
+
+ );
+}
diff --git a/components/organisms/salary-calculator.tsx b/components/organisms/salary-calculator.tsx
index 06908cc..776ba8d 100644
--- a/components/organisms/salary-calculator.tsx
+++ b/components/organisms/salary-calculator.tsx
@@ -7,6 +7,7 @@ import { SALARY_PERIOD_LABELS } from "@/lib/salary-period";
import { formatCurrency, parseCurrency } from "@/lib/utils";
import { CollapsiblePanel } from "../molecules/collapsible-panel";
import { CurrencyField } from "../molecules/currency-field";
+import { DurationField } from "../molecules/duration-field";
import { FormField } from "../molecules/form-field";
import { HeroPanel } from "../molecules/hero-panel";
import { PeriodSelector } from "../molecules/period-selector";
@@ -23,8 +24,8 @@ export function SalaryCalculator() {
setGrossSalary,
monthlyHours,
setMonthlyHours,
- dailyHours,
- setDailyHours,
+ dailyMinutes,
+ setDailyMinutes,
dependents,
setDependents,
regime,
@@ -81,15 +82,13 @@ export function SalaryCalculator() {
value={monthlyHours || ""}
onChange={(event) => setMonthlyHours(Number(event.target.value))}
/>
- }
- placeholder="8"
- value={dailyHours || ""}
- onChange={(event) => setDailyHours(Number(event.target.value))}
+ minutes={dailyMinutes}
+ onMinutesChange={setDailyMinutes}
/>
diff --git a/hooks/use-salary-calculator.ts b/hooks/use-salary-calculator.ts
index 4ff6d07..f725741 100644
--- a/hooks/use-salary-calculator.ts
+++ b/hooks/use-salary-calculator.ts
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
+import { minutesToHours } from "@/lib/duration";
import {
calculateIncomeTax,
calculateSocialSecurity,
@@ -19,14 +20,14 @@ export type ExtraKind = "gain" | "deduction";
const DEFAULT_GROSS_SALARY = 5000;
const DEFAULT_MONTHLY_HOURS = 220;
-const DEFAULT_DAILY_HOURS = 8;
+const DEFAULT_DAILY_MINUTES = 8 * 60 + 48;
const DEFAULT_REGIME: WorkRegime = "clt";
const DEFAULT_PERIOD: SalaryPeriod = "hour";
const STORAGE_KEYS = {
grossSalary: "grossSalary",
monthlyHours: "monthlyHours",
- dailyHours: "dailyHours",
+ dailyMinutes: "dailyMinutes",
dependents: "dependents",
regime: "workRegime",
period: "salaryPeriod",
@@ -57,7 +58,7 @@ function sumValues(items: readonly ExtraItem[]): number {
export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initialHours = DEFAULT_MONTHLY_HOURS) {
const [grossSalary, setGrossSalary] = useState(initialSalary);
const [monthlyHours, setMonthlyHours] = useState(initialHours);
- const [dailyHours, setDailyHours] = useState(DEFAULT_DAILY_HOURS);
+ const [dailyMinutes, setDailyMinutes] = useState(DEFAULT_DAILY_MINUTES);
const [dependents, setDependents] = useState(0);
const [regime, setRegime] = useState(DEFAULT_REGIME);
const [period, setPeriod] = useState(DEFAULT_PERIOD);
@@ -70,7 +71,7 @@ export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initia
useEffect(() => {
setGrossSalary(readStoredNumber(STORAGE_KEYS.grossSalary, initialSalary));
setMonthlyHours(readStoredNumber(STORAGE_KEYS.monthlyHours, initialHours));
- setDailyHours(readStoredNumber(STORAGE_KEYS.dailyHours, DEFAULT_DAILY_HOURS));
+ setDailyMinutes(readStoredNumber(STORAGE_KEYS.dailyMinutes, DEFAULT_DAILY_MINUTES));
setDependents(readStoredNumber(STORAGE_KEYS.dependents, 0));
setRegime(readStoredOption(STORAGE_KEYS.regime, WORK_REGIMES, DEFAULT_REGIME));
setPeriod(readStoredOption(STORAGE_KEYS.period, SALARY_PERIODS, DEFAULT_PERIOD));
@@ -84,13 +85,13 @@ export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initia
localStorage.setItem(STORAGE_KEYS.grossSalary, grossSalary.toString());
localStorage.setItem(STORAGE_KEYS.monthlyHours, monthlyHours.toString());
- localStorage.setItem(STORAGE_KEYS.dailyHours, dailyHours.toString());
+ localStorage.setItem(STORAGE_KEYS.dailyMinutes, dailyMinutes.toString());
localStorage.setItem(STORAGE_KEYS.dependents, dependents.toString());
localStorage.setItem(STORAGE_KEYS.regime, regime);
localStorage.setItem(STORAGE_KEYS.period, period);
localStorage.setItem(STORAGE_KEYS.extraDeductions, JSON.stringify(extraDeductions));
localStorage.setItem(STORAGE_KEYS.extraGains, JSON.stringify(extraGains));
- }, [isRestored, grossSalary, monthlyHours, dailyHours, dependents, regime, period, extraDeductions, extraGains]);
+ }, [isRestored, grossSalary, monthlyHours, dailyMinutes, dependents, regime, period, extraDeductions, extraGains]);
const autoInss = useMemo(() => calculateSocialSecurity(grossSalary, regime), [grossSalary, regime]);
const autoIrrf = useMemo(
@@ -119,12 +120,12 @@ export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initia
totalValue,
hourlyRate,
minuteRate: hourlyRate / 60,
- periodValue: amountForPeriod(totalValue, period, monthlyHours, dailyHours),
+ periodValue: amountForPeriod(totalValue, period, monthlyHours, minutesToHours(dailyMinutes)),
};
}, [
grossSalary,
monthlyHours,
- dailyHours,
+ dailyMinutes,
period,
manualInss,
autoInss,
@@ -168,8 +169,8 @@ export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initia
setGrossSalary,
monthlyHours,
setMonthlyHours,
- dailyHours,
- setDailyHours,
+ dailyMinutes,
+ setDailyMinutes,
dependents,
setDependents,
regime,