Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions __tests__/duration-field.test.tsx
Original file line numberDiff line numberDiff line change
@@ -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(<DurationField id="journey" label="Jornada Diária" minutes={528} onMinutesChange={vi.fn()} />);

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(<DurationField id="journey" label="Jornada Diária" minutes={528} onMinutesChange={onMinutesChange} />);

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(<DurationField id="journey" label="Jornada Diária" minutes={528} onMinutesChange={onMinutesChange} />);

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(
<DurationField id="journey" label="Jornada Diária" hint="Ex.: 08:48" minutes={480} onMinutesChange={vi.fn()} />,
);

expect(screen.getByLabelText("Jornada Diária")).toHaveAccessibleDescription("Ex.: 08:48");
});

it("renders both icons and merges the className", () => {
const { container } = render(
<DurationField
id="journey"
label="Jornada Diária"
className="my-custom"
icon={<svg data-testid="input-icon" />}
labelIcon={<svg data-testid="label-icon" />}
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");
});
});
8 changes: 4 additions & 4 deletions __tests__/salary-calculator.test.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -84,11 +84,11 @@ describe("SalaryCalculator", () => {
render(<SalaryCalculator />);

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();
});
Expand Down
14 changes: 14 additions & 0 deletions __tests__/use-salary-calculator.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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());

Expand Down
52 changes: 52 additions & 0 deletions components/molecules/duration-field.tsx
Original file line numberDiff line numberDiff line change
@@ -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 (
<div className={cn("space-y-3", className)}>
<Label htmlFor={id}>
{labelIcon}
{label}
</Label>
<MaskedInput
id={id}
icon={icon}
placeholder="08:48"
aria-describedby={hint ? `${id}-hint` : undefined}
value={formatPaddedDuration(minutes)}
separator=":"
groupSizes={DURATION_GROUP_SIZES}
isValid={isRealDuration}
onCommit={(duration) => onMinutesChange(parsePaddedDuration(duration))}
/>
{hint ? (
<p id={`${id}-hint`} className="text-xs text-neutral-500 dark:text-neutral-400">
{hint}
</p>
) : null}
</div>
);
}
17 changes: 8 additions & 9 deletions components/organisms/salary-calculator.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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";
Expand All@@ -23,8 +24,8 @@ export function SalaryCalculator() {
setGrossSalary,
monthlyHours,
setMonthlyHours,
dailyHours,
setDailyHours,
dailyMinutes,
setDailyMinutes,
dependents,
setDependents,
regime,
Expand DownExpand Up@@ -81,15 +82,13 @@ export function SalaryCalculator() {
value={monthlyHours || ""}
onChange={(event) => setMonthlyHours(Number(event.target.value))}
/>
<FormField
<DurationField
id="jornada-diaria"
label="Jornada Diária (horas)"
type="number"
min={0}
label="Jornada Diária"
hint="Horas e minutos por dia. Ex.: 08:48"
icon={<Sun className="w-5 h-5" aria-hidden="true" />}
placeholder="8"
value={dailyHours || ""}
onChange={(event) => setDailyHours(Number(event.target.value))}
minutes={dailyMinutes}
onMinutesChange={setDailyMinutes}
/>
</div>

Expand Down
21 changes: 11 additions & 10 deletions hooks/use-salary-calculator.ts
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import { minutesToHours } from "@/lib/duration";
import {
calculateIncomeTax,
calculateSocialSecurity,
Expand All@@ -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",
Expand DownExpand Up@@ -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<WorkRegime>(DEFAULT_REGIME);
const [period, setPeriod] = useState<SalaryPeriod>(DEFAULT_PERIOD);
Expand All@@ -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));
Expand All@@ -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(
Expand DownExpand Up@@ -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,
Expand DownExpand Up@@ -168,8 +169,8 @@ export function useSalaryCalculator(initialSalary = DEFAULT_GROSS_SALARY, initia
setGrossSalary,
monthlyHours,
setMonthlyHours,
dailyHours,
setDailyHours,
dailyMinutes,
setDailyMinutes,
dependents,
setDependents,
regime,
Expand Down