refactor(core): centraliza hooks, providers e base compartilhada

This commit is contained in:
Felipe Coutinho
2026-03-09 17:11:55 +00:00
parent 2de5101058
commit 3e06a1d056
76 changed files with 3271 additions and 709 deletions

View File

@@ -29,8 +29,8 @@ import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Textarea } from "@/components/ui/textarea";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { useFormState } from "@/lib/hooks/use-form-state";
import {
type Note,
type NoteFormValues,

View File

@@ -17,7 +17,7 @@ import {
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
import { useDraggableDialog } from "./use-draggable-dialog";
import { useDraggableDialog } from "../../lib/calculadora/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 "@/lib/calculadora/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 "@/lib/calculadora/use-calculator-keyboard";
import { useCalculatorState } from "@/lib/calculadora/use-calculator-state";
import { CalculatorDisplay } from "./calculator-display";
type CalculatorProps = {

View File

@@ -1,12 +1,6 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createCardAction,
@@ -24,12 +18,15 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import {
DEFAULT_CARD_BRANDS,
DEFAULT_CARD_STATUS,
} from "@/lib/cartoes/constants";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { useFormState } from "@/lib/hooks/use-form-state";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatLimitInput } from "@/lib/utils/currency";
import { formatLimitInput, normalizeDecimalInput } from "@/lib/utils/currency";
import { CardFormFields } from "./card-form-fields";
import { DEFAULT_CARD_BRANDS, DEFAULT_CARD_STATUS } from "./constants";
import type { Card, CardFormValues } from "./types";
type AccountOption = {
@@ -133,56 +130,66 @@ export function CardDialog({
},
});
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
type CardCreatePayload = Parameters<typeof createCardAction>[0];
if (mode === "update" && !card?.id) {
const message = "Cartão inválido.";
setErrorMessage(message);
toast.error(message);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (mode === "update" && !card?.id) {
const message = "Cartão inválido.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!formState.contaId) {
const message = "Selecione a conta vinculada.";
setErrorMessage(message);
toast.error(message);
return;
}
const rawLimit = normalizeDecimalInput(formState.limit);
const payload: CardCreatePayload = {
name: formState.name.trim(),
brand: formState.brand,
status: formState.status,
closingDay: formState.closingDay,
dueDay: formState.dueDay,
limit: rawLimit ? Number(rawLimit) : null,
note: formState.note.trim() || null,
logo: formState.logo,
contaId: formState.contaId,
};
if (!payload.logo) {
const message = "Selecione um logo.";
setErrorMessage(message);
toast.error(message);
return;
}
startTransition(async () => {
const result =
mode === "create"
? await createCardAction(payload)
: await updateCardAction({
id: card?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
resetForm(initialState);
return;
}
if (!formState.contaId) {
const message = "Selecione a conta vinculada.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = { ...formState };
if (!payload.logo) {
const message = "Selecione um logo.";
setErrorMessage(message);
toast.error(message);
return;
}
startTransition(async () => {
const result =
mode === "create"
? await createCardAction(payload)
: await updateCardAction({
id: card?.id ?? "",
...payload,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
resetForm(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
},
[card?.id, formState, initialState, mode, resetForm, setDialogOpen],
);
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
const description =
@@ -191,15 +198,12 @@ export function CardDialog({
: "Atualize as informações do cartão selecionado.";
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
const handleMainDialogOpenChange = useCallback(
(open: boolean) => {
if (!open && logoDialogOpen) {
return;
}
setDialogOpen(open);
},
[logoDialogOpen, setDialogOpen],
);
const handleMainDialogOpenChange = (open: boolean) => {
if (!open && logoDialogOpen) {
return;
}
setDialogOpen(open);
};
return (
<>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createCategoryAction,
@@ -16,10 +16,10 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { CATEGORY_TYPES } from "@/lib/categorias/constants";
import { getDefaultIconForType } from "@/lib/categorias/icons";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { useFormState } from "@/lib/hooks/use-form-state";
import { CategoryFormFields } from "./category-form-fields";
import type { Category, CategoryFormValues } from "./types";
@@ -41,7 +41,7 @@ const buildInitialValues = ({
defaultType?: CategoryFormValues["type"];
}): CategoryFormValues => {
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
const fallbackIcon = getDefaultIconForType(initialType);
const fallbackIcon = getDefaultIconForType();
const existingIcon = category?.icon ?? "";
const icon = existingIcon || fallbackIcon;
@@ -70,10 +70,14 @@ export function CategoryDialog({
onOpenChange,
);
const initialState = buildInitialValues({
category,
defaultType,
});
const initialState = useMemo(
() =>
buildInitialValues({
category,
defaultType,
}),
[category, defaultType],
);
// Use form state hook for form management
const { formState, resetForm, updateField } =

View File

@@ -1,12 +1,6 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createAccountAction,
@@ -24,10 +18,13 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { useFormState } from "@/lib/hooks/use-form-state";
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
import { formatInitialBalanceInput } from "@/lib/utils/currency";
import {
formatInitialBalanceInput,
normalizeDecimalInput,
} from "@/lib/utils/currency";
import { AccountFormFields } from "./account-form-fields";
import type { Account, AccountFormValues } from "./types";
@@ -145,6 +142,8 @@ export function AccountDialog({
}
}, [dialogOpen]);
type AccountCreatePayload = Parameters<typeof createAccountAction>[0];
// Use logo selection hook
const handleLogoSelection = useLogoSelection({
mode,
@@ -159,33 +158,38 @@ export function AccountDialog({
},
});
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const accountId = account?.id;
if (mode === "update" && !account?.id) {
const message = "Conta inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
if (mode === "update" && !accountId) {
const message = "Conta inválida.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = { ...formState };
const payload: AccountCreatePayload = {
name: formState.name.trim(),
accountType: formState.accountType,
status: formState.status,
note: formState.note.trim() || null,
logo: formState.logo,
initialBalance: Number(normalizeDecimalInput(formState.initialBalance)),
excludeFromBalance: formState.excludeFromBalance,
excludeInitialBalanceFromIncome:
formState.excludeInitialBalanceFromIncome,
};
if (!payload.logo) {
setErrorMessage("Selecione um logo.");
return;
}
if (!payload.logo) {
setErrorMessage("Selecione um logo.");
return;
}
startTransition(async () => {
const result =
mode === "create"
? await createAccountAction(payload)
: await updateAccountAction({
id: account?.id ?? "",
...payload,
});
startTransition(async () => {
if (mode === "create") {
const result = await createAccountAction(payload);
if (result.success) {
toast.success(result.message);
@@ -196,10 +200,29 @@ export function AccountDialog({
setErrorMessage(result.error);
toast.error(result.error);
return;
}
if (!accountId) {
return;
}
const result = await updateAccountAction({
id: accountId,
...payload,
});
},
[account?.id, formState, initialState, mode, resetForm, setDialogOpen],
);
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
resetForm(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Nova conta" : "Editar conta";
const description =
@@ -208,15 +231,12 @@ export function AccountDialog({
: "Atualize as informações da conta selecionada.";
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
const handleMainDialogOpenChange = useCallback(
(open: boolean) => {
if (!open && logoDialogOpen) {
return;
}
setDialogOpen(open);
},
[logoDialogOpen, setDialogOpen],
);
const handleMainDialogOpenChange = (open: boolean) => {
if (!open && logoDialogOpen) {
return;
}
setDialogOpen(open);
};
return (
<>

View File

@@ -5,7 +5,7 @@ import { toast } from "sonner";
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
import type { AccountData } from "@/app/(dashboard)/contas/data";
import { ContaCartaoSelectContent } from "@/components/lancamentos/select-items";
import { PeriodPicker } from "@/components/period-picker";
import { PeriodPicker } from "@/components/shared/period-picker";
import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import { DatePicker } from "@/components/ui/date-picker";
@@ -26,7 +26,7 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { getTodayDateString } from "@/lib/utils/date";
interface TransferDialogProps {

View File

@@ -1,21 +1,15 @@
"use client";
import { RiLoader4Line } from "@remixicon/react";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createInstallmentAnticipationAction,
getEligibleInstallmentsAction,
} from "@/app/(dashboard)/lancamentos/anticipation-actions";
import { CategoryIcon } from "@/components/categorias/category-icon";
import MoneyValues from "@/components/money-values";
import { PeriodPicker } from "@/components/period-picker";
import MoneyValues from "@/components/shared/money-values";
import { PeriodPicker } from "@/components/shared/period-picker";
import { Button } from "@/components/ui/button";
import { CurrencyInput } from "@/components/ui/currency-input";
import {
@@ -42,8 +36,8 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { useFormState } from "@/lib/hooks/use-form-state";
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
import { InstallmentSelectionTable } from "./installment-selection-table";
@@ -155,61 +149,58 @@ export function AnticipateInstallmentsDialog({
return totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
}, [totalAmount, formState.discount]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (selectedIds.length === 0) {
const message = "Selecione pelo menos uma parcela para antecipar.";
setErrorMessage(message);
toast.error(message);
return;
}
if (selectedIds.length === 0) {
const message = "Selecione pelo menos uma parcela para antecipar.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.anticipationPeriod.length === 0) {
const message = "Informe o período da antecipação.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.anticipationPeriod.length === 0) {
const message = "Informe o período da antecipação.";
setErrorMessage(message);
toast.error(message);
return;
}
const discount = Number(formState.discount) || 0;
if (discount > Math.abs(totalAmount)) {
const message =
"O desconto não pode ser maior que o valor total das parcelas.";
setErrorMessage(message);
toast.error(message);
return;
}
const discount = Number(formState.discount) || 0;
if (discount > Math.abs(totalAmount)) {
const message =
"O desconto não pode ser maior que o valor total das parcelas.";
setErrorMessage(message);
toast.error(message);
return;
}
startTransition(async () => {
const result = await createInstallmentAnticipationAction({
seriesId,
installmentIds: selectedIds,
anticipationPeriod: formState.anticipationPeriod,
discount: Number(formState.discount) || 0,
pagadorId: formState.pagadorId || undefined,
categoriaId: formState.categoriaId || undefined,
note: formState.note || undefined,
});
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
} else {
const errorMsg = result.error || "Erro ao criar antecipação";
setErrorMessage(errorMsg);
toast.error(errorMsg);
}
startTransition(async () => {
const result = await createInstallmentAnticipationAction({
seriesId,
installmentIds: selectedIds,
anticipationPeriod: formState.anticipationPeriod,
discount: Number(formState.discount) || 0,
pagadorId: formState.pagadorId || undefined,
categoriaId: formState.categoriaId || undefined,
note: formState.note || undefined,
});
},
[selectedIds, formState, seriesId, setDialogOpen, totalAmount],
);
const handleCancel = useCallback(() => {
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
} else {
const errorMsg = result.error || "Erro ao criar antecipação";
setErrorMessage(errorMsg);
toast.error(errorMsg);
}
});
};
const handleCancel = () => {
setDialogOpen(false);
}, [setDialogOpen]);
};
return (
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>

View File

@@ -19,7 +19,7 @@ import {
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
import { AnticipationCard } from "../../shared/anticipation-card";

View File

@@ -1,12 +1,6 @@
"use client";
import { RiAddLine } from "@remixicon/react";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createLancamentoAction,
@@ -27,7 +21,7 @@ import {
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import {
filterSecondaryPagadorOptions,
groupAndSortCategorias,
@@ -165,203 +159,160 @@ export function LancamentoDialog({
return groupAndSortCategorias(filtered);
}, [categoriaOptions, formState.transactionType]);
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
type UpdateLancamentoInput = Parameters<typeof updateLancamentoAction>[0];
const totalAmount = useMemo(() => {
const parsed = Number.parseFloat(formState.amount);
return Number.isNaN(parsed) ? 0 : Math.abs(parsed);
}, [formState.amount]);
const getCardInfo = useCallback(
(cartaoId: string | undefined) => {
if (!cartaoId) return null;
const card = cartaoOptions.find((opt) => opt.value === cartaoId);
if (!card) return null;
function getCardInfo(cartaoId: string | undefined) {
if (!cartaoId) return null;
const card = cartaoOptions.find((opt) => opt.value === cartaoId);
if (!card) return null;
return {
closingDay: card.closingDay ?? null,
dueDay: card.dueDay ?? null,
};
}
function handleFieldChange<Key extends keyof FormState>(
key: Key,
value: FormState[Key],
) {
setFormState((prev) => {
const effectiveCartaoId =
key === "cartaoId" ? (value as string) : prev.cartaoId;
const cardInfo = getCardInfo(effectiveCartaoId);
const dependencies = applyFieldDependencies(key, value, prev, cardInfo);
return {
closingDay: card.closingDay ?? null,
dueDay: card.dueDay ?? null,
...prev,
[key]: value,
...dependencies,
};
},
[cartaoOptions],
);
});
}
const handleFieldChange = useCallback(
<Key extends keyof FormState>(key: Key, value: FormState[Key]) => {
setFormState((prev) => {
const effectiveCartaoId =
key === "cartaoId" ? (value as string) : prev.cartaoId;
const cardInfo = getCardInfo(effectiveCartaoId);
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const dependencies = applyFieldDependencies(key, value, prev, cardInfo);
if (!formState.purchaseDate) {
const message = "Informe a data da transação.";
setErrorMessage(message);
toast.error(message);
return;
}
return {
...prev,
[key]: value,
...dependencies,
};
});
},
[getCardInfo],
);
if (!formState.name.trim()) {
const message = "Informe a descrição do lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
if (formState.isSplit && !formState.pagadorId) {
const message =
"Selecione o pagador principal para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!formState.purchaseDate) {
const message = "Informe a data da transação.";
if (formState.isSplit && !formState.secondaryPagadorId) {
const message =
"Selecione o pagador secundário para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
const amountValue = Number(formState.amount);
if (Number.isNaN(amountValue)) {
const message = "Informe um valor válido.";
setErrorMessage(message);
toast.error(message);
return;
}
const sanitizedAmount = Math.abs(amountValue);
if (!formState.categoriaId) {
const message = "Selecione uma categoria.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.paymentMethod === "Cartão de crédito") {
if (!formState.cartaoId) {
const message = "Selecione o cartão.";
setErrorMessage(message);
toast.error(message);
return;
}
} else if (!formState.contaId) {
const message = "Selecione a conta.";
setErrorMessage(message);
toast.error(message);
return;
}
if (!formState.name.trim()) {
const message = "Informe a descrição do lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.isSplit && !formState.pagadorId) {
const message =
"Selecione o pagador principal para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.isSplit && !formState.secondaryPagadorId) {
const message =
"Selecione o pagador secundário para dividir o lançamento.";
setErrorMessage(message);
toast.error(message);
return;
}
const amountValue = Number(formState.amount);
if (Number.isNaN(amountValue)) {
const message = "Informe um valor válido.";
setErrorMessage(message);
toast.error(message);
return;
}
const sanitizedAmount = Math.abs(amountValue);
if (!formState.categoriaId) {
const message = "Selecione uma categoria.";
setErrorMessage(message);
toast.error(message);
return;
}
if (formState.paymentMethod === "Cartão de crédito") {
if (!formState.cartaoId) {
const message = "Selecione o cartão.";
setErrorMessage(message);
toast.error(message);
return;
}
} else if (!formState.contaId) {
const message = "Selecione a conta.";
setErrorMessage(message);
toast.error(message);
return;
}
const payload = {
purchaseDate: formState.purchaseDate,
period: formState.period,
name: formState.name.trim(),
transactionType: formState.transactionType,
amount: sanitizedAmount,
condition: formState.condition,
paymentMethod: formState.paymentMethod,
pagadorId: formState.pagadorId,
secondaryPagadorId: formState.isSplit
? formState.secondaryPagadorId
const payload: CreateLancamentoInput = {
purchaseDate: formState.purchaseDate,
period: formState.period,
name: formState.name.trim(),
transactionType:
formState.transactionType as CreateLancamentoInput["transactionType"],
amount: sanitizedAmount,
condition: formState.condition as CreateLancamentoInput["condition"],
paymentMethod:
formState.paymentMethod as CreateLancamentoInput["paymentMethod"],
pagadorId: formState.pagadorId ?? null,
secondaryPagadorId: formState.isSplit
? formState.secondaryPagadorId
: undefined,
isSplit: formState.isSplit,
primarySplitAmount: formState.isSplit
? Number.parseFloat(formState.primarySplitAmount) || undefined
: undefined,
secondarySplitAmount: formState.isSplit
? Number.parseFloat(formState.secondarySplitAmount) || undefined
: undefined,
contaId: formState.contaId ?? null,
cartaoId: formState.cartaoId ?? null,
categoriaId: formState.categoriaId ?? null,
note: formState.note.trim() || null,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
installmentCount:
formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount)
: undefined,
isSplit: formState.isSplit,
primarySplitAmount: formState.isSplit
? Number.parseFloat(formState.primarySplitAmount) || undefined
recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount)
: undefined,
secondarySplitAmount: formState.isSplit
? Number.parseFloat(formState.secondarySplitAmount) || undefined
dueDate:
formState.paymentMethod === "Boleto" && formState.dueDate
? formState.dueDate
: undefined,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
categoriaId: formState.categoriaId,
note: formState.note.trim() || undefined,
isSettled:
formState.paymentMethod === "Cartão de crédito"
? null
: Boolean(formState.isSettled),
installmentCount:
formState.condition === "Parcelado" && formState.installmentCount
? Number(formState.installmentCount)
: undefined,
recurrenceCount:
formState.condition === "Recorrente" && formState.recurrenceCount
? Number(formState.recurrenceCount)
: undefined,
dueDate:
formState.paymentMethod === "Boleto" && formState.dueDate
? formState.dueDate
: undefined,
boletoPaymentDate:
mode === "update" &&
formState.paymentMethod === "Boleto" &&
formState.boletoPaymentDate
? formState.boletoPaymentDate
: undefined,
};
boletoPaymentDate:
mode === "update" &&
formState.paymentMethod === "Boleto" &&
formState.boletoPaymentDate
? formState.boletoPaymentDate
: undefined,
};
startTransition(async () => {
if (mode === "create") {
const result = await createLancamentoAction(payload);
if (result.success) {
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
return;
}
// Update mode
const hasSeriesId = Boolean(lancamento?.seriesId);
if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, abre o diálogo de bulk action
onBulkEditRequest({
id: lancamento?.id ?? "",
name: formState.name.trim(),
categoriaId: formState.categoriaId,
note: formState.note.trim() || "",
pagadorId: formState.pagadorId,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
amount: sanitizedAmount,
dueDate:
formState.paymentMethod === "Boleto"
? formState.dueDate || null
: null,
boletoPaymentDate:
mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null
: null,
});
return;
}
// Atualização normal para lançamentos únicos ou todos os campos
const result = await updateLancamentoAction({
id: lancamento?.id ?? "",
...payload,
});
startTransition(async () => {
if (mode === "create") {
const result = await createLancamentoAction(payload);
if (result.success) {
toast.success(result.message);
@@ -372,18 +323,54 @@ export function LancamentoDialog({
setErrorMessage(result.error);
toast.error(result.error);
});
},
[
formState,
mode,
lancamento?.id,
lancamento?.seriesId,
setDialogOpen,
onSuccess,
onBulkEditRequest,
],
);
return;
}
// Update mode
const hasSeriesId = Boolean(lancamento?.seriesId);
if (hasSeriesId && onBulkEditRequest) {
// Para lançamentos em série, abre o diálogo de bulk action
onBulkEditRequest({
id: lancamento?.id ?? "",
name: formState.name.trim(),
categoriaId: formState.categoriaId,
note: formState.note.trim() || "",
pagadorId: formState.pagadorId,
contaId: formState.contaId,
cartaoId: formState.cartaoId,
amount: sanitizedAmount,
dueDate:
formState.paymentMethod === "Boleto"
? formState.dueDate || null
: null,
boletoPaymentDate:
mode === "update" && formState.paymentMethod === "Boleto"
? formState.boletoPaymentDate || null
: null,
});
return;
}
// Atualização normal para lançamentos únicos ou todos os campos
const updatePayload: UpdateLancamentoInput = {
id: lancamento?.id ?? "",
...payload,
};
const result = await updateLancamentoAction(updatePayload);
if (result.success) {
toast.success(result.message);
onSuccess?.();
setDialogOpen(false);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
};
const isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting;
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;

View File

@@ -0,0 +1 @@
export { LogoPickerTrigger, LogoPickerDialog } from "./logo-picker";

View File

@@ -0,0 +1,187 @@
"use client";
import Image from "next/image";
import { useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { deriveNameFromLogo, resolveLogoSrc } from "@/lib/logo";
import { cn } from "@/lib/utils/ui";
const DEFAULT_BASE_PATH = "/logos";
interface LogoPickerTriggerProps {
selectedLogo?: string | null;
disabled?: boolean;
helperText?: string;
placeholder?: string;
basePath?: string;
onOpen: () => void;
className?: string;
}
export function LogoPickerTrigger({
selectedLogo,
disabled,
helperText = "Clique para trocar o logo",
placeholder = "Selecionar logo",
basePath = DEFAULT_BASE_PATH,
onOpen,
className,
}: LogoPickerTriggerProps) {
const hasLogo = Boolean(selectedLogo);
const selectedLogoLabel = deriveNameFromLogo(selectedLogo);
const selectedLogoPath =
hasLogo && selectedLogo ? resolveLogoSrc(selectedLogo, { basePath }) : null;
return (
<button
type="button"
onClick={onOpen}
disabled={disabled}
className={cn(
"flex w-full items-center gap-2 rounded-md border p-2 text-left transition-colors hover:border-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60",
className,
)}
>
<span className="flex size-8 shrink-0 items-center justify-center overflow-hidden rounded-full border border-border/40 bg-background shadow-xs">
{selectedLogoPath ? (
<Image
src={selectedLogoPath}
alt={selectedLogoLabel || "Logo selecionado"}
width={28}
height={28}
className="h-full w-full object-contain"
/>
) : (
<span className="text-[10px] text-muted-foreground">Logo</span>
)}
</span>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium text-foreground">
{selectedLogoLabel || placeholder}
</span>
<span className="text-xs text-muted-foreground">
{disabled ? "Nenhum logo disponível" : helperText}
</span>
</span>
</button>
);
}
interface LogoPickerDialogProps {
open: boolean;
logos: string[];
value: string;
onOpenChange: (open: boolean) => void;
onSelect: (logo: string) => void;
basePath?: string;
title?: string;
description?: string;
emptyState?: React.ReactNode;
}
export function LogoPickerDialog({
open,
logos,
value,
onOpenChange,
onSelect,
basePath = DEFAULT_BASE_PATH,
title = "Escolher logo",
description = "Selecione o logo que será usado para identificar este item.",
emptyState,
}: LogoPickerDialogProps) {
const [search, setSearch] = useState("");
const filteredLogos = logos.filter((logo) => {
if (!search.trim()) return true;
const logoLabel = deriveNameFromLogo(logo).toLowerCase();
return logoLabel.includes(search.toLowerCase().trim());
});
const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) setSearch("");
onOpenChange(isOpen);
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description ? (
<DialogDescription>{description}</DialogDescription>
) : null}
</DialogHeader>
{logos.length > 0 && (
<Input
type="text"
placeholder="Pesquisar..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="h-8 text-sm"
/>
)}
{logos.length === 0 ? (
(emptyState ?? (
<p className="text-sm text-muted-foreground">
Nenhum logo encontrado. Adicione arquivos na pasta de logos.
</p>
))
) : filteredLogos.length === 0 ? (
<p className="py-4 text-center text-sm text-muted-foreground">
Nenhum logo encontrado para &ldquo;{search}&rdquo;
</p>
) : (
<div className="grid max-h-custom-height-card grid-cols-4 gap-2 overflow-y-auto p-1 sm:grid-cols-4 md:grid-cols-5">
{filteredLogos.map((logo) => {
const isActive = value === logo;
const logoLabel = deriveNameFromLogo(logo);
return (
<button
type="button"
key={logo}
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
onSelect(logo);
}}
onPointerDown={(e) => e.stopPropagation()}
onTouchStart={(e) => e.stopPropagation()}
className={cn(
"flex flex-col items-center gap-1 rounded-md bg-card p-2 text-center text-xs transition-all hover:border-primary hover:bg-primary/5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
isActive &&
"border-primary bg-primary/5 ring-2 ring-primary/40",
)}
>
<span className="flex w-full items-center justify-center overflow-hidden rounded-full">
<Image
src={resolveLogoSrc(logo, { basePath }) ?? logo}
alt={logoLabel || logo}
width={40}
height={40}
className="rounded-full"
/>
</span>
<span className="line-clamp-1 text-[10px] leading-tight text-muted-foreground">
{logoLabel}
</span>
</button>
);
})}
</div>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -1,9 +1,9 @@
"use client";
import { useRouter } from "next/navigation";
import { useEffect, useMemo, useTransition } from "react";
import { Card } from "@/components/ui/card";
import { useEffect, useTransition } from "react";
import { getNextPeriod, getPreviousPeriod } from "@/lib/utils/period";
import { Card } from "../ui/card";
import LoadingSpinner from "./loading-spinner";
import NavigationButton from "./nav-button";
import ReturnButton from "./return-button";
@@ -16,23 +16,10 @@ export default function MonthNavigation() {
const router = useRouter();
const [isPending, startTransition] = useTransition();
const currentMonthLabel = useMemo(
() =>
`${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`,
[currentMonth, currentYear],
);
const prevTarget = useMemo(
() => buildHref(getPreviousPeriod(period)),
[buildHref, period],
);
const nextTarget = useMemo(
() => buildHref(getNextPeriod(period)),
[buildHref, period],
);
const returnTarget = useMemo(
() => buildHref(defaultPeriod),
[buildHref, defaultPeriod],
);
const currentMonthLabel = `${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`;
const prevTarget = buildHref(getPreviousPeriod(period));
const nextTarget = buildHref(getNextPeriod(period));
const returnTarget = buildHref(defaultPeriod);
const isDifferentFromCurrent = period !== defaultPeriod;
// Prefetch otimizado: apenas meses adjacentes (M-1, M+1) e mês atual
@@ -55,7 +42,7 @@ export default function MonthNavigation() {
};
return (
<Card className="flex w-full flex-row bg-card text-card-foreground p-4 sticky top-16 z-10">
<Card className="flex w-full flex-row p-4 sticky top-16 z-10 backdrop-blur-sm bg-card/30">
<div className="flex items-center gap-1">
<NavigationButton
direction="left"

View File

@@ -1,13 +1,11 @@
"use client";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { useCallback, useMemo } from "react";
import { usePathname, useSearchParams } from "next/navigation";
import { useRef } from "react";
import {
formatPeriod,
formatPeriodForUrl,
formatPeriodParam,
MONTH_NAMES,
parsePeriodParam,
} from "@/lib/utils/period";
@@ -16,74 +14,30 @@ 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 referenceDate = useRef(new Date()).current;
const defaultPeriod = formatPeriod(
referenceDate.getFullYear(),
referenceDate.getMonth() + 1,
);
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 { period, monthName, year } = parsePeriodParam(
periodFromParams,
referenceDate,
);
const buildHref = useCallback(
(targetPeriod: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
const buildHref = (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}?${params.toString()}`;
};
return {
pathname,
period,
currentMonth: monthName,
currentYear: year.toString(),
defaultPeriod,
defaultMonth,
defaultYear,
buildHref,
buildHrefFromMonth,
replacePeriod,
};
}

View File

@@ -1,12 +1,15 @@
import Link from "next/link";
import { AnimatedThemeToggler } from "@/components/animated-theme-toggler";
import { Logo } from "@/components/logo";
import { NotificationBell } from "@/components/notificacoes/notification-bell";
import { AnimatedThemeToggler } from "@/components/shared/animated-theme-toggler";
import { Logo } from "@/components/shared/logo";
import { RefreshPageButton } from "@/components/shared/refresh-page-button";
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
import { NavMenu } from "./nav-menu";
import { NavbarUser } from "./navbar-user";
const navbarActionClassName =
"border-black/10 bg-transparent text-black/75 shadow-none hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20 data-[state=open]:bg-black/10 data-[state=open]:text-black";
type AppNavbarProps = {
user: {
id: string;
@@ -26,11 +29,11 @@ export function AppNavbar({
notificationsSnapshot,
}: AppNavbarProps) {
return (
<header className="fixed top-0 left-0 right-0 z-50 h-16 shrink-0 flex items-center bg-card backdrop-blur-lg supports-backdrop-filter:bg-card/50">
<header className="fixed top-0 left-0 right-0 z-50 flex h-16 shrink-0 items-center bg-primary font-[aeonik] tracking-tight">
<div className="w-full max-w-8xl mx-auto px-4 flex items-center gap-4 h-full">
{/* Logo */}
<Link href="/dashboard" className="shrink-0 mr-1">
<Logo variant="compact" />
<Logo variant="compact" invertTextOnDark={false} />
</Link>
{/* Navigation */}
@@ -44,8 +47,8 @@ export function AppNavbar({
budgetNotifications={notificationsSnapshot.budgetNotifications}
preLancamentosCount={preLancamentosCount}
/>
<RefreshPageButton />
<AnimatedThemeToggler />
<RefreshPageButton className={navbarActionClassName} />
<AnimatedThemeToggler className={navbarActionClassName} />
</div>
{/* User avatar */}

View File

@@ -23,7 +23,7 @@ export function NavDropdown({ items }: NavDropdownProps) {
{item.badge && item.badge > 0 ? (
<Badge
variant="secondary"
className="text-[10px] px-1.5 py-0 h-4 min-w-4 ml-auto"
className="text-xs px-1.5 py-0 h-4 min-w-4 ml-auto"
>
{item.badge}
</Badge>

View File

@@ -2,7 +2,6 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { useMemo } from "react";
const PERIOD_PARAM = "periodo";
@@ -18,13 +17,14 @@ export function NavLink({
}: NavLinkProps) {
const searchParams = useSearchParams();
const resolvedHref = useMemo(() => {
if (!preservePeriod) return href;
let resolvedHref = href;
if (preservePeriod) {
const periodo = searchParams.get(PERIOD_PARAM);
if (!periodo) return href;
const separator = href.includes("?") ? "&" : "?";
return `${href}${separator}${PERIOD_PARAM}=${encodeURIComponent(periodo)}`;
}, [href, preservePeriod, searchParams]);
if (periodo) {
const separator = href.includes("?") ? "&" : "?";
resolvedHref = `${href}${separator}${PERIOD_PARAM}=${encodeURIComponent(periodo)}`;
}
}
return <Link href={resolvedHref} {...props} />;
}

View File

@@ -35,7 +35,7 @@ export function NavMenu() {
return (
<>
{/* Desktop */}
<nav className="hidden md:flex items-center justify-center flex-1">
<nav className="hidden md:flex items-center justify-center flex-1 ">
<NavigationMenu viewport={false}>
<NavigationMenuList className="gap-0">
<NavigationMenuItem>
@@ -73,14 +73,14 @@ export function NavMenu() {
<Button
variant="ghost"
size="icon"
className="-order-1 md:hidden text-foreground hover:bg-foreground/10 hover:text-foreground"
className="-order-1 border border-black/10 text-black/75 shadow-none md:hidden hover:border-black/20 hover:bg-black/10 hover:text-black focus-visible:ring-black/20"
>
<RiMenuLine className="size-5" />
<span className="sr-only">Abrir menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="w-72 p-0">
<SheetHeader className="p-4 border-b">
<SheetContent side="left" className="w-72 p-0 shadow-none">
<SheetHeader className="border-b border-border/60 p-4">
<SheetTitle>Menu</SheetTitle>
</SheetHeader>
<nav className="p-3 overflow-y-auto">

View File

@@ -3,11 +3,10 @@ export const linkBase =
"inline-flex h-8 items-center justify-center rounded-full px-3 text-sm font-medium transition-all lowercase";
// Estado inativo: muted, hover suave sem underline
export const linkIdle =
"text-muted-foreground hover:text-foreground hover:bg-accent";
export const linkIdle = "text-black/75 hover:bg-black/10 hover:text-black";
// Estado ativo: pill com cor primária
export const linkActive = "bg-primary/10 text-primary";
export const linkActive = "bg-black/10 text-black";
// Trigger do NavigationMenu — espelha linkBase + linkIdle, remove estilos padrão
export const triggerClass = [
@@ -18,13 +17,15 @@ export const triggerClass = [
"text-sm!",
"font-medium!",
"bg-transparent!",
"text-muted-foreground!",
"hover:text-foreground!",
"hover:bg-accent!",
"focus:text-foreground!",
"focus:bg-accent!",
"data-[state=open]:text-foreground!",
"data-[state=open]:bg-accent!",
"text-black/75!",
"hover:text-black!",
"hover:bg-black/10!",
"focus:text-black!",
"focus:bg-black/10!",
"focus-visible:ring-black/20!",
"data-[state=open]:text-black!",
"data-[state=open]:bg-black/10!",
"shadow-none!",
"[&_svg]:text-current!",
"lowercase!",
].join(" ");

View File

@@ -1,7 +1,7 @@
"use client";
import { RiCalculatorLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
import { usePrivacyMode } from "@/components/privacy-provider";
import { usePrivacyMode } from "@/components/providers/privacy-provider";
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils/ui";

View File

@@ -9,7 +9,7 @@ import {
import Image from "next/image";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useMemo, useState } from "react";
import { useState } from "react";
import { FeedbackDialogBody } from "@/components/feedback/feedback-dialog";
import { Badge } from "@/components/ui/badge";
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
@@ -44,11 +44,9 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
const [logoutLoading, setLogoutLoading] = useState(false);
const [feedbackOpen, setFeedbackOpen] = useState(false);
const avatarSrc = useMemo(() => {
if (pagadorAvatarUrl) return getAvatarSrc(pagadorAvatarUrl);
if (user.image) return user.image;
return getAvatarSrc(null);
}, [user.image, pagadorAvatarUrl]);
const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null);
async function handleLogout() {
await authClient.signOut({
@@ -65,7 +63,7 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full border-background bg-background shadow-lg"
className="relative flex size-9 items-center justify-center overflow-hidden rounded-full shadow-none transition-colors focus-visible:ring-2 focus-visible:ring-black/20 focus-visible:outline-none"
aria-label="Menu do usuário"
>
<Image
@@ -77,7 +75,11 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
/>
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-60 p-2" sideOffset={10}>
<DropdownMenuContent
align="end"
className="w-60 border-border/60 p-2 shadow-none"
sideOffset={10}
>
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
<Image
src={avatarSrc}

View File

@@ -1,6 +1,6 @@
"use client";
import * as React from "react";
import { Logo } from "@/components/logo";
import { Logo } from "@/components/shared/logo";
import { NavMain } from "@/components/navigation/sidebar/nav-main";
import { NavSecondary } from "@/components/navigation/sidebar/nav-secondary";
import { NavUser } from "@/components/navigation/sidebar/nav-user";

View File

@@ -3,7 +3,7 @@
import type { RemixiconComponentType } from "@remixicon/react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import * as React from "react";
import type * as React from "react";
import {
SidebarGroup,
SidebarGroupContent,
@@ -24,30 +24,22 @@ export function NavSecondary({
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
const pathname = usePathname();
const isLinkActive = React.useCallback(
(url: string) => {
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
const normalizedUrl =
url.endsWith("/") && url !== "/" ? url.slice(0, -1) : url;
// Verifica se é exatamente igual ou se o pathname começa com a URL
return (
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`)
);
},
[pathname],
);
return (
<SidebarGroup {...props}>
<SidebarGroupContent>
<SidebarMenu>
{items.map((item) => {
const itemIsActive = isLinkActive(item.url);
const normalizedPathname =
pathname.endsWith("/") && pathname !== "/"
? pathname.slice(0, -1)
: pathname;
const normalizedUrl =
item.url.endsWith("/") && item.url !== "/"
? item.url.slice(0, -1)
: item.url;
const itemIsActive =
normalizedPathname === normalizedUrl ||
normalizedPathname.startsWith(`${normalizedUrl}/`);
return (
<SidebarMenuItem key={item.title}>
<SidebarMenuButton

View File

@@ -1,12 +1,10 @@
"use client";
import Image from "next/image";
import { useMemo } from "react";
import {
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
useSidebar,
} from "@/components/ui/sidebar";
import { getAvatarSrc } from "@/lib/pagadores/utils";
@@ -21,19 +19,9 @@ type NavUserProps = {
};
export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
useSidebar();
const avatarSrc = useMemo(() => {
// Priorizar o avatar do pagador admin quando disponível
if (pagadorAvatarUrl) {
return getAvatarSrc(pagadorAvatarUrl);
}
// Fallback para a imagem do usuário (Google, etc)
if (user.image) {
return user.image;
}
return getAvatarSrc(null);
}, [user.image, pagadorAvatarUrl]);
const avatarSrc = pagadorAvatarUrl
? getAvatarSrc(pagadorAvatarUrl)
: user.image || getAvatarSrc(null);
return (
<SidebarMenu>

View File

@@ -1,13 +1,13 @@
"use client";
import { useEffect, useState, useTransition } from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createBudgetAction,
updateBudgetAction,
} from "@/app/(dashboard)/orcamentos/actions";
import { CategoryIcon } from "@/components/categorias/category-icon";
import { PeriodPicker } from "@/components/period-picker";
import { PeriodPicker } from "@/components/shared/period-picker";
import { Button } from "@/components/ui/button";
import {
Dialog,
@@ -27,8 +27,9 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Slider } from "@/components/ui/slider";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { useFormState } from "@/lib/hooks/use-form-state";
import { formatCurrency } from "@/lib/utils/currency";
import type { Budget, BudgetCategory, BudgetFormValues } from "./types";
@@ -54,12 +55,6 @@ const buildInitialValues = ({
amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "",
});
const formatCurrency = (value: number) =>
new Intl.NumberFormat("pt-BR", {
style: "currency",
currency: "BRL",
}).format(value);
export function BudgetDialog({
mode,
trigger,
@@ -79,10 +74,14 @@ export function BudgetDialog({
onOpenChange,
);
const initialState = buildInitialValues({
budget,
defaultPeriod,
});
const initialState = useMemo(
() =>
buildInitialValues({
budget,
defaultPeriod,
}),
[budget, defaultPeriod],
);
// Use form state hook for form management
const { formState, resetForm, updateField } =

View File

@@ -1,12 +1,6 @@
"use client";
import Image from "next/image";
import {
useCallback,
useEffect,
useMemo,
useState,
useTransition,
} from "react";
import { useEffect, useMemo, useState, useTransition } from "react";
import { toast } from "sonner";
import {
createPagadorAction,
@@ -32,8 +26,8 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useControlledState } from "@/hooks/use-controlled-state";
import { useFormState } from "@/hooks/use-form-state";
import { useControlledState } from "@/lib/hooks/use-controlled-state";
import { useFormState } from "@/lib/hooks/use-form-state";
import {
DEFAULT_PAGADOR_AVATAR,
PAGADOR_STATUS_OPTIONS,
@@ -116,46 +110,33 @@ export function PagadorDialog({
}
}, [dialogOpen, initialState, resetForm]);
const handleSubmit = useCallback(
(event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
type PagadorCreatePayload = Parameters<typeof createPagadorAction>[0];
if (mode === "update" && !pagador?.id) {
const message = "Pagador inválido.";
setErrorMessage(message);
toast.error(message);
return;
}
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
setErrorMessage(null);
const pagadorId = pagador?.id;
const payload: {
name: string;
email?: string;
status: PagadorStatus;
avatarUrl: string;
note: string;
isAutoSend: boolean;
} = {
name: formState.name.trim(),
status: formState.status,
avatarUrl: formState.avatarUrl,
note: formState.note.trim(),
isAutoSend: formState.isAutoSend,
};
if (mode === "update" && !pagadorId) {
const message = "Pagador inválido.";
setErrorMessage(message);
toast.error(message);
return;
}
const emailValue = formState.email.trim();
if (emailValue.length > 0) {
payload.email = emailValue;
}
const emailValue = formState.email.trim();
const payload: PagadorCreatePayload = {
name: formState.name.trim(),
status: formState.status,
avatarUrl: formState.avatarUrl,
email: emailValue || null,
note: formState.note.trim() || null,
isAutoSend: formState.isAutoSend,
};
startTransition(async () => {
const result =
mode === "create"
? await createPagadorAction(payload)
: await updatePagadorAction({
id: pagador?.id ?? "",
...payload,
});
startTransition(async () => {
if (mode === "create") {
const result = await createPagadorAction(payload);
if (result.success) {
toast.success(result.message);
@@ -166,10 +147,29 @@ export function PagadorDialog({
setErrorMessage(result.error);
toast.error(result.error);
return;
}
if (!pagadorId) {
return;
}
const result = await updatePagadorAction({
id: pagadorId,
...payload,
});
},
[formState, initialState, mode, pagador?.id, resetForm, setDialogOpen],
);
if (result.success) {
toast.success(result.message);
setDialogOpen(false);
resetForm(initialState);
return;
}
setErrorMessage(result.error);
toast.error(result.error);
});
};
const title = mode === "create" ? "Novo pagador" : "Editar pagador";
const description =

View File

@@ -0,0 +1,66 @@
"use client";
import { createContext, useContext, useEffect, useMemo, useState } from "react";
import { getFontVariable } from "@/public/fonts/font_index";
type FontContextValue = {
systemFont: string;
moneyFont: string;
setSystemFont: (key: string) => void;
setMoneyFont: (key: string) => void;
};
const FontContext = createContext<FontContextValue | null>(null);
export function FontProvider({
systemFont: initialSystemFont,
moneyFont: initialMoneyFont,
children,
}: {
systemFont: string;
moneyFont: string;
children: React.ReactNode;
}) {
const [systemFont, setSystemFontState] = useState(initialSystemFont);
const [moneyFont, setMoneyFontState] = useState(initialMoneyFont);
useEffect(() => {
document.documentElement.style.setProperty(
"--font-app",
getFontVariable(systemFont),
);
document.documentElement.style.setProperty(
"--font-money",
getFontVariable(moneyFont),
);
}, [systemFont, moneyFont]);
const value = useMemo(
() => ({
systemFont,
moneyFont,
setSystemFont: setSystemFontState,
setMoneyFont: setMoneyFontState,
}),
[systemFont, moneyFont],
);
return (
<>
<style
dangerouslySetInnerHTML={{
__html: `:root { --font-app: ${getFontVariable(initialSystemFont)}; --font-money: ${getFontVariable(initialMoneyFont)}; }`,
}}
/>
<FontContext value={value}>{children}</FontContext>
</>
);
}
export function useFont() {
const ctx = useContext(FontContext);
if (!ctx) {
throw new Error("useFont must be used within FontProvider");
}
return ctx;
}

View File

@@ -0,0 +1,3 @@
export { FontProvider } from "./font-provider";
export { PrivacyProvider } from "./privacy-provider";
export { ThemeProvider } from "./theme-provider";

View File

@@ -0,0 +1,91 @@
"use client";
import type React from "react";
import {
createContext,
useCallback,
useContext,
useMemo,
useSyncExternalStore,
} from "react";
interface PrivacyContextType {
privacyMode: boolean;
toggle: () => void;
set: (value: boolean) => void;
}
const PrivacyContext = createContext<PrivacyContextType | undefined>(undefined);
const STORAGE_KEY = "app:privacyMode";
const PRIVACY_MODE_EVENT = "openmonetis:privacy-mode";
// Read from localStorage safely (returns false on server)
function getStoredValue(): boolean {
if (typeof window === "undefined") return false;
return localStorage.getItem(STORAGE_KEY) === "true";
}
function notifyPrivacyModeChange() {
window.dispatchEvent(new Event(PRIVACY_MODE_EVENT));
}
// Subscribe to storage changes
function subscribeToStorage(callback: () => void) {
window.addEventListener("storage", callback);
window.addEventListener(PRIVACY_MODE_EVENT, callback);
return () => {
window.removeEventListener("storage", callback);
window.removeEventListener(PRIVACY_MODE_EVENT, callback);
};
}
export function PrivacyProvider({ children }: { children: React.ReactNode }) {
// useSyncExternalStore handles hydration safely
const privacyMode = useSyncExternalStore(
subscribeToStorage,
getStoredValue,
() => false, // Server snapshot
);
const setPrivacyMode = useCallback((value: boolean) => {
if (typeof window === "undefined") {
return;
}
const nextValue = String(value);
if (localStorage.getItem(STORAGE_KEY) === nextValue) {
return;
}
localStorage.setItem(STORAGE_KEY, nextValue);
notifyPrivacyModeChange();
}, []);
const toggle = useCallback(() => {
setPrivacyMode(!privacyMode);
}, [privacyMode, setPrivacyMode]);
const contextValue = useMemo(
() => ({
privacyMode,
toggle,
set: setPrivacyMode,
}),
[privacyMode, toggle, setPrivacyMode],
);
return (
<PrivacyContext.Provider value={contextValue}>
{children}
</PrivacyContext.Provider>
);
}
export function usePrivacyMode() {
const context = useContext(PrivacyContext);
if (context === undefined) {
throw new Error("usePrivacyMode must be used within a PrivacyProvider");
}
return context;
}

View File

@@ -0,0 +1,11 @@
"use client";
import { ThemeProvider as NextThemesProvider } from "next-themes";
import type * as React from "react";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View File

@@ -0,0 +1,122 @@
"use client";
import { RiMoonClearLine, RiSunLine } from "@remixicon/react";
import { useEffect, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { buttonVariants } from "@/components/ui/button";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils/ui";
interface AnimatedThemeTogglerProps
extends React.ComponentPropsWithoutRef<"button"> {
duration?: number;
}
export const AnimatedThemeToggler = ({
className,
duration = 400,
...props
}: AnimatedThemeTogglerProps) => {
const [isDark, setIsDark] = useState(false);
const buttonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const updateTheme = () => {
setIsDark(document.documentElement.classList.contains("dark"));
};
updateTheme();
const observer = new MutationObserver(updateTheme);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["class"],
});
return () => observer.disconnect();
}, []);
const toggleTheme = async () => {
if (!buttonRef.current) return;
await document.startViewTransition(() => {
flushSync(() => {
const newTheme = !isDark;
setIsDark(newTheme);
document.documentElement.classList.toggle("dark");
localStorage.setItem("theme", newTheme ? "dark" : "light");
});
}).ready;
const { top, left, width, height } =
buttonRef.current.getBoundingClientRect();
const x = left + width / 2;
const y = top + height / 2;
const maxRadius = Math.hypot(
Math.max(left, window.innerWidth - left),
Math.max(top, window.innerHeight - top),
);
document.documentElement.animate(
{
clipPath: [
`circle(0px at ${x}px ${y}px)`,
`circle(${maxRadius}px at ${x}px ${y}px)`,
],
},
{
duration,
easing: "ease-in-out",
pseudoElement: "::view-transition-new(root)",
},
);
};
return (
<Tooltip>
<TooltipTrigger asChild>
<button
ref={buttonRef}
type="button"
onClick={toggleTheme}
data-state={isDark ? "dark" : "light"}
className={cn(
buttonVariants({ variant: "ghost", size: "icon-sm" }),
"group relative text-muted-foreground transition-all duration-200",
"hover:text-foreground focus-visible:ring-2 focus-visible:ring-primary/40",
"data-[state=open]:bg-accent/60 data-[state=open]:text-foreground border",
className,
)}
{...props}
>
<span
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 opacity-0 transition-opacity duration-200 data-[state=dark]:opacity-100"
>
<span className="absolute inset-0 bg-linear-to-br from-amber-500/5 via-transparent to-amber-500/15 dark:from-amber-500/10 dark:to-amber-500/30" />
</span>
{isDark ? (
<RiSunLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
) : (
<RiMoonClearLine
className="size-4 transition-transform duration-200"
aria-hidden
/>
)}
<span className="sr-only">
{isDark ? "Ativar tema claro" : "Ativar tema escuro"}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={8}>
{isDark ? "Tema claro" : "Tema escuro"}
</TooltipContent>
</Tooltip>
);
};

View File

@@ -0,0 +1,103 @@
"use client";
import type { VariantProps } from "class-variance-authority";
import { useState, useTransition } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { buttonVariants } from "@/components/ui/button";
interface ConfirmActionDialogProps {
trigger?: React.ReactNode;
title: string;
description?: string;
confirmLabel?: string;
cancelLabel?: string;
pendingLabel?: string;
confirmVariant?: VariantProps<typeof buttonVariants>["variant"];
open?: boolean;
onOpenChange?: (open: boolean) => void;
onConfirm?: () => Promise<void> | void;
disabled?: boolean;
className?: string;
}
export function ConfirmActionDialog({
trigger,
title,
description,
confirmLabel = "Confirmar",
cancelLabel = "Cancelar",
pendingLabel,
confirmVariant = "default",
open,
onOpenChange,
onConfirm,
disabled = false,
className,
}: ConfirmActionDialogProps) {
const [internalOpen, setInternalOpen] = useState(false);
const [isPending, startTransition] = useTransition();
const dialogOpen = open ?? internalOpen;
const setDialogOpen = (value: boolean) => {
if (open === undefined) {
setInternalOpen(value);
}
onOpenChange?.(value);
};
const resolvedPendingLabel = pendingLabel ?? confirmLabel;
const handleConfirm = () => {
if (!onConfirm) {
setDialogOpen(false);
return;
}
startTransition(async () => {
try {
await onConfirm();
setDialogOpen(false);
} catch {
// Mantém o diálogo aberto para que o chamador trate o erro.
}
});
};
return (
<AlertDialog open={dialogOpen} onOpenChange={setDialogOpen}>
{trigger ? (
<AlertDialogTrigger asChild>{trigger}</AlertDialogTrigger>
) : null}
<AlertDialogContent className={className}>
<AlertDialogHeader>
<AlertDialogTitle>{title}</AlertDialogTitle>
{description ? (
<AlertDialogDescription>{description}</AlertDialogDescription>
) : null}
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isPending || disabled}>
{cancelLabel}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirm}
disabled={isPending || disabled}
className={buttonVariants({ variant: confirmVariant })}
>
{isPending ? resolvedPendingLabel : confirmLabel}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

View File

@@ -0,0 +1,103 @@
"use client";
import { RiExpandDiagonalLine } from "@remixicon/react";
import { useEffect, useRef, useState } from "react";
import type { WidgetCardProps } from "@/components/shared/widget-card";
import WidgetCard from "@/components/shared/widget-card";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
const OVERFLOW_THRESHOLD_PX = 16;
const EXPANDABLE_CONTENT_CLASSNAME =
"max-h-[calc(var(--spacing-custom-height-card)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]";
export function ExpandableWidgetCard({
title,
subtitle,
icon,
children,
action,
}: WidgetCardProps) {
const contentRef = useRef<HTMLDivElement | null>(null);
const [hasOverflow, setHasOverflow] = useState(false);
const [isOpen, setIsOpen] = useState(false);
useEffect(() => {
const element = contentRef.current;
if (!element) return;
let frameId = 0;
const checkOverflow = () => {
cancelAnimationFrame(frameId);
frameId = window.requestAnimationFrame(() => {
const hasOverflowNow =
element.scrollHeight - element.clientHeight > OVERFLOW_THRESHOLD_PX;
setHasOverflow((currentValue) =>
currentValue === hasOverflowNow ? currentValue : hasOverflowNow,
);
});
};
checkOverflow();
const resizeObserver = new ResizeObserver(checkOverflow);
resizeObserver.observe(element);
return () => {
cancelAnimationFrame(frameId);
resizeObserver.disconnect();
};
}, []);
return (
<>
<WidgetCard
title={title}
subtitle={subtitle}
icon={icon}
action={action}
contentRef={contentRef}
contentClassName={EXPANDABLE_CONTENT_CLASSNAME}
overlay={
hasOverflow ? (
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex justify-center bg-linear-to-t from-card to-transparent pt-12 pb-6">
<Button
variant="secondary"
className="pointer-events-auto rounded-full text-xs"
onClick={() => setIsOpen(true)}
aria-label="Expandir para ver todo o conteúdo"
>
Ver tudo <RiExpandDiagonalLine size={10} aria-hidden="true" />
</Button>
</div>
) : null
}
>
{children}
</WidgetCard>
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent className="max-h-[85vh] w-full max-w-[calc(100%-2rem)] sm:max-w-3xl overflow-hidden p-6">
<DialogHeader className="text-left">
<DialogTitle className="flex items-center gap-2">
{icon}
<span>{title}</span>
</DialogTitle>
{subtitle ? (
<p className="text-muted-foreground text-sm">{subtitle}</p>
) : null}
</DialogHeader>
<div className="scrollbar-hide max-h-[calc(85vh-6rem)] overflow-y-auto pb-6">
{children}
</div>
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,82 @@
import Image from "next/image";
import { cn } from "@/lib/utils/ui";
import { version } from "@/package.json";
interface LogoProps {
variant?: "full" | "small" | "compact";
className?: string;
showVersion?: boolean;
invertTextOnDark?: boolean;
}
export function Logo({
variant = "full",
className,
showVersion = false,
invertTextOnDark = true,
}: LogoProps) {
if (variant === "compact") {
return (
<div className={cn("flex items-center gap-1", className)}>
<Image
src="/imagens/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className="object-contain brightness-0 saturate-0"
priority
/>
<Image
src="/imagens/logo_text.png"
alt="OpenMonetis"
width={110}
height={32}
className={cn(
"hidden object-contain sm:block",
invertTextOnDark && "dark:invert",
)}
priority
/>
</div>
);
}
if (variant === "small") {
return (
<Image
src="/imagens/logo_small.png"
alt="OpenMonetis"
width={32}
height={32}
className={cn("object-contain", className)}
priority
/>
);
}
return (
<div className={cn("flex items-center gap-1.5 py-4", className)}>
<Image
src="/imagens/logo_small.png"
alt="OpenMonetis"
width={28}
height={28}
className="object-contain"
priority
/>
<Image
src="/imagens/logo_text.png"
alt="OpenMonetis"
width={100}
height={32}
className={cn("object-contain", invertTextOnDark && "dark:invert")}
priority
/>
{showVersion && (
<span className="text-[9px] font-medium text-muted-foreground">
{version}
</span>
)}
</div>
);
}

View File

@@ -0,0 +1,40 @@
"use client";
import { usePrivacyMode } from "@/components/providers/privacy-provider";
import { formatCurrency } from "@/lib/utils/currency";
import { cn } from "@/lib/utils/ui";
type Props = {
amount: number;
className?: string;
showPositiveSign?: boolean;
};
function MoneyValues({ amount, className, showPositiveSign = false }: Props) {
const { privacyMode } = usePrivacyMode();
const formattedValue = formatCurrency(amount);
const displayValue =
showPositiveSign && amount > 0 ? `+${formattedValue}` : formattedValue;
return (
<span
style={{ fontFamily: "var(--font-money)" }}
className={cn(
"inline-flex items-baseline transition-all duration-200 tracking-tighter",
privacyMode &&
"blur-[6px] select-none hover:blur-none focus-within:blur-none",
className,
)}
aria-label={privacyMode ? "Valor oculto" : displayValue}
data-privacy={privacyMode ? "hidden" : undefined}
title={
privacyMode ? "Valor oculto - passe o mouse para revelar" : undefined
}
>
{displayValue}
</span>
);
}
export default MoneyValues;

View File

@@ -9,7 +9,7 @@ export default function PageDescription({
}) {
return (
<div>
<h1 className="text-2xl font-semibold flex items-center gap-1">
<h1 className="text-2xl font-semibold flex items-center gap-1 font-[aeonik] tracking-tighter">
<span className="text-primary">{icon}</span>
{title}
</h1>

View File

@@ -0,0 +1,71 @@
"use client";
import { RiCalendarLine } from "@remixicon/react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { MonthPicker } from "@/components/ui/month-picker";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
dateToPeriod,
formatMonthYearLabel,
periodToDate,
} from "@/lib/utils/period";
import { cn } from "@/lib/utils/ui";
interface PeriodPickerProps {
value: string; // "YYYY-MM" format
onChange: (value: string) => void;
disabled?: boolean;
className?: string;
placeholder?: string;
variant?: "default" | "outline" | "ghost";
size?: "default" | "sm" | "lg";
}
export function PeriodPicker({
value,
onChange,
disabled = false,
className,
placeholder = "Selecione o período",
variant = "outline",
size = "default",
}: PeriodPickerProps) {
const [open, setOpen] = useState(false);
const handleSelect = (date: Date) => {
const period = dateToPeriod(date);
onChange(period);
setOpen(false);
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant={variant}
size={size}
disabled={disabled}
className={cn(
"justify-start text-left font-normal capitalize",
!value && "text-muted-foreground",
className,
)}
>
<RiCalendarLine className="h-4 w-4" />
{value ? formatMonthYearLabel(value) : placeholder}
</Button>
</PopoverTrigger>
<PopoverContent className="w-auto p-0" align="start">
<MonthPicker
selectedMonth={value ? periodToDate(value) : new Date()}
onMonthSelect={handleSelect}
/>
</PopoverContent>
</Popover>
);
}

View File

@@ -1,4 +1,4 @@
import { SectionCardsSkeleton } from "./section-cards-skeleton";
import { DashboardMetricsCardsSkeleton } from "./dashboard-metrics-cards-skeleton";
import { WidgetSkeleton } from "./widget-skeleton";
/**
@@ -8,8 +8,8 @@ import { WidgetSkeleton } from "./widget-skeleton";
export function DashboardGridSkeleton() {
return (
<div className="@container/main space-y-4">
{/* Section Cards no topo */}
<SectionCardsSkeleton />
{/* Cards de métricas no topo */}
<DashboardMetricsCardsSkeleton />
{/* Grid de widgets - mesmos breakpoints do dashboard real */}
<div className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">

View File

@@ -0,0 +1,37 @@
import { Card, CardFooter, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
/**
* Skeleton fiel aos cards de métricas do dashboard (DashboardMetricsCards)
* Mantém o mesmo layout de 4 colunas responsivo
*/
export function DashboardMetricsCardsSkeleton() {
return (
<div className="*:data-[slot=card]:from-primary/5 *:data-[slot=card]:to-card dark:*:data-[slot=card]:bg-card grid grid-cols-1 gap-3 @xl/main:grid-cols-2 @5xl/main:grid-cols-4">
{Array.from({ length: 4 }).map((_, index) => (
<Card key={index} className="@container/card gap-2">
<CardHeader>
<div className="space-y-3">
{/* Título com ícone */}
<div className="flex items-center gap-1">
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-20 rounded-2xl bg-foreground/10" />
</div>
{/* Valor principal */}
<Skeleton className="h-8 w-32 rounded-2xl bg-foreground/10" />
{/* Badge de tendência */}
<Skeleton className="h-6 w-16 rounded-2xl bg-foreground/10" />
</div>
</CardHeader>
<CardFooter className="flex-col items-start gap-1.5 text-sm">
<Skeleton className="h-4 w-24 rounded-2xl bg-foreground/10" />
<Skeleton className="h-4 w-20 rounded-2xl bg-foreground/10" />
</CardFooter>
</Card>
))}
</div>
);
}

View File

@@ -5,8 +5,8 @@
export { AccountStatementCardSkeleton } from "./account-statement-card-skeleton";
export { CategoryReportSkeleton } from "./category-report-skeleton";
export { DashboardGridSkeleton } from "./dashboard-grid-skeleton";
export { DashboardMetricsCardsSkeleton } from "./dashboard-metrics-cards-skeleton";
export { FilterSkeleton } from "./filter-skeleton";
export { InvoiceSummaryCardSkeleton } from "./invoice-summary-card-skeleton";
export { SectionCardsSkeleton } from "./section-cards-skeleton";
export { TransactionsTableSkeleton } from "./transactions-table-skeleton";
export { WidgetSkeleton } from "./widget-skeleton";

View File

@@ -7,23 +7,23 @@ import { Skeleton } from "@/components/ui/skeleton";
*/
export function WidgetSkeleton() {
return (
<Card className="relative h-auto md:h-custom-height-1 md:overflow-hidden">
<CardHeader className="border-b [.border-b]:pb-2">
<Card className="relative h-auto gap-0 py-0 md:h-custom-height-card md:overflow-hidden">
<CardHeader className="border-b px-6 py-4">
<div className="flex w-full items-start justify-between">
<div className="space-y-2">
<div className="min-w-0 space-y-1.5">
{/* Title com ícone */}
<div className="flex items-center gap-1">
<div className="flex items-center gap-2">
<Skeleton className="size-4 rounded-2xl bg-foreground/10" />
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
</div>
{/* Subtitle */}
<Skeleton className="h-4 w-48 rounded-2xl bg-foreground/10" />
<Skeleton className="h-3 w-48 rounded-2xl bg-foreground/10" />
</div>
</div>
</CardHeader>
<CardContent className="max-h-[calc(var(--spacing-custom-height-1)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]">
<div className="flex flex-col gap-3 py-4">
<CardContent className="min-h-0 flex-1 overflow-hidden px-6 py-4">
<div className="flex flex-col gap-3">
{/* Simula 5 linhas de conteúdo */}
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center justify-between gap-3">

View File

@@ -0,0 +1,19 @@
import { cn } from "@/lib/utils";
type StatusDotProps = {
color: string;
className?: string;
};
export default function StatusDot({ color, className }: StatusDotProps) {
return (
<span
className={cn(
"inline-block size-2 shrink-0 rounded-full",
color,
className,
)}
aria-hidden="true"
/>
);
}

View File

@@ -0,0 +1,63 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils/ui";
import StatusDot from "./status-dot";
type TypeBadgeType =
| "receita"
| "despesa"
| "Receita"
| "Despesa"
| "Transferência"
| "transferência"
| "Saldo inicial"
| "Saldo Inicial";
interface TypeBadgeProps {
type: TypeBadgeType | string;
className?: string;
}
const TYPE_LABELS: Record<string, string> = {
receita: "Receita",
despesa: "Despesa",
Receita: "Receita",
Despesa: "Despesa",
Transferência: "Transferência",
transferência: "Transferência",
"Saldo inicial": "Saldo Inicial",
"Saldo Inicial": "Saldo Inicial",
};
export function TypeBadge({ type, className }: TypeBadgeProps) {
const normalizedType = type.toLowerCase();
const isReceita = normalizedType === "receita";
const isTransferencia = normalizedType === "transferência";
const isSaldoInicial = normalizedType === "saldo inicial";
const label = TYPE_LABELS[type] || type;
const colorClass = isTransferencia
? "text-info"
: isReceita || isSaldoInicial
? "text-success"
: "text-destructive";
const dotColor = isTransferencia
? "bg-info"
: isReceita || isSaldoInicial
? "bg-success"
: "bg-destructive";
return (
<Badge
variant={"outline"}
className={cn(
"flex items-center gap-1 px-2 text-xs",
colorClass,
className,
)}
>
<StatusDot color={dotColor} />
{label}
</Badge>
);
}

View File

@@ -0,0 +1,60 @@
import type * as React from "react";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
import { Separator } from "@/components/ui/separator";
export type WidgetCardProps = {
title: string;
subtitle: string;
children: React.ReactNode;
icon: React.ReactNode;
action?: React.ReactNode;
};
type WidgetCardShellProps = WidgetCardProps & {
contentClassName?: string;
contentRef?: React.Ref<HTMLDivElement>;
overlay?: React.ReactNode;
};
export default function WidgetCard({
title,
subtitle,
icon,
children,
action,
contentClassName,
contentRef,
overlay,
}: WidgetCardShellProps) {
return (
<Card className="relative gap-2 overflow-hidden md:h-custom-height-card">
<CardHeader>
<div className="flex w-full items-start justify-between">
<div>
<CardTitle className="flex items-center gap-1 font-[aeonik] tracking-tighter lowercase">
<span className="size-4">{icon}</span>
{title}
</CardTitle>
<CardDescription className="text-muted-foreground text-sm lowercase mt-2">
{subtitle}
</CardDescription>
</div>
{action && <div className="shrink-0">{action}</div>}
</div>
<Separator className="mt-1" />
</CardHeader>
<CardContent ref={contentRef} className={contentClassName}>
{children}
</CardContent>
{overlay}
</Card>
);
}

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from "react";
import {
Empty,
EmptyDescription,
EmptyHeader,
EmptyMedia,
EmptyTitle,
} from "@/components/ui/empty";
type WidgetEmptyStateProps = {
icon?: ReactNode;
title: string;
description?: string;
};
export function WidgetEmptyState({
icon,
title,
description,
}: WidgetEmptyStateProps) {
return (
<Empty>
<EmptyHeader>
<EmptyMedia>{icon}</EmptyMedia>
<EmptyTitle>{title}</EmptyTitle>
<EmptyDescription>{description}</EmptyDescription>
</EmptyHeader>
</Empty>
);
}

View File

@@ -9,7 +9,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
default: "bg-primary hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:

View File

@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 border-transparent border drop-shadow-xs py-6 rounded-md hover:border-primary/50 transition-all ease-in-out duration-300",
"bg-card text-card-foreground flex flex-col gap-6 border py-6 rounded-md hover:border-primary/50 transition-all ease-in-out duration-300",
className,
)}
{...props}

View File

@@ -1,6 +1,7 @@
"use client";
import { RiCalendarLine } from "@remixicon/react";
import { ptBR } from "date-fns/locale";
import * as React from "react";
import { Button } from "@/components/ui/button";
@@ -11,6 +12,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { parseLocalDateString, toLocalDateString } from "@/lib/utils/date";
import { cn } from "@/lib/utils/ui";
function formatDate(date: Date | undefined, compact = false): string {
@@ -43,13 +45,7 @@ function isValidDate(date: Date | undefined): boolean {
}
function dateToYYYYMMDD(date: Date | undefined): string {
if (!date || !isValidDate(date)) {
return "";
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}-${month}-${day}`;
return isValidDate(date) ? (toLocalDateString(date) ?? "") : "";
}
function parseYYYYMMDD(dateString: string): Date | undefined {
@@ -62,8 +58,7 @@ function parseYYYYMMDD(dateString: string): Date | undefined {
// which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
const ymdMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
if (ymdMatch) {
const [, year, month, day] = ymdMatch;
const date = new Date(Number(year), Number(month) - 1, Number(day));
const date = parseLocalDateString(dateString);
return isValidDate(date) ? date : undefined;
}
@@ -179,29 +174,7 @@ export function DatePicker({
onSelect={handleCalendarSelect}
fromYear={2020}
toYear={new Date().getFullYear() + 10}
locale={{
localize: {
day: (n) => ["D", "S", "T", "Q", "Q", "S", "S"][n],
month: (n) =>
[
"Jan",
"Fev",
"Mar",
"Abr",
"Mai",
"Jun",
"Jul",
"Ago",
"Set",
"Out",
"Nov",
"Dez",
][n],
},
formatLong: {
date: () => "dd/MM/yyyy",
},
}}
locale={ptBR}
/>
</PopoverContent>
</Popover>

View File

@@ -74,7 +74,7 @@ function NavigationMenuTrigger({
>
{children}{" "}
<RiArrowDropDownLine
className="relative top-px ml-1 size-4 text-primary transition duration-300 group-data-[state=open]:rotate-180"
className="relative top-px size-5 opacity-70 transition duration-300 group-data-[state=open]:rotate-180"
aria-hidden="true"
/>
</NavigationMenuPrimitive.Trigger>
@@ -90,7 +90,7 @@ function NavigationMenuContent({
data-slot="navigation-menu-content"
className={cn(
"data-[motion^=from-]:animate-in data-[motion^=to-]:animate-out data-[motion^=from-]:fade-in data-[motion^=to-]:fade-out data-[motion=from-end]:slide-in-from-right-52 data-[motion=from-start]:slide-in-from-left-52 data-[motion=to-end]:slide-out-to-right-52 data-[motion=to-start]:slide-out-to-left-52 top-0 left-0 w-full p-2 pr-2.5 md:absolute md:w-auto",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
"group-data-[viewport=false]/navigation-menu:bg-popover group-data-[viewport=false]/navigation-menu:text-popover-foreground group-data-[viewport=false]/navigation-menu:data-[state=open]:animate-in group-data-[viewport=false]/navigation-menu:data-[state=closed]:animate-out group-data-[viewport=false]/navigation-menu:data-[state=closed]:zoom-out-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:zoom-in-95 group-data-[viewport=false]/navigation-menu:data-[state=open]:fade-in-0 group-data-[viewport=false]/navigation-menu:data-[state=closed]:fade-out-0 group-data-[viewport=false]/navigation-menu:top-full group-data-[viewport=false]/navigation-menu:mt-1.5 group-data-[viewport=false]/navigation-menu:overflow-hidden group-data-[viewport=false]/navigation-menu:rounded-md group-data-[viewport=false]/navigation-menu:border group-data-[viewport=false]/navigation-menu:shadow-none group-data-[viewport=false]/navigation-menu:duration-200 **:data-[slot=navigation-menu-link]:focus:ring-0 **:data-[slot=navigation-menu-link]:focus:outline-none",
className,
)}
{...props}

View File

@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
<th
data-slot="table-head"
className={cn(
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"text-foreground h-10 px-2 text-left align-middle font-medium whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
<td
data-slot="table-cell"
className={cn(
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
"p-2 align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 *:[[role=checkbox]]:translate-y-[2px]",
className,
)}
{...props}

View File

@@ -1,26 +1 @@
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);
}
export { useIsMobile, useMobile } from "@/lib/hooks/use-mobile";