refactor hooks organization and month picker

This commit is contained in:
Felipe Coutinho
2026-03-06 16:39:49 +00:00
parent 9a5e9161db
commit ad0df4ea81
22 changed files with 239 additions and 239 deletions

View File

@@ -85,17 +85,17 @@ export function NoteDialog({
const initialState = buildInitialValues(note);
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<NoteFormValues>(initialState);
useEffect(() => {
if (dialogOpen) {
setFormState(buildInitialValues(note));
resetForm(buildInitialValues(note));
setErrorMessage(null);
setNewTaskText("");
requestAnimationFrame(() => titleRef.current?.focus());
}
}, [dialogOpen, note, setFormState]);
}, [dialogOpen, note, resetForm]);
const dialogTitle = mode === "create" ? "Nova anotação" : "Editar anotação";
const description =

View File

@@ -16,8 +16,8 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useDraggableDialog } from "@/hooks/use-draggable-dialog";
import { cn } from "@/lib/utils/ui";
import { useDraggableDialog } from "./use-draggable-dialog";
type Variant = React.ComponentProps<typeof Button>["variant"];
type Size = React.ComponentProps<typeof Button>["size"];

View File

@@ -1,5 +1,5 @@
import type { CalculatorButtonConfig } from "@/components/calculadora/use-calculator-state";
import { Button } from "@/components/ui/button";
import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state";
import type { Operator } from "@/lib/utils/calculator";
import { cn } from "@/lib/utils/ui";

View File

