refactor hooks organization and month picker
This commit is contained in:
@@ -11,6 +11,9 @@ e este projeto adere ao [Versionamento Semântico](https://semver.org/lang/pt-BR
|
|||||||
|
|
||||||
### Alterado
|
### Alterado
|
||||||
|
|
||||||
|
- Períodos e navegação mensal: `useMonthPeriod` passou a usar os helpers centrais de período (`YYYY-MM`), o month-picker foi simplificado e o rótulo visual agora segue o formato `Março 2026`.
|
||||||
|
- Hooks e organização: hooks locais de calculadora, month-picker, logo picker e sidebar foram movidos para perto das respectivas features, deixando `/hooks` focado nos hooks realmente compartilhados.
|
||||||
|
- Estado de formulários e responsividade: `useFormState` ganhou APIs explícitas de reset/substituição no lugar do setter cru, e `useIsMobile` foi atualizado para assinatura estável com `useSyncExternalStore`, reduzindo a troca estrutural inicial no sidebar entre mobile e desktop.
|
||||||
- Navegação e estrutura compartilhada: `components/navbar` e `components/sidebar` foram consolidados em `components/navigation/*`, componentes globais migraram para `components/shared/*` e os imports foram padronizados no projeto.
|
- Navegação e estrutura compartilhada: `components/navbar` e `components/sidebar` foram consolidados em `components/navigation/*`, componentes globais migraram para `components/shared/*` e os imports foram padronizados no projeto.
|
||||||
- Dashboard e relatórios: a análise de parcelas foi movida para `/relatorios/analise-parcelas`, ações rápidas e widgets do dashboard foram refinados, e os cards de relatórios ganharam ajustes para evitar overflow no mobile.
|
- Dashboard e relatórios: a análise de parcelas foi movida para `/relatorios/analise-parcelas`, ações rápidas e widgets do dashboard foram refinados, e os cards de relatórios ganharam ajustes para evitar overflow no mobile.
|
||||||
- Pré-lançamentos e lançamentos: tabs e cards da inbox ficaram mais consistentes no mobile, itens descartados podem voltar para `Pendente` e compras feitas no dia do fechamento do cartão agora entram na próxima fatura.
|
- Pré-lançamentos e lançamentos: tabs e cards da inbox ficaram mais consistentes no mobile, itens descartados podem voltar para `Pendente` e compras feitas no dia do fechamento do cartão agora entram na próxima fatura.
|
||||||
|
|||||||
@@ -85,17 +85,17 @@ export function NoteDialog({
|
|||||||
|
|
||||||
const initialState = buildInitialValues(note);
|
const initialState = buildInitialValues(note);
|
||||||
|
|
||||||
const { formState, updateField, setFormState } =
|
const { formState, resetForm, updateField } =
|
||||||
useFormState<NoteFormValues>(initialState);
|
useFormState<NoteFormValues>(initialState);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setFormState(buildInitialValues(note));
|
resetForm(buildInitialValues(note));
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
setNewTaskText("");
|
setNewTaskText("");
|
||||||
requestAnimationFrame(() => titleRef.current?.focus());
|
requestAnimationFrame(() => titleRef.current?.focus());
|
||||||
}
|
}
|
||||||
}, [dialogOpen, note, setFormState]);
|
}, [dialogOpen, note, resetForm]);
|
||||||
|
|
||||||
const dialogTitle = mode === "create" ? "Nova anotação" : "Editar anotação";
|
const dialogTitle = mode === "create" ? "Nova anotação" : "Editar anotação";
|
||||||
const description =
|
const description =
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ import {
|
|||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useDraggableDialog } from "@/hooks/use-draggable-dialog";
|
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
import { useDraggableDialog } from "./use-draggable-dialog";
|
||||||
|
|
||||||
type Variant = React.ComponentProps<typeof Button>["variant"];
|
type Variant = React.ComponentProps<typeof Button>["variant"];
|
||||||
type Size = React.ComponentProps<typeof Button>["size"];
|
type Size = React.ComponentProps<typeof Button>["size"];
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import type { CalculatorButtonConfig } from "@/components/calculadora/use-calculator-state";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { CalculatorButtonConfig } from "@/hooks/use-calculator-state";
|
|
||||||
import type { Operator } from "@/lib/utils/calculator";
|
import type { Operator } from "@/lib/utils/calculator";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { CalculatorKeypad } from "@/components/calculadora/calculator-keypad";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { useCalculatorKeyboard } from "@/hooks/use-calculator-keyboard";
|
|
||||||
import { useCalculatorState } from "@/hooks/use-calculator-state";
|
|
||||||
import { CalculatorDisplay } from "./calculator-display";
|
import { CalculatorDisplay } from "./calculator-display";
|
||||||
|
|
||||||
type CalculatorProps = {
|
type CalculatorProps = {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
updateCardAction,
|
updateCardAction,
|
||||||
} from "@/app/(dashboard)/cartoes/actions";
|
} from "@/app/(dashboard)/cartoes/actions";
|
||||||
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
|
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
|
||||||
|
import { useLogoSelection } from "@/components/logo-picker/use-logo-selection";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -25,7 +26,6 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/hooks/use-form-state";
|
||||||
import { useLogoSelection } from "@/hooks/use-logo-selection";
|
|
||||||
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
|
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
|
||||||
import { formatLimitInput } from "@/lib/utils/currency";
|
import { formatLimitInput } from "@/lib/utils/currency";
|
||||||
import { CardFormFields } from "./card-form-fields";
|
import { CardFormFields } from "./card-form-fields";
|
||||||
@@ -100,16 +100,16 @@ export function CardDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, updateField, updateFields, setFormState } =
|
const { formState, resetForm, updateField, updateFields } =
|
||||||
useFormState<CardFormValues>(initialState);
|
useFormState<CardFormValues>(initialState);
|
||||||
|
|
||||||
// Reset form when dialog opens
|
// Reset form when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
}, [dialogOpen, initialState, setFormState]);
|
}, [dialogOpen, initialState, resetForm]);
|
||||||
|
|
||||||
// Close logo dialog when main dialog closes
|
// Close logo dialog when main dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -173,7 +173,7 @@ export function CardDialog({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,7 +181,7 @@ export function CardDialog({
|
|||||||
toast.error(result.error);
|
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";
|
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
|
||||||
|
|||||||
@@ -76,16 +76,16 @@ export function CategoryDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, updateField, setFormState } =
|
const { formState, resetForm, updateField } =
|
||||||
useFormState<CategoryFormValues>(initialState);
|
useFormState<CategoryFormValues>(initialState);
|
||||||
|
|
||||||
// Reset form when dialog opens
|
// Reset form when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
}, [dialogOpen, setFormState, initialState]);
|
}, [dialogOpen, initialState, resetForm]);
|
||||||
|
|
||||||
// Clear error when dialog closes
|
// Clear error when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -123,7 +123,7 @@ export function CategoryDialog({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
updateAccountAction,
|
updateAccountAction,
|
||||||
} from "@/app/(dashboard)/contas/actions";
|
} from "@/app/(dashboard)/contas/actions";
|
||||||
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
|
import { LogoPickerDialog, LogoPickerTrigger } from "@/components/logo-picker";
|
||||||
|
import { useLogoSelection } from "@/components/logo-picker/use-logo-selection";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -25,7 +26,6 @@ import {
|
|||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/hooks/use-form-state";
|
||||||
import { useLogoSelection } from "@/hooks/use-logo-selection";
|
|
||||||
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
|
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
|
||||||
import { formatInitialBalanceInput } from "@/lib/utils/currency";
|
import { formatInitialBalanceInput } from "@/lib/utils/currency";
|
||||||
|
|
||||||
@@ -126,16 +126,16 @@ export function AccountDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, updateField, updateFields, setFormState } =
|
const { formState, resetForm, updateField, updateFields } =
|
||||||
useFormState<AccountFormValues>(initialState);
|
useFormState<AccountFormValues>(initialState);
|
||||||
|
|
||||||
// Reset form when dialog opens
|
// Reset form when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
}, [dialogOpen, initialState, setFormState]);
|
}, [dialogOpen, initialState, resetForm]);
|
||||||
|
|
||||||
// Close logo dialog when main dialog closes
|
// Close logo dialog when main dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -190,7 +190,7 @@ export function AccountDialog({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +198,7 @@ export function AccountDialog({
|
|||||||
toast.error(result.error);
|
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";
|
const title = mode === "create" ? "Nova conta" : "Editar conta";
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, updateField, setFormState } =
|
const { formState, replaceForm, updateField } =
|
||||||
useFormState<AnticipationFormValues>({
|
useFormState<AnticipationFormValues>({
|
||||||
anticipationPeriod: defaultPeriod,
|
anticipationPeriod: defaultPeriod,
|
||||||
discount: "0",
|
discount: "0",
|
||||||
@@ -122,7 +122,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
// Pré-preencher pagador e categoria da primeira parcela
|
// Pré-preencher pagador e categoria da primeira parcela
|
||||||
if (installments.length > 0) {
|
if (installments.length > 0) {
|
||||||
const first = installments[0];
|
const first = installments[0];
|
||||||
setFormState({
|
replaceForm({
|
||||||
anticipationPeriod: defaultPeriod,
|
anticipationPeriod: defaultPeriod,
|
||||||
discount: "0",
|
discount: "0",
|
||||||
pagadorId: first.pagadorId ?? "",
|
pagadorId: first.pagadorId ?? "",
|
||||||
@@ -140,7 +140,7 @@ export function AnticipateInstallmentsDialog({
|
|||||||
setIsLoadingInstallments(false);
|
setIsLoadingInstallments(false);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [dialogOpen, seriesId, defaultPeriod, setFormState]);
|
}, [defaultPeriod, dialogOpen, replaceForm, seriesId]);
|
||||||
|
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
return eligibleInstallments
|
return eligibleInstallments
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ interface UseLogoSelectionProps {
|
|||||||
* mode: 'create',
|
* mode: 'create',
|
||||||
* currentLogo: formState.logo,
|
* currentLogo: formState.logo,
|
||||||
* currentName: formState.name,
|
* currentName: formState.name,
|
||||||
* onUpdate: (updates) => setFormState(prev => ({ ...prev, ...updates }))
|
* onUpdate: (updates) => updateFields(updates)
|
||||||
* });
|
* });
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@@ -3,61 +3,37 @@
|
|||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useTransition } from "react";
|
import { useEffect, useMemo, useTransition } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
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 LoadingSpinner from "./loading-spinner";
|
||||||
import NavigationButton from "./nav-button";
|
import NavigationButton from "./nav-button";
|
||||||
import ReturnButton from "./return-button";
|
import ReturnButton from "./return-button";
|
||||||
|
import { useMonthPeriod } from "./use-month-period";
|
||||||
|
|
||||||
export default function MonthNavigation() {
|
export default function MonthNavigation() {
|
||||||
const {
|
const { period, currentMonth, currentYear, defaultPeriod, buildHref } =
|
||||||
monthNames,
|
useMonthPeriod();
|
||||||
currentMonth,
|
|
||||||
currentYear,
|
|
||||||
defaultMonth,
|
|
||||||
defaultYear,
|
|
||||||
buildHref,
|
|
||||||
} = useMonthPeriod();
|
|
||||||
|
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const currentMonthLabel = useMemo(
|
const currentMonthLabel = useMemo(
|
||||||
() => currentMonth.charAt(0).toUpperCase() + currentMonth.slice(1),
|
() =>
|
||||||
[currentMonth],
|
`${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`,
|
||||||
|
[currentMonth, currentYear],
|
||||||
);
|
);
|
||||||
|
const prevTarget = useMemo(
|
||||||
const currentMonthIndex = useMemo(
|
() => buildHref(getPreviousPeriod(period)),
|
||||||
() => monthNames.indexOf(currentMonth),
|
[buildHref, period],
|
||||||
[monthNames, currentMonth],
|
);
|
||||||
|
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(
|
const returnTarget = useMemo(
|
||||||
() => buildHref(defaultMonth, defaultYear),
|
() => buildHref(defaultPeriod),
|
||||||
[buildHref, defaultMonth, defaultYear],
|
[buildHref, defaultPeriod],
|
||||||
);
|
);
|
||||||
|
const isDifferentFromCurrent = period !== defaultPeriod;
|
||||||
const isDifferentFromCurrent =
|
|
||||||
currentMonth !== defaultMonth || currentYear !== defaultYear.toString();
|
|
||||||
|
|
||||||
// Prefetch otimizado: apenas meses adjacentes (M-1, M+1) e mês atual
|
// Prefetch otimizado: apenas meses adjacentes (M-1, M+1) e mês atual
|
||||||
// Isso melhora a performance da navegação sem sobrecarregar o cliente
|
// Isso melhora a performance da navegação sem sobrecarregar o cliente
|
||||||
@@ -91,10 +67,9 @@ export default function MonthNavigation() {
|
|||||||
<div
|
<div
|
||||||
className="mx-1 space-x-1 capitalize font-semibold"
|
className="mx-1 space-x-1 capitalize font-semibold"
|
||||||
aria-current={!isDifferentFromCurrent ? "date" : undefined}
|
aria-current={!isDifferentFromCurrent ? "date" : undefined}
|
||||||
aria-label={`Período selecionado: ${currentMonthLabel} de ${currentYear}`}
|
aria-label={`Período selecionado: ${currentMonthLabel}`}
|
||||||
>
|
>
|
||||||
<span>{currentMonthLabel}</span>
|
<span>{currentMonthLabel}</span>
|
||||||
<span>{currentYear}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isPending && <LoadingSpinner />}
|
{isPending && <LoadingSpinner />}
|
||||||
|
|||||||
90
components/month-picker/use-month-period.ts
Normal file
90
components/month-picker/use-month-period.ts
Normal 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 };
|
||||||
@@ -85,16 +85,16 @@ export function BudgetDialog({
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, updateField, setFormState } =
|
const { formState, resetForm, updateField } =
|
||||||
useFormState<BudgetFormValues>(initialState);
|
useFormState<BudgetFormValues>(initialState);
|
||||||
|
|
||||||
// Reset form when dialog opens
|
// Reset form when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
}, [dialogOpen, setFormState, initialState]);
|
}, [dialogOpen, initialState, resetForm]);
|
||||||
|
|
||||||
// Clear error when dialog closes
|
// Clear error when dialog closes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -153,7 +153,7 @@ export function BudgetDialog({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ export function PagadorDialog({
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, updateField, setFormState } =
|
const { formState, resetForm, updateField } =
|
||||||
useFormState<PagadorFormValues>(initialState);
|
useFormState<PagadorFormValues>(initialState);
|
||||||
|
|
||||||
const availableAvatars = useMemo(() => {
|
const availableAvatars = useMemo(() => {
|
||||||
@@ -111,10 +111,10 @@ export function PagadorDialog({
|
|||||||
// Reset form when dialog opens
|
// Reset form when dialog opens
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dialogOpen) {
|
if (dialogOpen) {
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
setErrorMessage(null);
|
setErrorMessage(null);
|
||||||
}
|
}
|
||||||
}, [dialogOpen, initialState, setFormState]);
|
}, [dialogOpen, initialState, resetForm]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = useCallback(
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
(event: React.FormEvent<HTMLFormElement>) => {
|
||||||
@@ -160,7 +160,7 @@ export function PagadorDialog({
|
|||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
setDialogOpen(false);
|
setDialogOpen(false);
|
||||||
setFormState(initialState);
|
resetForm(initialState);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,7 +168,7 @@ export function PagadorDialog({
|
|||||||
toast.error(result.error);
|
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";
|
const title = mode === "create" ? "Novo pagador" : "Editar pagador";
|
||||||
|
|||||||
@@ -21,8 +21,8 @@ import {
|
|||||||
TooltipProvider,
|
TooltipProvider,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { useIsMobile } from "@/hooks/use-mobile";
|
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
import { useIsMobile } from "./use-mobile";
|
||||||
|
|
||||||
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
const SIDEBAR_COOKIE_NAME = "sidebar_state";
|
||||||
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
const SIDEBAR_COOKIE_MAX_AGE = 60 * 60 * 24 * 7;
|
||||||
@@ -162,7 +162,7 @@ function Sidebar({
|
|||||||
variant?: "sidebar" | "floating" | "inset";
|
variant?: "sidebar" | "floating" | "inset";
|
||||||
collapsible?: "offcanvas" | "icon" | "none";
|
collapsible?: "offcanvas" | "icon" | "none";
|
||||||
}) {
|
}) {
|
||||||
const { isMobile, state, openMobile, setOpenMobile } = useSidebar();
|
const { state, openMobile, setOpenMobile } = useSidebar();
|
||||||
|
|
||||||
if (collapsible === "none") {
|
if (collapsible === "none") {
|
||||||
return (
|
return (
|
||||||
@@ -179,14 +179,14 @@ function Sidebar({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isMobile) {
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
<Sheet open={openMobile} onOpenChange={setOpenMobile} {...props}>
|
||||||
<SheetContent
|
<SheetContent
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
data-mobile="true"
|
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={
|
style={
|
||||||
{
|
{
|
||||||
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
"--sidebar-width": SIDEBAR_WIDTH_MOBILE,
|
||||||
@@ -201,10 +201,7 @@ function Sidebar({
|
|||||||
<div className="flex h-full w-full flex-col">{children}</div>
|
<div className="flex h-full w-full flex-col">{children}</div>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
className="group peer text-sidebar-foreground hidden md:block"
|
className="group peer text-sidebar-foreground hidden md:block"
|
||||||
data-state={state}
|
data-state={state}
|
||||||
@@ -249,6 +246,7 @@ function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
components/ui/use-mobile.ts
Normal file
26
components/ui/use-mobile.ts
Normal 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);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing form state with type-safe field updates
|
* Hook for managing form state with type-safe field updates
|
||||||
*
|
*
|
||||||
* @param initialValues - Initial form values
|
* @param initialValues - Initial form values
|
||||||
* @returns Object with formState, updateField, resetForm, setFormState
|
* @returns Object with formState, updateField, updateFields, replaceForm, resetForm
|
||||||
*
|
*
|
||||||
* @example
|
* @example
|
||||||
* ```tsx
|
* ```tsx
|
||||||
@@ -16,37 +16,45 @@ import { useState } from "react";
|
|||||||
* updateField('name', 'John');
|
* updateField('name', 'John');
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
export function useFormState<T extends Record<string, unknown>>(
|
export function useFormState<T extends object>(initialValues: T) {
|
||||||
initialValues: T,
|
const latestInitialValuesRef = useRef(initialValues);
|
||||||
) {
|
latestInitialValuesRef.current = initialValues;
|
||||||
|
|
||||||
const [formState, setFormState] = useState<T>(initialValues);
|
const [formState, setFormState] = useState<T>(initialValues);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates a single field in the form state
|
* Updates a single field in the form state
|
||||||
*/
|
*/
|
||||||
const updateField = <K extends keyof T>(field: K, value: T[K]) => {
|
const updateField = useCallback(
|
||||||
|
<K extends keyof T>(field: K, value: T[K]) => {
|
||||||
setFormState((prev) => ({ ...prev, [field]: value }));
|
setFormState((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resets form to initial values
|
* Resets form to initial values
|
||||||
*/
|
*/
|
||||||
const resetForm = () => {
|
const resetForm = useCallback((nextValues?: T) => {
|
||||||
setFormState(initialValues);
|
setFormState(nextValues ?? latestInitialValuesRef.current);
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates multiple fields at once
|
* Updates multiple fields at once
|
||||||
*/
|
*/
|
||||||
const updateFields = (updates: Partial<T>) => {
|
const updateFields = useCallback((updates: Partial<T>) => {
|
||||||
setFormState((prev) => ({ ...prev, ...updates }));
|
setFormState((prev) => ({ ...prev, ...updates }));
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
|
const replaceForm = useCallback((nextValues: T) => {
|
||||||
|
setFormState(nextValues);
|
||||||
|
}, []);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
formState,
|
formState,
|
||||||
updateField,
|
updateField,
|
||||||
updateFields,
|
updateFields,
|
||||||
|
replaceForm,
|
||||||
resetForm,
|
resetForm,
|
||||||
setFormState,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
import * as React from "react";
|
|
||||||
|
|
||||||
const MOBILE_BREAKPOINT = 768;
|
|
||||||
|
|
||||||
export function useIsMobile() {
|
|
||||||
const [isMobile, setIsMobile] = React.useState<boolean | undefined>(
|
|
||||||
undefined,
|
|
||||||
);
|
|
||||||
|
|
||||||
React.useEffect(() => {
|
|
||||||
const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
|
|
||||||
const onChange = () => {
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
};
|
|
||||||
mql.addEventListener("change", onChange);
|
|
||||||
setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
|
|
||||||
return () => mql.removeEventListener("change", onChange);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
return !!isMobile;
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
import { MONTH_NAMES } from "@/lib/utils/period";
|
|
||||||
|
|
||||||
const PERIOD_PARAM = "periodo";
|
|
||||||
|
|
||||||
const normalizeMonth = (value: string) => value.trim().toLowerCase();
|
|
||||||
|
|
||||||
export function useMonthPeriod() {
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const pathname = usePathname();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
// Get current date info
|
|
||||||
const now = new Date();
|
|
||||||
const currentYear = now.getFullYear();
|
|
||||||
const currentMonthName = MONTH_NAMES[now.getMonth()];
|
|
||||||
const optionsMeses = useMemo(() => [...MONTH_NAMES], []);
|
|
||||||
|
|
||||||
const defaultMonth = currentMonthName;
|
|
||||||
const defaultYear = currentYear.toString();
|
|
||||||
|
|
||||||
const periodFromParams = searchParams.get(PERIOD_PARAM);
|
|
||||||
|
|
||||||
const { month: currentMonth, year: currentYearValue } = useMemo(() => {
|
|
||||||
if (!periodFromParams) {
|
|
||||||
return { month: defaultMonth, year: defaultYear };
|
|
||||||
}
|
|
||||||
|
|
||||||
const [rawMonth, rawYear] = periodFromParams.split("-");
|
|
||||||
const normalizedMonth = normalizeMonth(rawMonth ?? "");
|
|
||||||
const normalizedYear = (rawYear ?? "").trim();
|
|
||||||
const monthExists = optionsMeses.includes(normalizedMonth);
|
|
||||||
const parsedYear = Number.parseInt(normalizedYear, 10);
|
|
||||||
|
|
||||||
if (!monthExists || Number.isNaN(parsedYear)) {
|
|
||||||
return { month: defaultMonth, year: defaultYear };
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
month: normalizedMonth,
|
|
||||||
year: parsedYear.toString(),
|
|
||||||
};
|
|
||||||
}, [periodFromParams, defaultMonth, defaultYear, optionsMeses]);
|
|
||||||
|
|
||||||
const buildHref = (month: string, year: string | number) => {
|
|
||||||
const normalizedMonth = normalizeMonth(month);
|
|
||||||
const normalizedYear = String(year).trim();
|
|
||||||
|
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
|
||||||
params.set(PERIOD_PARAM, `${normalizedMonth}-${normalizedYear}`);
|
|
||||||
|
|
||||||
return `${pathname}?${params.toString()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const replacePeriod = (target: string) => {
|
|
||||||
if (!target) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
router.replace(target, { scroll: false });
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
monthNames: optionsMeses,
|
|
||||||
pathname,
|
|
||||||
currentMonth,
|
|
||||||
currentYear: currentYearValue,
|
|
||||||
defaultMonth,
|
|
||||||
defaultYear,
|
|
||||||
buildHref,
|
|
||||||
replacePeriod,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export { PERIOD_PARAM as MONTH_PERIOD_PARAM };
|
|
||||||
Reference in New Issue
Block a user