@@ -1,9 +1,9 @@
"use client";
import { CalculatorKeypad } from "@/components/calculadora/calculator-keypad";
import { useCalculatorKeyboard } from "@/components/calculadora/use-calculator-keyboard";
import { useCalculatorState } from "@/components/calculadora/use-calculator-state";
import { Button } from "@/components/ui/button";
import { useCalculatorKeyboard } from "@/hooks/use-calculator-keyboard";
import { useCalculatorState } from "@/hooks/use-calculator-state";
import { CalculatorDisplay } from "./calculator-display";
type CalculatorProps = {

View File

@@ -0,0 +1,143 @@
import { useEffect } from "react";
import type { Operator } from "@/lib/utils/calculator";
type UseCalculatorKeyboardParams = {
isOpen: boolean;
canCopy: boolean;
onCopy: () => void | Promise<void>;
onPaste: () => void | Promise<void>;
inputDigit: (digit: string) => void;
inputDecimal: () => void;
setNextOperator: (op: Operator) => void;
evaluate: () => void;
deleteLastDigit: () => void;
reset: () => void;
applyPercent: () => void;
};
function shouldIgnoreForEditableTarget(target: EventTarget | null): boolean {
if (!target || !(target instanceof HTMLElement)) {
return false;
}
const tagName = target.tagName;
return (
tagName === "INPUT" || tagName === "TEXTAREA" || target.isContentEditable
);
}
const KEY_TO_OPERATOR: Record<string, Operator> = {
"+": "add",
"-": "subtract",
"*": "multiply",
"/": "divide",
};
export function useCalculatorKeyboard({
isOpen,
canCopy,
onCopy,
onPaste,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
}: UseCalculatorKeyboardParams) {
useEffect(() => {
if (!isOpen) return;
const handleKeyDown = (event: KeyboardEvent) => {
const { key, ctrlKey, metaKey } = event;
// Ctrl/Cmd shortcuts
if (ctrlKey || metaKey) {
if (shouldIgnoreForEditableTarget(event.target)) return;
const lowerKey = key.toLowerCase();
if (lowerKey === "c" && canCopy) {
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
event.preventDefault();
void onCopy();
} else if (lowerKey === "v") {
const selection = window.getSelection();
if (selection && selection.toString().trim().length > 0) return;
if (!navigator.clipboard?.readText) return;
event.preventDefault();
void onPaste();
}
return;
}
// Digits
if (key >= "0" && key <= "9") {
event.preventDefault();
inputDigit(key);
return;
}
// Decimal
if (key === "." || key === ",") {
event.preventDefault();
inputDecimal();
return;
}
// Operators
const op = KEY_TO_OPERATOR[key];
if (op) {
event.preventDefault();
setNextOperator(op);
return;
}
// Evaluate
if (key === "Enter" || key === "=") {
event.preventDefault();
evaluate();
return;
}
// Backspace
if (key === "Backspace") {
event.preventDefault();
deleteLastDigit();
return;
}
// Escape resets calculator (dialog close is handled by onEscapeKeyDown)
if (key === "Escape") {
event.preventDefault();
reset();
return;
}
// Percent
if (key === "%") {
event.preventDefault();
applyPercent();
return;
}
};
document.addEventListener("keydown", handleKeyDown);
return () => {
document.removeEventListener("keydown", handleKeyDown);
};
}, [
isOpen,
canCopy,
onCopy,
onPaste,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
]);
}

View File

@@ -0,0 +1,379 @@
import type { VariantProps } from "class-variance-authority";
import { useEffect, useRef, useState } from "react";
import type { buttonVariants } from "@/components/ui/button";
import {
formatLocaleValue,
formatNumber,
normalizeClipboardNumber,
OPERATOR_SYMBOLS,
type Operator,
performOperation,
} from "@/lib/utils/calculator";
export type CalculatorButtonConfig = {
label: string;
onClick: () => void;
variant?: VariantProps<typeof buttonVariants>["variant"];
colSpan?: number;
className?: string;
};
export function useCalculatorState() {
const [display, setDisplay] = useState("0");
const [accumulator, setAccumulator] = useState<number | null>(null);
const [operator, setOperator] = useState<Operator | null>(null);
const [overwrite, setOverwrite] = useState(false);
const [history, setHistory] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const resetCopiedTimeoutRef = useRef<number | undefined>(undefined);
const currentValue = Number(display);
const resultText = (() => {
if (display === "Erro") {
return null;
}
const normalized = formatNumber(currentValue);
if (normalized === "Erro") {
return null;
}
return formatLocaleValue(normalized);
})();
const reset = () => {
setDisplay("0");
setAccumulator(null);
setOperator(null);
setOverwrite(false);
setHistory(null);
};
const inputDigit = (digit: string) => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
setDisplay((prev) => {
if (shouldReset) {
return digit;
}
if (prev === "0") {
return digit;
}
// Limitar a 10 dígitos (excluindo sinal negativo e ponto decimal)
const digitCount = prev.replace(/[-.]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}${digit}`;
});
// Update related states after display update
if (shouldReset) {
setOverwrite(false);
setHistory(null);
}
};
const inputDecimal = () => {
// Check conditions before state updates
const shouldReset = overwrite || display === "Erro";
setDisplay((prev) => {
if (shouldReset) {
return "0.";
}
if (prev.includes(".")) {
return prev;
}
// Limitar a 10 dígitos antes de adicionar o ponto decimal
const digitCount = prev.replace(/[-]/g, "").length;
if (digitCount >= 10) {
return prev;
}
return `${prev}.`;
});
// Update related states after display update
if (shouldReset) {
setOverwrite(false);
setHistory(null);
}
};
const setNextOperator = (nextOperator: Operator) => {
if (display === "Erro") {
reset();
return;
}
const value = currentValue;
if (accumulator === null || operator === null || overwrite) {
setAccumulator(value);
} else {
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setAccumulator(Number.isFinite(result) ? result : null);
setDisplay(formatted);
if (!Number.isFinite(result)) {
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
}
setOperator(nextOperator);
setOverwrite(true);
setHistory(null);
};
const evaluate = () => {
if (operator === null || accumulator === null || display === "Erro") {
return;
}
const value = currentValue;
const left = formatNumber(accumulator);
const right = formatNumber(value);
const symbol = OPERATOR_SYMBOLS[operator];
const operation = `${formatLocaleValue(left)} ${symbol} ${formatLocaleValue(
right,
)}`;
const result = performOperation(accumulator, value, operator);
const formatted = formatNumber(result);
setDisplay(formatted);
setAccumulator(Number.isFinite(result) ? result : null);
setOperator(null);
setOverwrite(true);
setHistory(operation);
};
const toggleSign = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
}
if (prev.startsWith("-")) {
return prev.slice(1);
}
return prev === "0" ? prev : `-${prev}`;
});
if (overwrite) {
setOverwrite(false);
setHistory(null);
}
};
const deleteLastDigit = () => {
setHistory(null);
// Check conditions before state updates
const isError = display === "Erro";
setDisplay((prev) => {
if (prev === "Erro") {
return "0";
}
if (overwrite) {
return "0";
}
if (prev.length <= 1 || (prev.length === 2 && prev.startsWith("-"))) {
return "0";
}
return prev.slice(0, -1);
});
// Update related states after display update
if (isError) {
setAccumulator(null);
setOperator(null);
setOverwrite(false);
} else if (overwrite) {
setOverwrite(false);
}
};
const applyPercent = () => {
setDisplay((prev) => {
if (prev === "Erro") {
return prev;
}
const value = Number(prev);
return formatNumber(value / 100);
});
setOverwrite(true);
setHistory(null);
};
const expression = (() => {
if (display === "Erro") {
return "Erro";
}
if (operator && accumulator !== null) {
const symbol = OPERATOR_SYMBOLS[operator];
const left = formatLocaleValue(formatNumber(accumulator));
if (overwrite) {
return `${left} ${symbol}`;
}
return `${left} ${symbol} ${formatLocaleValue(display)}`;
}
return formatLocaleValue(display);
})();
const makeOperatorHandler = (nextOperator: Operator) => () =>
setNextOperator(nextOperator);
const buttons: CalculatorButtonConfig[][] = [
[
{ label: "C", onClick: reset, variant: "destructive" },
{ label: "⌫", onClick: deleteLastDigit, variant: "secondary" },
{ label: "%", onClick: applyPercent, variant: "secondary" },
{
label: "÷",
onClick: makeOperatorHandler("divide"),
variant: "outline",
},
],
[
{ label: "7", onClick: () => inputDigit("7") },
{ label: "8", onClick: () => inputDigit("8") },
{ label: "9", onClick: () => inputDigit("9") },
{
label: "×",
onClick: makeOperatorHandler("multiply"),
variant: "outline",
},
],
[
{ label: "4", onClick: () => inputDigit("4") },
{ label: "5", onClick: () => inputDigit("5") },
{ label: "6", onClick: () => inputDigit("6") },
{
label: "-",
onClick: makeOperatorHandler("subtract"),
variant: "outline",
},
],
[
{ label: "1", onClick: () => inputDigit("1") },
{ label: "2", onClick: () => inputDigit("2") },
{ label: "3", onClick: () => inputDigit("3") },
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
],
[
{ label: "±", onClick: toggleSign, variant: "secondary" },
{ label: "0", onClick: () => inputDigit("0") },
{ label: ",", onClick: inputDecimal },
{ label: "=", onClick: evaluate, variant: "default" },
],
];
const copyToClipboard = async () => {
if (!resultText) return;
try {
await navigator.clipboard.writeText(resultText);
setCopied(true);
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
resetCopiedTimeoutRef.current = window.setTimeout(() => {
setCopied(false);
}, 2000);
} catch (error) {
console.error(
"Não foi possível copiar o resultado da calculadora.",
error,
);
}
};
const pasteFromClipboard = async () => {
if (!navigator.clipboard?.readText) return;
try {
const rawValue = await navigator.clipboard.readText();
const normalized = normalizeClipboardNumber(rawValue);
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
resetCopiedTimeoutRef.current = undefined;
}
setCopied(false);
if (!normalized) {
setDisplay("Erro");
setAccumulator(null);
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
// Limitar a 10 dígitos
const digitCount = normalized.replace(/[-.]/g, "").length;
if (digitCount > 10) {
setDisplay("Erro");
setAccumulator(null);
setOperator(null);
setOverwrite(true);
setHistory(null);
return;
}
setDisplay(normalized);
setAccumulator(null);
setOperator(null);
setOverwrite(false);
setHistory(null);
} catch (error) {
console.error("Não foi possível colar o valor na calculadora.", error);
}
};
useEffect(() => {
return () => {
if (resetCopiedTimeoutRef.current !== undefined) {
window.clearTimeout(resetCopiedTimeoutRef.current);
}
};
}, []);
return {
display,
operator,
expression,
history,
resultText,
copied,
buttons,
inputDigit,
inputDecimal,
setNextOperator,
evaluate,
deleteLastDigit,
reset,
applyPercent,
copyToClipboard,
pasteFromClipboard,
};
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useRef } from "react";
type Position = { x: number; y: number };
const MIN_VISIBLE_PX = 20;
function clampPosition(
x: number,
y: number,
elementWidth: number,
elementHeight: number,
): Position {
// Dialog starts centered (left/top 50% + translate(-50%, -50%)).
// Clamp offsets so at least MIN_VISIBLE_PX remains visible on each axis.
const halfViewportWidth = window.innerWidth / 2;
const halfViewportHeight = window.innerHeight / 2;
const halfElementWidth = elementWidth / 2;
const halfElementHeight = elementHeight / 2;
const minX = MIN_VISIBLE_PX - (halfViewportWidth + halfElementWidth);
const maxX = halfViewportWidth + halfElementWidth - MIN_VISIBLE_PX;
const minY = MIN_VISIBLE_PX - (halfViewportHeight + halfElementHeight);
const maxY = halfViewportHeight + halfElementHeight - MIN_VISIBLE_PX;
return {
x: Math.min(Math.max(x, minX), maxX),
y: Math.min(Math.max(y, minY), maxY),
};
}
function applyPosition(el: HTMLElement, x: number, y: number) {
if (x === 0 && y === 0) {
el.style.translate = "";
el.style.transform = "";
} else {
// Keep the dialog's centered baseline (-50%, -50%) and only add drag offset.
el.style.translate = `calc(-50% + ${x}px) calc(-50% + ${y}px)`;
el.style.transform = "";
}
}
export function useDraggableDialog() {
const offset = useRef<Position>({ x: 0, y: 0 });
const dragStart = useRef<Position | null>(null);
const initialOffset = useRef<Position>({ x: 0, y: 0 });
const contentRef = useRef<HTMLElement | null>(null);
const onPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (e.button !== 0) return;
dragStart.current = { x: e.clientX, y: e.clientY };
initialOffset.current = { x: offset.current.x, y: offset.current.y };
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
}, []);
const onPointerMove = useCallback((e: React.PointerEvent<HTMLElement>) => {
if (!dragStart.current || !contentRef.current) return;
const dx = e.clientX - dragStart.current.x;
const dy = e.clientY - dragStart.current.y;
const rawX = initialOffset.current.x + dx;
const rawY = initialOffset.current.y + dy;
const el = contentRef.current;
const clamped = clampPosition(rawX, rawY, el.offsetWidth, el.offsetHeight);
offset.current = clamped;
applyPosition(el, clamped.x, clamped.y);
}, []);
const onPointerUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
dragStart.current = null;
if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
}
}, []);
const onPointerCancel = useCallback(() => {
dragStart.current = null;
}, []);
const onLostPointerCapture = useCallback(() => {
dragStart.current = null;
}, []);
const resetPosition = useCallback(() => {
offset.current = { x: 0, y: 0 };
if (contentRef.current) {
applyPosition(contentRef.current, 0, 0);
}
}, []);
const dragHandleProps = {
onPointerDown,
onPointerMove,
onPointerUp,
onPointerCancel,
onLostPointerCapture,
style: { touchAction: "none" as const, cursor: "grab" },
};
const contentRefCallback = useCallback((node: HTMLElement | null) => {
contentRef.current = node;
}, []);
return {
dragHandleProps,
contentRefCallback,
resetPosition,
};
}

View File

@@ -13,6 +13,7 @@ import {
updateCardAction,
} from "@/app/(dashboard)/cartoes/actions";
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
import { useLogoSelection } from "@/components/logo-picker/use-logo-selection";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -25,7 +26,6 @@ import {
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useLogoSelection } from "@/hooks/use-logo-selection";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatLimitInput } from "@/lib/utils/currency";
import { CardFormFields } from "./card-form-fields";
@@ -100,16 +100,16 @@ export function CardDialog({
);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
const { formState, resetForm, updateField, updateFields } =
useFormState<CardFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, initialState, resetForm]);
// Close logo dialog when main dialog closes
useEffect(() => {
@@ -173,7 +173,7 @@ export function CardDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}
@@ -181,7 +181,7 @@ export function CardDialog({
toast.error(result.error);
});
},
[card?.id, formState, initialState, mode, setDialogOpen, setFormState],
[card?.id, formState, initialState, mode, resetForm, setDialogOpen],
);
const title = mode === "create" ? "Novo cartão" : "Editar cartão";

View File

@@ -76,16 +76,16 @@ export function CategoryDialog({
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<CategoryFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, setFormState, initialState]);
}, [dialogOpen, initialState, resetForm]);
// Clear error when dialog closes
useEffect(() => {
@@ -123,7 +123,7 @@ export function CategoryDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}

View File

@@ -13,6 +13,7 @@ import {
updateAccountAction,
} from "@/app/(dashboard)/contas/actions";
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
import { useLogoSelection } from "@/components/logo-picker/use-logo-selection";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -25,7 +26,6 @@ import {
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useLogoSelection } from "@/hooks/use-logo-selection";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatInitialBalanceInput } from "@/lib/utils/currency";
@@ -126,16 +126,16 @@ export function AccountDialog({
);
// Use form state hook for form management
const { formState, updateField, updateFields, setFormState } =
const { formState, resetForm, updateField, updateFields } =
useFormState<AccountFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, initialState, resetForm]);
// Close logo dialog when main dialog closes
useEffect(() => {
@@ -190,7 +190,7 @@ export function AccountDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}
@@ -198,7 +198,7 @@ export function AccountDialog({
toast.error(result.error);
});
},
[account?.id, formState, initialState, mode, setDialogOpen, setFormState],
[account?.id, formState, initialState, mode, resetForm, setDialogOpen],
);
const title = mode === "create" ? "Nova conta" : "Editar conta";

View File

@@ -92,7 +92,7 @@ export function AnticipateInstallmentsDialog({
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, replaceForm, updateField } =
useFormState<AnticipationFormValues>({
anticipationPeriod: defaultPeriod,
discount: "0",
@@ -122,7 +122,7 @@ export function AnticipateInstallmentsDialog({
// Pré-preencher pagador e categoria da primeira parcela
if (installments.length > 0) {
const first = installments[0];
setFormState({
replaceForm({
anticipationPeriod: defaultPeriod,
discount: "0",
pagadorId: first.pagadorId ?? "",
@@ -140,7 +140,7 @@ export function AnticipateInstallmentsDialog({
setIsLoadingInstallments(false);
});
}
}, [dialogOpen, seriesId, defaultPeriod, setFormState]);
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
const totalAmount = useMemo(() => {
return eligibleInstallments

View File

@@ -0,0 +1,58 @@
import { useCallback } from "react";
import { deriveNameFromLogo } from "@/lib/logo";
interface UseLogoSelectionProps {
mode: "create" | "update";
currentLogo: string;
currentName: string;
onUpdate: (updates: { logo: string; name?: string }) => void;
}
/**
* Hook for handling logo selection with automatic name derivation
*
* When a logo is selected, automatically updates the name field if:
* - Mode is "create", OR
* - Current name is empty, OR
* - Current name matches the previously derived name from logo
*
* @param props Configuration for logo selection behavior
* @returns Handler function for logo selection
*
* @example
* ```tsx
* const handleLogoSelection = useLogoSelection({
* mode: 'create',
* currentLogo: formState.logo,
* currentName: formState.name,
* onUpdate: (updates) => updateFields(updates)
* });
* ```
*/
export function useLogoSelection({
mode,
currentLogo,
currentName,
onUpdate,
}: UseLogoSelectionProps) {
const handleLogoSelection = useCallback(
(newLogo: string) => {
const derived = deriveNameFromLogo(newLogo);
const previousDerived = deriveNameFromLogo(currentLogo);
const shouldUpdateName =
mode === "create" ||
currentName.trim().length === 0 ||
previousDerived === currentName.trim();
if (shouldUpdateName) {
onUpdate({ logo: newLogo, name: derived });
} else {
onUpdate({ logo: newLogo });
}
},
[mode, currentLogo, currentName, onUpdate],
);
return handleLogoSelection;
}

View File

@@ -3,61 +3,37 @@
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useTransition } from "react";
import { Card } from "@/components/ui/card";
import { useMonthPeriod } from "@/hooks/use-month-period";
import { getNextPeriod, getPreviousPeriod } from "@/lib/utils/period";
import LoadingSpinner from "./loading-spinner";
import NavigationButton from "./nav-button";
import ReturnButton from "./return-button";
import { useMonthPeriod } from "./use-month-period";
export default function MonthNavigation() {
const {
monthNames,
currentMonth,
currentYear,
defaultMonth,
defaultYear,
buildHref,
} = useMonthPeriod();
const { period, currentMonth, currentYear, defaultPeriod, buildHref } =
useMonthPeriod();
const router = useRouter();
const [isPending, startTransition] = useTransition();
const currentMonthLabel = useMemo(
() => currentMonth.charAt(0).toUpperCase() + currentMonth.slice(1),
[currentMonth],
() =>
`${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`,
[currentMonth, currentYear],
);
const currentMonthIndex = useMemo(
() => monthNames.indexOf(currentMonth),
[monthNames, currentMonth],
const prevTarget = useMemo(
() => buildHref(getPreviousPeriod(period)),
[buildHref, period],
);
const nextTarget = useMemo(
() => buildHref(getNextPeriod(period)),
[buildHref, period],
);
const prevTarget = useMemo(() => {
let idx = currentMonthIndex - 1;
let year = currentYear;
if (idx < 0) {
idx = monthNames.length - 1;
year = (parseInt(currentYear, 10) - 1).toString();
}
return buildHref(monthNames[idx], year);
}, [currentMonthIndex, currentYear, monthNames, buildHref]);
const nextTarget = useMemo(() => {
let idx = currentMonthIndex + 1;
let year = currentYear;
if (idx >= monthNames.length) {
idx = 0;
year = (parseInt(currentYear, 10) + 1).toString();
}
return buildHref(monthNames[idx], year);
}, [currentMonthIndex, currentYear, monthNames, buildHref]);
const returnTarget = useMemo(
() => buildHref(defaultMonth, defaultYear),
[buildHref, defaultMonth, defaultYear],
() => buildHref(defaultPeriod),
[buildHref, defaultPeriod],
);
const isDifferentFromCurrent =
currentMonth !== defaultMonth || currentYear !== defaultYear.toString();
const isDifferentFromCurrent = period !== defaultPeriod;
// Prefetch otimizado: apenas meses adjacentes (M-1, M+1) e mês atual
// Isso melhora a performance da navegação sem sobrecarregar o cliente
@@ -91,10 +67,9 @@ export default function MonthNavigation() {
<div
className="mx-1 space-x-1 capitalize font-semibold"
aria-current={!isDifferentFromCurrent ? "date" : undefined}
aria-label={`Período selecionado: ${currentMonthLabel} de ${currentYear}`}
aria-label={`Período selecionado: ${currentMonthLabel}`}
>
<span>{currentMonthLabel}</span>
<span>{currentYear}</span>
</div>
{isPending && <LoadingSpinner />}

View File

@@ -0,0 +1,90 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
import {
formatPeriod,
formatPeriodForUrl,
formatPeriodParam,
MONTH_NAMES,
parsePeriodParam,
} from "@/lib/utils/period";
const PERIOD_PARAM = "periodo";
export function useMonthPeriod() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
const periodFromParams = searchParams.get(PERIOD_PARAM);
const referenceDate = useMemo(() => new Date(), []);
const defaultPeriod = useMemo(
() =>
formatPeriod(referenceDate.getFullYear(), referenceDate.getMonth() + 1),
[referenceDate],
);
const { period, monthName, year } = useMemo(
() => parsePeriodParam(periodFromParams, referenceDate),
[periodFromParams, referenceDate],
);
const defaultMonth = useMemo(
() => MONTH_NAMES[referenceDate.getMonth()] ?? "",
[referenceDate],
);
const defaultYear = useMemo(
() => referenceDate.getFullYear().toString(),
[referenceDate],
);
const buildHref = useCallback(
(targetPeriod: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
return `${pathname}?${params.toString()}`;
},
[pathname, searchParams],
);
const buildHrefFromMonth = useCallback(
(month: string, nextYear: string | number) => {
const parsedYear = Number.parseInt(String(nextYear).trim(), 10);
if (Number.isNaN(parsedYear)) {
return buildHref(defaultPeriod);
}
const param = formatPeriodParam(month, parsedYear);
const parsed = parsePeriodParam(param, referenceDate);
return buildHref(parsed.period);
},
[buildHref, defaultPeriod, referenceDate],
);
const replacePeriod = useCallback(
(targetPeriod: string) => {
if (!targetPeriod) {
return;
}
router.replace(buildHref(targetPeriod), { scroll: false });
},
[buildHref, router],
);
return {
pathname,
period,
currentMonth: monthName,
currentYear: year.toString(),
defaultPeriod,
defaultMonth,
defaultYear,
buildHref,
buildHrefFromMonth,
replacePeriod,
};
}
export { PERIOD_PARAM as MONTH_PERIOD_PARAM };

View File

@@ -85,16 +85,16 @@ export function BudgetDialog({
});
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<BudgetFormValues>(initialState);
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, setFormState, initialState]);
}, [dialogOpen, initialState, resetForm]);
// Clear error when dialog closes
useEffect(() => {
@@ -153,7 +153,7 @@ export function BudgetDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}

View File

@@ -95,7 +95,7 @@ export function PagadorDialog({
);
// Use form state hook for form management
const { formState, updateField, setFormState } =
const { formState, resetForm, updateField } =
useFormState<PagadorFormValues>(initialState);
const availableAvatars = useMemo(() => {
@@ -111,10 +111,10 @@ export function PagadorDialog({
// Reset form when dialog opens
useEffect(() => {
if (dialogOpen) {
setFormState(initialState);
resetForm(initialState);
setErrorMessage(null);
}
}, [dialogOpen, initialState, setFormState]);
}, [dialogOpen, initialState, resetForm]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
@@ -160,7 +160,7 @@ export function PagadorDialog({
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
setFormState(initialState);
resetForm(initialState);
return;
}
@@ -168,7 +168,7 @@ export function PagadorDialog({
toast.error(result.error);
});
},
[formState, initialState, mode, pagador?.id, setDialogOpen, setFormState],
[formState, initialState, mode, pagador?.id, resetForm, setDialogOpen],
);
const title = mode === "create" ? "Novo pagador" : "Editar pagador";

View File

@@ -21,8 +21,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useIsMobile } from "@/hooks/use-mobile";
import { cn } from "@/lib/utils/ui";
import { useIsMobile } from "./use-mobile";
const SIDEBAR_COOKIE_NAME = "sidebar_state";
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
@@ -162,7 +162,7 @@ function Sidebar({
variant?: "sidebar" | "floating" | "inset";
collapsible?: "offcanvas" | "icon" | "none";
}) {
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
const { state, openMobile, setOpenMobile } = useSidebar();
if (collapsible === "none") {
return (
@@ -179,14 +179,14 @@ function Sidebar({
);
}
if (isMobile) {
return (
return (
<>
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
<SheetContent
data-sidebar="sidebar"
data-slot="sidebar"
data-mobile="true"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 [&>button]:hidden"
className="bg-sidebar text-sidebar-foreground w-(--sidebar-width) p-0 md:hidden [&>button]:hidden"
style={
{
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
@@ -201,54 +201,52 @@ function Sidebar({
<div className="flex h-full w-full flex-col">{children}</div>
</SheetContent>
</Sheet>
);
}
return (
<div
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=right]:border-l",
className,
)}
{...props}
className="group peer text-sidebar-foreground hidden md:block"
data-state={state}
data-collapsible={state === "collapsed" ? collapsible : ""}
data-variant={variant}
data-side={side}
data-slot="sidebar"
>
{/* This is what handles the sidebar gap on desktop */}
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
data-slot="sidebar-gap"
className={cn(
"relative w-(--sidebar-width) bg-transparent transition-[width] duration-200 ease-linear",
"group-data-[collapsible=offcanvas]:w-0",
"group-data-[side=right]:rotate-180",
variant === "floating" || variant === "inset"
? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon)",
)}
/>
<div
data-slot="sidebar-container"
className={cn(
"fixed inset-y-0 z-10 hidden h-svh w-(--sidebar-width) transition-[left,right,width] duration-200 ease-linear md:flex",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
// Adjust the padding for floating and inset variants.
variant === "floating" || variant === "inset"
? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]"
: "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=right]:border-l",
className,
)}
{...props}
>
{children}
<div
data-sidebar="sidebar"
data-slot="sidebar-inner"
className="bg-sidebar group-data-[variant=floating]:border-sidebar-border flex h-full w-full flex-col group-data-[variant=floating]:rounded-lg group-data-[variant=floating]:border group-data-[variant=floating]:shadow-sm"
>
{children}
</div>
</div>
</div>
</div>
</>
);
}

View File

@@ -0,0 +1,26 @@
import * as React from "react";
const MOBILE_BREAKPOINT = 768;
const MOBILE_MEDIA_QUERY = `(max-width: ${MOBILE_BREAKPOINT - 1}px)`;
export function useIsMobile() {
const subscribe = React.useCallback((onStoreChange: () => void) => {
if (typeof window === "undefined") {
return () => {};
}
const mediaQueryList = window.matchMedia(MOBILE_MEDIA_QUERY);
mediaQueryList.addEventListener("change", onStoreChange);
return () => mediaQueryList.removeEventListener("change", onStoreChange);
}, []);
const getSnapshot = React.useCallback(() => {
if (typeof window === "undefined") {
return false;
}
return window.matchMedia(MOBILE_MEDIA_QUERY).matches;
}, []);
return React.useSyncExternalStore(subscribe, getSnapshot, () => false);
}