mirror of
https://github.com/felipegcoutinho/openmonetis.git
synced 2026-05-09 02:51:46 +00:00
refactor(core): centraliza hooks, providers e base compartilhada
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
import { FontProvider } from "@/components/font-provider";
|
|
||||||
import { AppNavbar } from "@/components/navigation/navbar/app-navbar";
|
import { AppNavbar } from "@/components/navigation/navbar/app-navbar";
|
||||||
import { PrivacyProvider } from "@/components/privacy-provider";
|
import { FontProvider } from "@/components/providers/font-provider";
|
||||||
|
import { PrivacyProvider } from "@/components/providers/privacy-provider";
|
||||||
import { getUserSession } from "@/lib/auth/server";
|
import { getUserSession } from "@/lib/auth/server";
|
||||||
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
|
import { fetchDashboardNotifications } from "@/lib/dashboard/notifications";
|
||||||
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
import { fetchPagadoresWithAccess } from "@/lib/pagadores/access";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Analytics } from "@vercel/analytics/next";
|
import { Analytics } from "@vercel/analytics/next";
|
||||||
import { SpeedInsights } from "@vercel/speed-insights/next";
|
import { SpeedInsights } from "@vercel/speed-insights/next";
|
||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import { ThemeProvider } from "@/components/theme-provider";
|
import { ThemeProvider } from "@/components/providers/theme-provider";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
import { allFontVariables } from "@/public/fonts/font_index";
|
import { allFontVariables } from "@/public/fonts/font_index";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
"utils": "@/lib/utils",
|
"utils": "@/lib/utils",
|
||||||
"ui": "@/components/ui",
|
"ui": "@/components/ui",
|
||||||
"lib": "@/lib",
|
"lib": "@/lib",
|
||||||
"hooks": "@/hooks"
|
"hooks": "@/lib/hooks"
|
||||||
},
|
},
|
||||||
"registries": {
|
"registries": {
|
||||||
"@coss": "https://coss.com/ui/r/{name}.json",
|
"@coss": "https://coss.com/ui/r/{name}.json",
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/lib/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/lib/hooks/use-form-state";
|
||||||
import {
|
import {
|
||||||
type Note,
|
type Note,
|
||||||
type NoteFormValues,
|
type NoteFormValues,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@/components/ui/tooltip";
|
} from "@/components/ui/tooltip";
|
||||||
import { cn } from "@/lib/utils/ui";
|
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 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 "@/lib/calculadora/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 "@/lib/calculadora/use-calculator-keyboard";
|
||||||
|
import { useCalculatorState } from "@/lib/calculadora/use-calculator-state";
|
||||||
import { CalculatorDisplay } from "./calculator-display";
|
import { CalculatorDisplay } from "./calculator-display";
|
||||||
|
|
||||||
type CalculatorProps = {
|
type CalculatorProps = {
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useTransition,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createCardAction,
|
createCardAction,
|
||||||
@@ -24,12 +18,15 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import {
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
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 { 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 { CardFormFields } from "./card-form-fields";
|
||||||
import { DEFAULT_CARD_BRANDS, DEFAULT_CARD_STATUS } from "./constants";
|
|
||||||
import type { Card, CardFormValues } from "./types";
|
import type { Card, CardFormValues } from "./types";
|
||||||
|
|
||||||
type AccountOption = {
|
type AccountOption = {
|
||||||
@@ -133,56 +130,66 @@ export function CardDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
type CardCreatePayload = Parameters<typeof createCardAction>[0];
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
if (mode === "update" && !card?.id) {
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
const message = "Cartão inválido.";
|
event.preventDefault();
|
||||||
setErrorMessage(message);
|
setErrorMessage(null);
|
||||||
toast.error(message);
|
|
||||||
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!formState.contaId) {
|
setErrorMessage(result.error);
|
||||||
const message = "Selecione a conta vinculada.";
|
toast.error(result.error);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
|
const title = mode === "create" ? "Novo cartão" : "Editar cartão";
|
||||||
const description =
|
const description =
|
||||||
@@ -191,15 +198,12 @@ export function CardDialog({
|
|||||||
: "Atualize as informações do cartão selecionado.";
|
: "Atualize as informações do cartão selecionado.";
|
||||||
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
const submitLabel = mode === "create" ? "Salvar cartão" : "Atualizar cartão";
|
||||||
|
|
||||||
const handleMainDialogOpenChange = useCallback(
|
const handleMainDialogOpenChange = (open: boolean) => {
|
||||||
(open: boolean) => {
|
if (!open && logoDialogOpen) {
|
||||||
if (!open && logoDialogOpen) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
setDialogOpen(open);
|
||||||
setDialogOpen(open);
|
};
|
||||||
},
|
|
||||||
[logoDialogOpen, setDialogOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createCategoryAction,
|
createCategoryAction,
|
||||||
@@ -16,10 +16,10 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} 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 { CATEGORY_TYPES } from "@/lib/categorias/constants";
|
||||||
import { getDefaultIconForType } from "@/lib/categorias/icons";
|
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 { CategoryFormFields } from "./category-form-fields";
|
||||||
import type { Category, CategoryFormValues } from "./types";
|
import type { Category, CategoryFormValues } from "./types";
|
||||||
@@ -41,7 +41,7 @@ const buildInitialValues = ({
|
|||||||
defaultType?: CategoryFormValues["type"];
|
defaultType?: CategoryFormValues["type"];
|
||||||
}): CategoryFormValues => {
|
}): CategoryFormValues => {
|
||||||
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
|
const initialType = category?.type ?? defaultType ?? CATEGORY_TYPES[0];
|
||||||
const fallbackIcon = getDefaultIconForType(initialType);
|
const fallbackIcon = getDefaultIconForType();
|
||||||
const existingIcon = category?.icon ?? "";
|
const existingIcon = category?.icon ?? "";
|
||||||
const icon = existingIcon || fallbackIcon;
|
const icon = existingIcon || fallbackIcon;
|
||||||
|
|
||||||
@@ -70,10 +70,14 @@ export function CategoryDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialState = buildInitialValues({
|
const initialState = useMemo(
|
||||||
category,
|
() =>
|
||||||
defaultType,
|
buildInitialValues({
|
||||||
});
|
category,
|
||||||
|
defaultType,
|
||||||
|
}),
|
||||||
|
[category, defaultType],
|
||||||
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, resetForm, updateField } =
|
const { formState, resetForm, updateField } =
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useTransition,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createAccountAction,
|
createAccountAction,
|
||||||
@@ -24,10 +18,13 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/lib/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/lib/hooks/use-form-state";
|
||||||
import { deriveNameFromLogo, normalizeLogo } from "@/lib/logo";
|
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 { AccountFormFields } from "./account-form-fields";
|
||||||
import type { Account, AccountFormValues } from "./types";
|
import type { Account, AccountFormValues } from "./types";
|
||||||
@@ -145,6 +142,8 @@ export function AccountDialog({
|
|||||||
}
|
}
|
||||||
}, [dialogOpen]);
|
}, [dialogOpen]);
|
||||||
|
|
||||||
|
type AccountCreatePayload = Parameters<typeof createAccountAction>[0];
|
||||||
|
|
||||||
// Use logo selection hook
|
// Use logo selection hook
|
||||||
const handleLogoSelection = useLogoSelection({
|
const handleLogoSelection = useLogoSelection({
|
||||||
mode,
|
mode,
|
||||||
@@ -159,33 +158,38 @@ export function AccountDialog({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
setErrorMessage(null);
|
||||||
setErrorMessage(null);
|
const accountId = account?.id;
|
||||||
|
|
||||||
if (mode === "update" && !account?.id) {
|
if (mode === "update" && !accountId) {
|
||||||
const message = "Conta inválida.";
|
const message = "Conta inválida.";
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
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) {
|
if (!payload.logo) {
|
||||||
setErrorMessage("Selecione um logo.");
|
setErrorMessage("Selecione um logo.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result =
|
if (mode === "create") {
|
||||||
mode === "create"
|
const result = await createAccountAction(payload);
|
||||||
? await createAccountAction(payload)
|
|
||||||
: await updateAccountAction({
|
|
||||||
id: account?.id ?? "",
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
@@ -196,10 +200,29 @@ export function AccountDialog({
|
|||||||
|
|
||||||
setErrorMessage(result.error);
|
setErrorMessage(result.error);
|
||||||
toast.error(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 title = mode === "create" ? "Nova conta" : "Editar conta";
|
||||||
const description =
|
const description =
|
||||||
@@ -208,15 +231,12 @@ export function AccountDialog({
|
|||||||
: "Atualize as informações da conta selecionada.";
|
: "Atualize as informações da conta selecionada.";
|
||||||
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
|
const submitLabel = mode === "create" ? "Salvar conta" : "Atualizar conta";
|
||||||
|
|
||||||
const handleMainDialogOpenChange = useCallback(
|
const handleMainDialogOpenChange = (open: boolean) => {
|
||||||
(open: boolean) => {
|
if (!open && logoDialogOpen) {
|
||||||
if (!open && logoDialogOpen) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
setDialogOpen(open);
|
||||||
setDialogOpen(open);
|
};
|
||||||
},
|
|
||||||
[logoDialogOpen, setDialogOpen],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { toast } from "sonner";
|
|||||||
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
|
import { transferBetweenAccountsAction } from "@/app/(dashboard)/contas/actions";
|
||||||
import type { AccountData } from "@/app/(dashboard)/contas/data";
|
import type { AccountData } from "@/app/(dashboard)/contas/data";
|
||||||
import { ContaCartaoSelectContent } from "@/components/lancamentos/select-items";
|
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 { Button } from "@/components/ui/button";
|
||||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||||
import { DatePicker } from "@/components/ui/date-picker";
|
import { DatePicker } from "@/components/ui/date-picker";
|
||||||
@@ -26,7 +26,7 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} 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";
|
import { getTodayDateString } from "@/lib/utils/date";
|
||||||
|
|
||||||
interface TransferDialogProps {
|
interface TransferDialogProps {
|
||||||
|
|||||||
@@ -1,21 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiLoader4Line } from "@remixicon/react";
|
import { RiLoader4Line } from "@remixicon/react";
|
||||||
import {
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useTransition,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createInstallmentAnticipationAction,
|
createInstallmentAnticipationAction,
|
||||||
getEligibleInstallmentsAction,
|
getEligibleInstallmentsAction,
|
||||||
} from "@/app/(dashboard)/lancamentos/anticipation-actions";
|
} from "@/app/(dashboard)/lancamentos/anticipation-actions";
|
||||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
import { CategoryIcon } from "@/components/categorias/category-icon";
|
||||||
import MoneyValues from "@/components/money-values";
|
import MoneyValues from "@/components/shared/money-values";
|
||||||
import { PeriodPicker } from "@/components/period-picker";
|
import { PeriodPicker } from "@/components/shared/period-picker";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { CurrencyInput } from "@/components/ui/currency-input";
|
import { CurrencyInput } from "@/components/ui/currency-input";
|
||||||
import {
|
import {
|
||||||
@@ -42,8 +36,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/lib/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/lib/hooks/use-form-state";
|
||||||
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
|
import type { EligibleInstallment } from "@/lib/installments/anticipation-types";
|
||||||
import { InstallmentSelectionTable } from "./installment-selection-table";
|
import { InstallmentSelectionTable } from "./installment-selection-table";
|
||||||
|
|
||||||
@@ -155,61 +149,58 @@ export function AnticipateInstallmentsDialog({
|
|||||||
return totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
|
return totalAmount < 0 ? totalAmount + discount : totalAmount - discount;
|
||||||
}, [totalAmount, formState.discount]);
|
}, [totalAmount, formState.discount]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
event.preventDefault();
|
||||||
event.preventDefault();
|
setErrorMessage(null);
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
if (selectedIds.length === 0) {
|
if (selectedIds.length === 0) {
|
||||||
const message = "Selecione pelo menos uma parcela para antecipar.";
|
const message = "Selecione pelo menos uma parcela para antecipar.";
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formState.anticipationPeriod.length === 0) {
|
if (formState.anticipationPeriod.length === 0) {
|
||||||
const message = "Informe o período da antecipação.";
|
const message = "Informe o período da antecipação.";
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const discount = Number(formState.discount) || 0;
|
const discount = Number(formState.discount) || 0;
|
||||||
if (discount > Math.abs(totalAmount)) {
|
if (discount > Math.abs(totalAmount)) {
|
||||||
const message =
|
const message =
|
||||||
"O desconto não pode ser maior que o valor total das parcelas.";
|
"O desconto não pode ser maior que o valor total das parcelas.";
|
||||||
setErrorMessage(message);
|
setErrorMessage(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result = await createInstallmentAnticipationAction({
|
const result = await createInstallmentAnticipationAction({
|
||||||
seriesId,
|
seriesId,
|
||||||
installmentIds: selectedIds,
|
installmentIds: selectedIds,
|
||||||
anticipationPeriod: formState.anticipationPeriod,
|
anticipationPeriod: formState.anticipationPeriod,
|
||||||
discount: Number(formState.discount) || 0,
|
discount: Number(formState.discount) || 0,
|
||||||
pagadorId: formState.pagadorId || undefined,
|
pagadorId: formState.pagadorId || undefined,
|
||||||
categoriaId: formState.categoriaId || undefined,
|
categoriaId: formState.categoriaId || undefined,
|
||||||
note: formState.note || 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);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
|
||||||
[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(false);
|
||||||
}, [setDialogOpen]);
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
EmptyMedia,
|
EmptyMedia,
|
||||||
EmptyTitle,
|
EmptyTitle,
|
||||||
} from "@/components/ui/empty";
|
} 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 type { InstallmentAnticipationWithRelations } from "@/lib/installments/anticipation-types";
|
||||||
import { AnticipationCard } from "../../shared/anticipation-card";
|
import { AnticipationCard } from "../../shared/anticipation-card";
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import { RiAddLine } from "@remixicon/react";
|
import { RiAddLine } from "@remixicon/react";
|
||||||
import {
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useTransition,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createLancamentoAction,
|
createLancamentoAction,
|
||||||
@@ -27,7 +21,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogTrigger,
|
DialogTrigger,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/lib/hooks/use-controlled-state";
|
||||||
import {
|
import {
|
||||||
filterSecondaryPagadorOptions,
|
filterSecondaryPagadorOptions,
|
||||||
groupAndSortCategorias,
|
groupAndSortCategorias,
|
||||||
@@ -165,203 +159,160 @@ export function LancamentoDialog({
|
|||||||
return groupAndSortCategorias(filtered);
|
return groupAndSortCategorias(filtered);
|
||||||
}, [categoriaOptions, formState.transactionType]);
|
}, [categoriaOptions, formState.transactionType]);
|
||||||
|
|
||||||
|
type CreateLancamentoInput = Parameters<typeof createLancamentoAction>[0];
|
||||||
|
type UpdateLancamentoInput = Parameters<typeof updateLancamentoAction>[0];
|
||||||
|
|
||||||
const totalAmount = useMemo(() => {
|
const totalAmount = useMemo(() => {
|
||||||
const parsed = Number.parseFloat(formState.amount);
|
const parsed = Number.parseFloat(formState.amount);
|
||||||
return Number.isNaN(parsed) ? 0 : Math.abs(parsed);
|
return Number.isNaN(parsed) ? 0 : Math.abs(parsed);
|
||||||
}, [formState.amount]);
|
}, [formState.amount]);
|
||||||
|
|
||||||
const getCardInfo = useCallback(
|
function getCardInfo(cartaoId: string | undefined) {
|
||||||
(cartaoId: string | undefined) => {
|
if (!cartaoId) return null;
|
||||||
if (!cartaoId) return null;
|
const card = cartaoOptions.find((opt) => opt.value === cartaoId);
|
||||||
const card = cartaoOptions.find((opt) => opt.value === cartaoId);
|
if (!card) return null;
|
||||||
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 {
|
return {
|
||||||
closingDay: card.closingDay ?? null,
|
...prev,
|
||||||
dueDay: card.dueDay ?? null,
|
[key]: value,
|
||||||
|
...dependencies,
|
||||||
};
|
};
|
||||||
},
|
});
|
||||||
[cartaoOptions],
|
}
|
||||||
);
|
|
||||||
|
|
||||||
const handleFieldChange = useCallback(
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
<Key extends keyof FormState>(key: Key, value: FormState[Key]) => {
|
event.preventDefault();
|
||||||
setFormState((prev) => {
|
setErrorMessage(null);
|
||||||
const effectiveCartaoId =
|
|
||||||
key === "cartaoId" ? (value as string) : prev.cartaoId;
|
|
||||||
const cardInfo = getCardInfo(effectiveCartaoId);
|
|
||||||
|
|
||||||
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 {
|
if (!formState.name.trim()) {
|
||||||
...prev,
|
const message = "Informe a descrição do lançamento.";
|
||||||
[key]: value,
|
setErrorMessage(message);
|
||||||
...dependencies,
|
toast.error(message);
|
||||||
};
|
return;
|
||||||
});
|
}
|
||||||
},
|
|
||||||
[getCardInfo],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
if (formState.isSplit && !formState.pagadorId) {
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
const message =
|
||||||
event.preventDefault();
|
"Selecione o pagador principal para dividir o lançamento.";
|
||||||
setErrorMessage(null);
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!formState.purchaseDate) {
|
if (formState.isSplit && !formState.secondaryPagadorId) {
|
||||||
const message = "Informe a data da transação.";
|
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);
|
setErrorMessage(message);
|
||||||
toast.error(message);
|
toast.error(message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
} else if (!formState.contaId) {
|
||||||
|
const message = "Selecione a conta.";
|
||||||
|
setErrorMessage(message);
|
||||||
|
toast.error(message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!formState.name.trim()) {
|
const payload: CreateLancamentoInput = {
|
||||||
const message = "Informe a descrição do lançamento.";
|
purchaseDate: formState.purchaseDate,
|
||||||
setErrorMessage(message);
|
period: formState.period,
|
||||||
toast.error(message);
|
name: formState.name.trim(),
|
||||||
return;
|
transactionType:
|
||||||
}
|
formState.transactionType as CreateLancamentoInput["transactionType"],
|
||||||
|
amount: sanitizedAmount,
|
||||||
if (formState.isSplit && !formState.pagadorId) {
|
condition: formState.condition as CreateLancamentoInput["condition"],
|
||||||
const message =
|
paymentMethod:
|
||||||
"Selecione o pagador principal para dividir o lançamento.";
|
formState.paymentMethod as CreateLancamentoInput["paymentMethod"],
|
||||||
setErrorMessage(message);
|
pagadorId: formState.pagadorId ?? null,
|
||||||
toast.error(message);
|
secondaryPagadorId: formState.isSplit
|
||||||
return;
|
? formState.secondaryPagadorId
|
||||||
}
|
: undefined,
|
||||||
|
isSplit: formState.isSplit,
|
||||||
if (formState.isSplit && !formState.secondaryPagadorId) {
|
primarySplitAmount: formState.isSplit
|
||||||
const message =
|
? Number.parseFloat(formState.primarySplitAmount) || undefined
|
||||||
"Selecione o pagador secundário para dividir o lançamento.";
|
: undefined,
|
||||||
setErrorMessage(message);
|
secondarySplitAmount: formState.isSplit
|
||||||
toast.error(message);
|
? Number.parseFloat(formState.secondarySplitAmount) || undefined
|
||||||
return;
|
: undefined,
|
||||||
}
|
contaId: formState.contaId ?? null,
|
||||||
|
cartaoId: formState.cartaoId ?? null,
|
||||||
const amountValue = Number(formState.amount);
|
categoriaId: formState.categoriaId ?? null,
|
||||||
if (Number.isNaN(amountValue)) {
|
note: formState.note.trim() || null,
|
||||||
const message = "Informe um valor válido.";
|
isSettled:
|
||||||
setErrorMessage(message);
|
formState.paymentMethod === "Cartão de crédito"
|
||||||
toast.error(message);
|
? null
|
||||||
return;
|
: Boolean(formState.isSettled),
|
||||||
}
|
installmentCount:
|
||||||
|
formState.condition === "Parcelado" && formState.installmentCount
|
||||||
const sanitizedAmount = Math.abs(amountValue);
|
? Number(formState.installmentCount)
|
||||||
|
|
||||||
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
|
|
||||||
: undefined,
|
: undefined,
|
||||||
isSplit: formState.isSplit,
|
recurrenceCount:
|
||||||
primarySplitAmount: formState.isSplit
|
formState.condition === "Recorrente" && formState.recurrenceCount
|
||||||
? Number.parseFloat(formState.primarySplitAmount) || undefined
|
? Number(formState.recurrenceCount)
|
||||||
: undefined,
|
: undefined,
|
||||||
secondarySplitAmount: formState.isSplit
|
dueDate:
|
||||||
? Number.parseFloat(formState.secondarySplitAmount) || undefined
|
formState.paymentMethod === "Boleto" && formState.dueDate
|
||||||
|
? formState.dueDate
|
||||||
: undefined,
|
: undefined,
|
||||||
contaId: formState.contaId,
|
boletoPaymentDate:
|
||||||
cartaoId: formState.cartaoId,
|
mode === "update" &&
|
||||||
categoriaId: formState.categoriaId,
|
formState.paymentMethod === "Boleto" &&
|
||||||
note: formState.note.trim() || undefined,
|
formState.boletoPaymentDate
|
||||||
isSettled:
|
? formState.boletoPaymentDate
|
||||||
formState.paymentMethod === "Cartão de crédito"
|
: undefined,
|
||||||
? 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,
|
|
||||||
};
|
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
if (mode === "create") {
|
if (mode === "create") {
|
||||||
const result = await createLancamentoAction(payload);
|
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
@@ -372,18 +323,54 @@ export function LancamentoDialog({
|
|||||||
|
|
||||||
setErrorMessage(result.error);
|
setErrorMessage(result.error);
|
||||||
toast.error(result.error);
|
toast.error(result.error);
|
||||||
});
|
return;
|
||||||
},
|
}
|
||||||
[
|
|
||||||
formState,
|
// Update mode
|
||||||
mode,
|
const hasSeriesId = Boolean(lancamento?.seriesId);
|
||||||
lancamento?.id,
|
|
||||||
lancamento?.seriesId,
|
if (hasSeriesId && onBulkEditRequest) {
|
||||||
setDialogOpen,
|
// Para lançamentos em série, abre o diálogo de bulk action
|
||||||
onSuccess,
|
onBulkEditRequest({
|
||||||
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 isCopyMode = mode === "create" && Boolean(lancamento) && !isImporting;
|
||||||
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;
|
const isImportMode = mode === "create" && Boolean(lancamento) && isImporting;
|
||||||
|
|||||||
1
components/logo-picker/index.ts
Normal file
1
components/logo-picker/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export { LogoPickerTrigger, LogoPickerDialog } from "./logo-picker";
|
||||||
187
components/logo-picker/logo-picker.tsx
Normal file
187
components/logo-picker/logo-picker.tsx
Normal 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 “{search}”
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useEffect, useMemo, useTransition } from "react";
|
import { useEffect, useTransition } from "react";
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import { getNextPeriod, getPreviousPeriod } from "@/lib/utils/period";
|
import { getNextPeriod, getPreviousPeriod } from "@/lib/utils/period";
|
||||||
|
import { Card } from "../ui/card";
|
||||||
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";
|
||||||
@@ -16,23 +16,10 @@ export default function MonthNavigation() {
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [isPending, startTransition] = useTransition();
|
const [isPending, startTransition] = useTransition();
|
||||||
|
|
||||||
const currentMonthLabel = useMemo(
|
const currentMonthLabel = `${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`;
|
||||||
() =>
|
const prevTarget = buildHref(getPreviousPeriod(period));
|
||||||
`${currentMonth.charAt(0).toUpperCase()}${currentMonth.slice(1)} ${currentYear}`,
|
const nextTarget = buildHref(getNextPeriod(period));
|
||||||
[currentMonth, currentYear],
|
const returnTarget = buildHref(defaultPeriod);
|
||||||
);
|
|
||||||
const prevTarget = useMemo(
|
|
||||||
() => buildHref(getPreviousPeriod(period)),
|
|
||||||
[buildHref, period],
|
|
||||||
);
|
|
||||||
const nextTarget = useMemo(
|
|
||||||
() => buildHref(getNextPeriod(period)),
|
|
||||||
[buildHref, period],
|
|
||||||
);
|
|
||||||
const returnTarget = useMemo(
|
|
||||||
() => buildHref(defaultPeriod),
|
|
||||||
[buildHref, defaultPeriod],
|
|
||||||
);
|
|
||||||
const isDifferentFromCurrent = period !== defaultPeriod;
|
const isDifferentFromCurrent = period !== defaultPeriod;
|
||||||
|
|
||||||
// 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
|
||||||
@@ -55,7 +42,7 @@ export default function MonthNavigation() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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">
|
<div className="flex items-center gap-1">
|
||||||
<NavigationButton
|
<NavigationButton
|
||||||
direction="left"
|
direction="left"
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
import { usePathname, useSearchParams } from "next/navigation";
|
||||||
import { useCallback, useMemo } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
formatPeriod,
|
formatPeriod,
|
||||||
formatPeriodForUrl,
|
formatPeriodForUrl,
|
||||||
formatPeriodParam,
|
|
||||||
MONTH_NAMES,
|
|
||||||
parsePeriodParam,
|
parsePeriodParam,
|
||||||
} from "@/lib/utils/period";
|
} from "@/lib/utils/period";
|
||||||
|
|
||||||
@@ -16,74 +14,30 @@ const PERIOD_PARAM = "periodo";
|
|||||||
export function useMonthPeriod() {
|
export function useMonthPeriod() {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const periodFromParams = searchParams.get(PERIOD_PARAM);
|
const periodFromParams = searchParams.get(PERIOD_PARAM);
|
||||||
const referenceDate = useMemo(() => new Date(), []);
|
const referenceDate = useRef(new Date()).current;
|
||||||
const defaultPeriod = useMemo(
|
const defaultPeriod = formatPeriod(
|
||||||
() =>
|
referenceDate.getFullYear(),
|
||||||
formatPeriod(referenceDate.getFullYear(), referenceDate.getMonth() + 1),
|
referenceDate.getMonth() + 1,
|
||||||
[referenceDate],
|
|
||||||
);
|
);
|
||||||
const { period, monthName, year } = useMemo(
|
const { period, monthName, year } = parsePeriodParam(
|
||||||
() => parsePeriodParam(periodFromParams, referenceDate),
|
periodFromParams,
|
||||||
[periodFromParams, referenceDate],
|
referenceDate,
|
||||||
);
|
|
||||||
const defaultMonth = useMemo(
|
|
||||||
() => MONTH_NAMES[referenceDate.getMonth()] ?? "",
|
|
||||||
[referenceDate],
|
|
||||||
);
|
|
||||||
const defaultYear = useMemo(
|
|
||||||
() => referenceDate.getFullYear().toString(),
|
|
||||||
[referenceDate],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const buildHref = useCallback(
|
const buildHref = (targetPeriod: string) => {
|
||||||
(targetPeriod: string) => {
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
const params = new URLSearchParams(searchParams.toString());
|
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
|
||||||
params.set(PERIOD_PARAM, formatPeriodForUrl(targetPeriod));
|
|
||||||
|
|
||||||
return `${pathname}?${params.toString()}`;
|
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 {
|
return {
|
||||||
pathname,
|
|
||||||
period,
|
period,
|
||||||
currentMonth: monthName,
|
currentMonth: monthName,
|
||||||
currentYear: year.toString(),
|
currentYear: year.toString(),
|
||||||
defaultPeriod,
|
defaultPeriod,
|
||||||
defaultMonth,
|
|
||||||
defaultYear,
|
|
||||||
buildHref,
|
buildHref,
|
||||||
buildHrefFromMonth,
|
|
||||||
replacePeriod,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import Link from "next/link";
|
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 { 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 { RefreshPageButton } from "@/components/shared/refresh-page-button";
|
||||||
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
|
import type { DashboardNotificationsSnapshot } from "@/lib/dashboard/notifications";
|
||||||
import { NavMenu } from "./nav-menu";
|
import { NavMenu } from "./nav-menu";
|
||||||
import { NavbarUser } from "./navbar-user";
|
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 = {
|
type AppNavbarProps = {
|
||||||
user: {
|
user: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -26,11 +29,11 @@ export function AppNavbar({
|
|||||||
notificationsSnapshot,
|
notificationsSnapshot,
|
||||||
}: AppNavbarProps) {
|
}: AppNavbarProps) {
|
||||||
return (
|
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">
|
<div className="w-full max-w-8xl mx-auto px-4 flex items-center gap-4 h-full">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<Link href="/dashboard" className="shrink-0 mr-1">
|
<Link href="/dashboard" className="shrink-0 mr-1">
|
||||||
<Logo variant="compact" />
|
<Logo variant="compact" invertTextOnDark={false} />
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
@@ -44,8 +47,8 @@ export function AppNavbar({
|
|||||||
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
budgetNotifications={notificationsSnapshot.budgetNotifications}
|
||||||
preLancamentosCount={preLancamentosCount}
|
preLancamentosCount={preLancamentosCount}
|
||||||
/>
|
/>
|
||||||
<RefreshPageButton />
|
<RefreshPageButton className={navbarActionClassName} />
|
||||||
<AnimatedThemeToggler />
|
<AnimatedThemeToggler className={navbarActionClassName} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User avatar */}
|
{/* User avatar */}
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ export function NavDropdown({ items }: NavDropdownProps) {
|
|||||||
{item.badge && item.badge > 0 ? (
|
{item.badge && item.badge > 0 ? (
|
||||||
<Badge
|
<Badge
|
||||||
variant="secondary"
|
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}
|
{item.badge}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { useMemo } from "react";
|
|
||||||
|
|
||||||
const PERIOD_PARAM = "periodo";
|
const PERIOD_PARAM = "periodo";
|
||||||
|
|
||||||
@@ -18,13 +17,14 @@ export function NavLink({
|
|||||||
}: NavLinkProps) {
|
}: NavLinkProps) {
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
const resolvedHref = useMemo(() => {
|
let resolvedHref = href;
|
||||||
if (!preservePeriod) return href;
|
if (preservePeriod) {
|
||||||
const periodo = searchParams.get(PERIOD_PARAM);
|
const periodo = searchParams.get(PERIOD_PARAM);
|
||||||
if (!periodo) return href;
|
if (periodo) {
|
||||||
const separator = href.includes("?") ? "&" : "?";
|
const separator = href.includes("?") ? "&" : "?";
|
||||||
return `${href}${separator}${PERIOD_PARAM}=${encodeURIComponent(periodo)}`;
|
resolvedHref = `${href}${separator}${PERIOD_PARAM}=${encodeURIComponent(periodo)}`;
|
||||||
}, [href, preservePeriod, searchParams]);
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return <Link href={resolvedHref} {...props} />;
|
return <Link href={resolvedHref} {...props} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ export function NavMenu() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Desktop */}
|
{/* 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}>
|
<NavigationMenu viewport={false}>
|
||||||
<NavigationMenuList className="gap-0">
|
<NavigationMenuList className="gap-0">
|
||||||
<NavigationMenuItem>
|
<NavigationMenuItem>
|
||||||
@@ -73,14 +73,14 @@ export function NavMenu() {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
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" />
|
<RiMenuLine className="size-5" />
|
||||||
<span className="sr-only">Abrir menu</span>
|
<span className="sr-only">Abrir menu</span>
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent side="left" className="w-72 p-0">
|
<SheetContent side="left" className="w-72 p-0 shadow-none">
|
||||||
<SheetHeader className="p-4 border-b">
|
<SheetHeader className="border-b border-border/60 p-4">
|
||||||
<SheetTitle>Menu</SheetTitle>
|
<SheetTitle>Menu</SheetTitle>
|
||||||
</SheetHeader>
|
</SheetHeader>
|
||||||
<nav className="p-3 overflow-y-auto">
|
<nav className="p-3 overflow-y-auto">
|
||||||
|
|||||||
@@ -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";
|
"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
|
// Estado inativo: muted, hover suave sem underline
|
||||||
export const linkIdle =
|
export const linkIdle = "text-black/75 hover:bg-black/10 hover:text-black";
|
||||||
"text-muted-foreground hover:text-foreground hover:bg-accent";
|
|
||||||
|
|
||||||
// Estado ativo: pill com cor primária
|
// 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
|
// Trigger do NavigationMenu — espelha linkBase + linkIdle, remove estilos padrão
|
||||||
export const triggerClass = [
|
export const triggerClass = [
|
||||||
@@ -18,13 +17,15 @@ export const triggerClass = [
|
|||||||
"text-sm!",
|
"text-sm!",
|
||||||
"font-medium!",
|
"font-medium!",
|
||||||
"bg-transparent!",
|
"bg-transparent!",
|
||||||
"text-muted-foreground!",
|
"text-black/75!",
|
||||||
"hover:text-foreground!",
|
"hover:text-black!",
|
||||||
"hover:bg-accent!",
|
"hover:bg-black/10!",
|
||||||
"focus:text-foreground!",
|
"focus:text-black!",
|
||||||
"focus:bg-accent!",
|
"focus:bg-black/10!",
|
||||||
"data-[state=open]:text-foreground!",
|
"focus-visible:ring-black/20!",
|
||||||
"data-[state=open]:bg-accent!",
|
"data-[state=open]:text-black!",
|
||||||
|
"data-[state=open]:bg-black/10!",
|
||||||
"shadow-none!",
|
"shadow-none!",
|
||||||
|
"[&_svg]:text-current!",
|
||||||
"lowercase!",
|
"lowercase!",
|
||||||
].join(" ");
|
].join(" ");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalculatorLine, RiEyeLine, RiEyeOffLine } from "@remixicon/react";
|
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 { Badge } from "@/components/ui/badge";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import {
|
|||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useMemo, useState } from "react";
|
import { useState } from "react";
|
||||||
import { FeedbackDialogBody } from "@/components/feedback/feedback-dialog";
|
import { FeedbackDialogBody } from "@/components/feedback/feedback-dialog";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
|
import { Dialog, DialogTrigger } from "@/components/ui/dialog";
|
||||||
@@ -44,11 +44,9 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
|
|||||||
const [logoutLoading, setLogoutLoading] = useState(false);
|
const [logoutLoading, setLogoutLoading] = useState(false);
|
||||||
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||||
|
|
||||||
const avatarSrc = useMemo(() => {
|
const avatarSrc = pagadorAvatarUrl
|
||||||
if (pagadorAvatarUrl) return getAvatarSrc(pagadorAvatarUrl);
|
? getAvatarSrc(pagadorAvatarUrl)
|
||||||
if (user.image) return user.image;
|
: user.image || getAvatarSrc(null);
|
||||||
return getAvatarSrc(null);
|
|
||||||
}, [user.image, pagadorAvatarUrl]);
|
|
||||||
|
|
||||||
async function handleLogout() {
|
async function handleLogout() {
|
||||||
await authClient.signOut({
|
await authClient.signOut({
|
||||||
@@ -65,7 +63,7 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<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"
|
aria-label="Menu do usuário"
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
@@ -77,7 +75,11 @@ export function NavbarUser({ user, pagadorAvatarUrl }: NavbarUserProps) {
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</DropdownMenuTrigger>
|
</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">
|
<DropdownMenuLabel className="flex items-center gap-3 px-2 py-2">
|
||||||
<Image
|
<Image
|
||||||
src={avatarSrc}
|
src={avatarSrc}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import * as React from "react";
|
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 { NavMain } from "@/components/navigation/sidebar/nav-main";
|
||||||
import { NavSecondary } from "@/components/navigation/sidebar/nav-secondary";
|
import { NavSecondary } from "@/components/navigation/sidebar/nav-secondary";
|
||||||
import { NavUser } from "@/components/navigation/sidebar/nav-user";
|
import { NavUser } from "@/components/navigation/sidebar/nav-user";
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import type { RemixiconComponentType } from "@remixicon/react";
|
import type { RemixiconComponentType } from "@remixicon/react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import * as React from "react";
|
import type * as React from "react";
|
||||||
import {
|
import {
|
||||||
SidebarGroup,
|
SidebarGroup,
|
||||||
SidebarGroupContent,
|
SidebarGroupContent,
|
||||||
@@ -24,30 +24,22 @@ export function NavSecondary({
|
|||||||
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
} & React.ComponentPropsWithoutRef<typeof SidebarGroup>) {
|
||||||
const pathname = usePathname();
|
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 (
|
return (
|
||||||
<SidebarGroup {...props}>
|
<SidebarGroup {...props}>
|
||||||
<SidebarGroupContent>
|
<SidebarGroupContent>
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
{items.map((item) => {
|
{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 (
|
return (
|
||||||
<SidebarMenuItem key={item.title}>
|
<SidebarMenuItem key={item.title}>
|
||||||
<SidebarMenuButton
|
<SidebarMenuButton
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import { useMemo } from "react";
|
|
||||||
import {
|
import {
|
||||||
SidebarMenu,
|
SidebarMenu,
|
||||||
SidebarMenuButton,
|
SidebarMenuButton,
|
||||||
SidebarMenuItem,
|
SidebarMenuItem,
|
||||||
useSidebar,
|
|
||||||
} from "@/components/ui/sidebar";
|
} from "@/components/ui/sidebar";
|
||||||
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
import { getAvatarSrc } from "@/lib/pagadores/utils";
|
||||||
|
|
||||||
@@ -21,19 +19,9 @@ type NavUserProps = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
|
export function NavUser({ user, pagadorAvatarUrl }: NavUserProps) {
|
||||||
useSidebar();
|
const avatarSrc = pagadorAvatarUrl
|
||||||
|
? getAvatarSrc(pagadorAvatarUrl)
|
||||||
const avatarSrc = useMemo(() => {
|
: user.image || getAvatarSrc(null);
|
||||||
// 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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarMenu>
|
<SidebarMenu>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState, useTransition } from "react";
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createBudgetAction,
|
createBudgetAction,
|
||||||
updateBudgetAction,
|
updateBudgetAction,
|
||||||
} from "@/app/(dashboard)/orcamentos/actions";
|
} from "@/app/(dashboard)/orcamentos/actions";
|
||||||
import { CategoryIcon } from "@/components/categorias/category-icon";
|
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 { Button } from "@/components/ui/button";
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
@@ -27,8 +27,9 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Slider } from "@/components/ui/slider";
|
import { Slider } from "@/components/ui/slider";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/lib/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/lib/hooks/use-form-state";
|
||||||
|
import { formatCurrency } from "@/lib/utils/currency";
|
||||||
|
|
||||||
import type { Budget, BudgetCategory, BudgetFormValues } from "./types";
|
import type { Budget, BudgetCategory, BudgetFormValues } from "./types";
|
||||||
|
|
||||||
@@ -54,12 +55,6 @@ const buildInitialValues = ({
|
|||||||
amount: budget ? (Math.round(budget.amount * 100) / 100).toFixed(2) : "",
|
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({
|
export function BudgetDialog({
|
||||||
mode,
|
mode,
|
||||||
trigger,
|
trigger,
|
||||||
@@ -79,10 +74,14 @@ export function BudgetDialog({
|
|||||||
onOpenChange,
|
onOpenChange,
|
||||||
);
|
);
|
||||||
|
|
||||||
const initialState = buildInitialValues({
|
const initialState = useMemo(
|
||||||
budget,
|
() =>
|
||||||
defaultPeriod,
|
buildInitialValues({
|
||||||
});
|
budget,
|
||||||
|
defaultPeriod,
|
||||||
|
}),
|
||||||
|
[budget, defaultPeriod],
|
||||||
|
);
|
||||||
|
|
||||||
// Use form state hook for form management
|
// Use form state hook for form management
|
||||||
const { formState, resetForm, updateField } =
|
const { formState, resetForm, updateField } =
|
||||||
|
|||||||
@@ -1,12 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
import {
|
import { useEffect, useMemo, useState, useTransition } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useState,
|
|
||||||
useTransition,
|
|
||||||
} from "react";
|
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import {
|
import {
|
||||||
createPagadorAction,
|
createPagadorAction,
|
||||||
@@ -32,8 +26,8 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { useControlledState } from "@/hooks/use-controlled-state";
|
import { useControlledState } from "@/lib/hooks/use-controlled-state";
|
||||||
import { useFormState } from "@/hooks/use-form-state";
|
import { useFormState } from "@/lib/hooks/use-form-state";
|
||||||
import {
|
import {
|
||||||
DEFAULT_PAGADOR_AVATAR,
|
DEFAULT_PAGADOR_AVATAR,
|
||||||
PAGADOR_STATUS_OPTIONS,
|
PAGADOR_STATUS_OPTIONS,
|
||||||
@@ -116,46 +110,33 @@ export function PagadorDialog({
|
|||||||
}
|
}
|
||||||
}, [dialogOpen, initialState, resetForm]);
|
}, [dialogOpen, initialState, resetForm]);
|
||||||
|
|
||||||
const handleSubmit = useCallback(
|
type PagadorCreatePayload = Parameters<typeof createPagadorAction>[0];
|
||||||
(event: React.FormEvent<HTMLFormElement>) => {
|
|
||||||
event.preventDefault();
|
|
||||||
setErrorMessage(null);
|
|
||||||
|
|
||||||
if (mode === "update" && !pagador?.id) {
|
const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
const message = "Pagador inválido.";
|
event.preventDefault();
|
||||||
setErrorMessage(message);
|
setErrorMessage(null);
|
||||||
toast.error(message);
|
const pagadorId = pagador?.id;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload: {
|
if (mode === "update" && !pagadorId) {
|
||||||
name: string;
|
const message = "Pagador inválido.";
|
||||||
email?: string;
|
setErrorMessage(message);
|
||||||
status: PagadorStatus;
|
toast.error(message);
|
||||||
avatarUrl: string;
|
return;
|
||||||
note: string;
|
}
|
||||||
isAutoSend: boolean;
|
|
||||||
} = {
|
|
||||||
name: formState.name.trim(),
|
|
||||||
status: formState.status,
|
|
||||||
avatarUrl: formState.avatarUrl,
|
|
||||||
note: formState.note.trim(),
|
|
||||||
isAutoSend: formState.isAutoSend,
|
|
||||||
};
|
|
||||||
|
|
||||||
const emailValue = formState.email.trim();
|
const emailValue = formState.email.trim();
|
||||||
if (emailValue.length > 0) {
|
const payload: PagadorCreatePayload = {
|
||||||
payload.email = emailValue;
|
name: formState.name.trim(),
|
||||||
}
|
status: formState.status,
|
||||||
|
avatarUrl: formState.avatarUrl,
|
||||||
|
email: emailValue || null,
|
||||||
|
note: formState.note.trim() || null,
|
||||||
|
isAutoSend: formState.isAutoSend,
|
||||||
|
};
|
||||||
|
|
||||||
startTransition(async () => {
|
startTransition(async () => {
|
||||||
const result =
|
if (mode === "create") {
|
||||||
mode === "create"
|
const result = await createPagadorAction(payload);
|
||||||
? await createPagadorAction(payload)
|
|
||||||
: await updatePagadorAction({
|
|
||||||
id: pagador?.id ?? "",
|
|
||||||
...payload,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
toast.success(result.message);
|
toast.success(result.message);
|
||||||
@@ -166,10 +147,29 @@ export function PagadorDialog({
|
|||||||
|
|
||||||
setErrorMessage(result.error);
|
setErrorMessage(result.error);
|
||||||
toast.error(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 title = mode === "create" ? "Novo pagador" : "Editar pagador";
|
||||||
const description =
|
const description =
|
||||||
|
|||||||
66
components/providers/font-provider.tsx
Normal file
66
components/providers/font-provider.tsx
Normal 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;
|
||||||
|
}
|
||||||
3
components/providers/index.ts
Normal file
3
components/providers/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { FontProvider } from "./font-provider";
|
||||||
|
export { PrivacyProvider } from "./privacy-provider";
|
||||||
|
export { ThemeProvider } from "./theme-provider";
|
||||||
91
components/providers/privacy-provider.tsx
Normal file
91
components/providers/privacy-provider.tsx
Normal 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;
|
||||||
|
}
|
||||||
11
components/providers/theme-provider.tsx
Normal file
11
components/providers/theme-provider.tsx
Normal 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>;
|
||||||
|
}
|
||||||
122
components/shared/animated-theme-toggler.tsx
Normal file
122
components/shared/animated-theme-toggler.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
103
components/shared/confirm-action-dialog.tsx
Normal file
103
components/shared/confirm-action-dialog.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
103
components/shared/expandable-widget-card.tsx
Normal file
103
components/shared/expandable-widget-card.tsx
Normal 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>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
components/shared/logo.tsx
Normal file
82
components/shared/logo.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
40
components/shared/money-values.tsx
Normal file
40
components/shared/money-values.tsx
Normal 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;
|
||||||
@@ -9,7 +9,7 @@ export default function PageDescription({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<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>
|
<span className="text-primary">{icon}</span>
|
||||||
{title}
|
{title}
|
||||||
</h1>
|
</h1>
|
||||||
|
|||||||
71
components/shared/period-picker.tsx
Normal file
71
components/shared/period-picker.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { SectionCardsSkeleton } from "./section-cards-skeleton";
|
import { DashboardMetricsCardsSkeleton } from "./dashboard-metrics-cards-skeleton";
|
||||||
import { WidgetSkeleton } from "./widget-skeleton";
|
import { WidgetSkeleton } from "./widget-skeleton";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8,8 +8,8 @@ import { WidgetSkeleton } from "./widget-skeleton";
|
|||||||
export function DashboardGridSkeleton() {
|
export function DashboardGridSkeleton() {
|
||||||
return (
|
return (
|
||||||
<div className="@container/main space-y-4">
|
<div className="@container/main space-y-4">
|
||||||
{/* Section Cards no topo */}
|
{/* Cards de métricas no topo */}
|
||||||
<SectionCardsSkeleton />
|
<DashboardMetricsCardsSkeleton />
|
||||||
|
|
||||||
{/* Grid de widgets - mesmos breakpoints do dashboard real */}
|
{/* 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">
|
<div className="grid grid-cols-1 gap-3 @4xl/main:grid-cols-2 @6xl/main:grid-cols-3">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,8 +5,8 @@
|
|||||||
export { AccountStatementCardSkeleton } from "./account-statement-card-skeleton";
|
export { AccountStatementCardSkeleton } from "./account-statement-card-skeleton";
|
||||||
export { CategoryReportSkeleton } from "./category-report-skeleton";
|
export { CategoryReportSkeleton } from "./category-report-skeleton";
|
||||||
export { DashboardGridSkeleton } from "./dashboard-grid-skeleton";
|
export { DashboardGridSkeleton } from "./dashboard-grid-skeleton";
|
||||||
|
export { DashboardMetricsCardsSkeleton } from "./dashboard-metrics-cards-skeleton";
|
||||||
export { FilterSkeleton } from "./filter-skeleton";
|
export { FilterSkeleton } from "./filter-skeleton";
|
||||||
export { InvoiceSummaryCardSkeleton } from "./invoice-summary-card-skeleton";
|
export { InvoiceSummaryCardSkeleton } from "./invoice-summary-card-skeleton";
|
||||||
export { SectionCardsSkeleton } from "./section-cards-skeleton";
|
|
||||||
export { TransactionsTableSkeleton } from "./transactions-table-skeleton";
|
export { TransactionsTableSkeleton } from "./transactions-table-skeleton";
|
||||||
export { WidgetSkeleton } from "./widget-skeleton";
|
export { WidgetSkeleton } from "./widget-skeleton";
|
||||||
|
|||||||
@@ -7,23 +7,23 @@ import { Skeleton } from "@/components/ui/skeleton";
|
|||||||
*/
|
*/
|
||||||
export function WidgetSkeleton() {
|
export function WidgetSkeleton() {
|
||||||
return (
|
return (
|
||||||
<Card className="relative h-auto md:h-custom-height-1 md:overflow-hidden">
|
<Card className="relative h-auto gap-0 py-0 md:h-custom-height-card md:overflow-hidden">
|
||||||
<CardHeader className="border-b [.border-b]:pb-2">
|
<CardHeader className="border-b px-6 py-4">
|
||||||
<div className="flex w-full items-start justify-between">
|
<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 */}
|
{/* 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="size-4 rounded-2xl bg-foreground/10" />
|
||||||
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
|
<Skeleton className="h-5 w-32 rounded-2xl bg-foreground/10" />
|
||||||
</div>
|
</div>
|
||||||
{/* Subtitle */}
|
{/* 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>
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<CardContent className="max-h-[calc(var(--spacing-custom-height-1)-5rem)] overflow-hidden md:max-h-[calc(100%-5rem)]">
|
<CardContent className="min-h-0 flex-1 overflow-hidden px-6 py-4">
|
||||||
<div className="flex flex-col gap-3 py-4">
|
<div className="flex flex-col gap-3">
|
||||||
{/* Simula 5 linhas de conteúdo */}
|
{/* Simula 5 linhas de conteúdo */}
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 5 }).map((_, i) => (
|
||||||
<div key={i} className="flex items-center justify-between gap-3">
|
<div key={i} className="flex items-center justify-between gap-3">
|
||||||
|
|||||||
19
components/shared/status-dot.tsx
Normal file
19
components/shared/status-dot.tsx
Normal 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"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
63
components/shared/type-badge.tsx
Normal file
63
components/shared/type-badge.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
60
components/shared/widget-card.tsx
Normal file
60
components/shared/widget-card.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
components/shared/widget-empty-state.tsx
Normal file
30
components/shared/widget-empty-state.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
variant: {
|
||||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
default: "bg-primary hover:bg-primary/90",
|
||||||
destructive:
|
destructive:
|
||||||
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
|
||||||
outline:
|
outline:
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ function Card({ className, ...props }: React.ComponentProps<"div">) {
|
|||||||
<div
|
<div
|
||||||
data-slot="card"
|
data-slot="card"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { RiCalendarLine } from "@remixicon/react";
|
import { RiCalendarLine } from "@remixicon/react";
|
||||||
|
import { ptBR } from "date-fns/locale";
|
||||||
import * as React from "react";
|
import * as React from "react";
|
||||||
|
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
@@ -11,6 +12,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
|
import { parseLocalDateString, toLocalDateString } from "@/lib/utils/date";
|
||||||
import { cn } from "@/lib/utils/ui";
|
import { cn } from "@/lib/utils/ui";
|
||||||
|
|
||||||
function formatDate(date: Date | undefined, compact = false): string {
|
function formatDate(date: Date | undefined, compact = false): string {
|
||||||
@@ -43,13 +45,7 @@ function isValidDate(date: Date | undefined): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function dateToYYYYMMDD(date: Date | undefined): string {
|
function dateToYYYYMMDD(date: Date | undefined): string {
|
||||||
if (!date || !isValidDate(date)) {
|
return isValidDate(date) ? (toLocalDateString(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}`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseYYYYMMDD(dateString: string): Date | undefined {
|
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!
|
// which in Brazil (UTC-3) becomes 2025-11-26 03:00 local time!
|
||||||
const ymdMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
const ymdMatch = dateString.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
if (ymdMatch) {
|
if (ymdMatch) {
|
||||||
const [, year, month, day] = ymdMatch;
|
const date = parseLocalDateString(dateString);
|
||||||
const date = new Date(Number(year), Number(month) - 1, Number(day));
|
|
||||||
return isValidDate(date) ? date : undefined;
|
return isValidDate(date) ? date : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,29 +174,7 @@ export function DatePicker({
|
|||||||
onSelect={handleCalendarSelect}
|
onSelect={handleCalendarSelect}
|
||||||
fromYear={2020}
|
fromYear={2020}
|
||||||
toYear={new Date().getFullYear() + 10}
|
toYear={new Date().getFullYear() + 10}
|
||||||
locale={{
|
locale={ptBR}
|
||||||
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",
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ function NavigationMenuTrigger({
|
|||||||
>
|
>
|
||||||
{children}{" "}
|
{children}{" "}
|
||||||
<RiArrowDropDownLine
|
<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"
|
aria-hidden="true"
|
||||||
/>
|
/>
|
||||||
</NavigationMenuPrimitive.Trigger>
|
</NavigationMenuPrimitive.Trigger>
|
||||||
@@ -90,7 +90,7 @@ function NavigationMenuContent({
|
|||||||
data-slot="navigation-menu-content"
|
data-slot="navigation-menu-content"
|
||||||
className={cn(
|
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",
|
"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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ function TableHead({ className, ...props }: React.ComponentProps<"th">) {
|
|||||||
<th
|
<th
|
||||||
data-slot="table-head"
|
data-slot="table-head"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
@@ -83,7 +83,7 @@ function TableCell({ className, ...props }: React.ComponentProps<"td">) {
|
|||||||
<td
|
<td
|
||||||
data-slot="table-cell"
|
data-slot="table-cell"
|
||||||
className={cn(
|
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,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
|
|||||||
@@ -1,26 +1 @@
|
|||||||
import * as React from "react";
|
export { useIsMobile, useMobile } from "@/lib/hooks/use-mobile";
|
||||||
|
|
||||||
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,7 +1,7 @@
|
|||||||
import { revalidatePath, revalidateTag } from "next/cache";
|
import { revalidatePath, revalidateTag } from "next/cache";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import type { ActionResult } from "./types";
|
import type { ActionResult } from "@/lib/types/actions";
|
||||||
import { errorResult } from "./types";
|
import { errorResult } from "@/lib/types/actions";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Handles errors in server actions consistently
|
* Handles errors in server actions consistently
|
||||||
|
|||||||
143
lib/calculadora/use-calculator-keyboard.ts
Normal file
143
lib/calculadora/use-calculator-keyboard.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import type { Operator } from "@/lib/utils/calculator";
|
||||||
|
|
||||||
|
type UseCalculatorKeyboardParams = {
|
||||||
|
isOpen: boolean;
|
||||||
|
canCopy: boolean;
|
||||||
|
onCopy: () => void | Promise<void>;
|
||||||
|
onPaste: () => void | Promise<void>;
|
||||||
|
inputDigit: (digit: string) => void;
|
||||||
|
inputDecimal: () => void;
|
||||||
|
setNextOperator: (op: Operator) => void;
|
||||||
|
evaluate: () => void;
|
||||||
|
deleteLastDigit: () => void;
|
||||||
|
reset: () => void;
|
||||||
|
applyPercent: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function shouldIgnoreForEditableTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!target || !(target instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagName = target.tagName;
|
||||||
|
return (
|
||||||
|
tagName === "INPUT" || tagName === "TEXTAREA" || target.isContentEditable
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KEY_TO_OPERATOR: Record<string, Operator> = {
|
||||||
|
"+": "add",
|
||||||
|
"-": "subtract",
|
||||||
|
"*": "multiply",
|
||||||
|
"/": "divide",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCalculatorKeyboard({
|
||||||
|
isOpen,
|
||||||
|
canCopy,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
|
inputDigit,
|
||||||
|
inputDecimal,
|
||||||
|
setNextOperator,
|
||||||
|
evaluate,
|
||||||
|
deleteLastDigit,
|
||||||
|
reset,
|
||||||
|
applyPercent,
|
||||||
|
}: UseCalculatorKeyboardParams) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen) return;
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
const { key, ctrlKey, metaKey } = event;
|
||||||
|
|
||||||
|
// Ctrl/Cmd shortcuts
|
||||||
|
if (ctrlKey || metaKey) {
|
||||||
|
if (shouldIgnoreForEditableTarget(event.target)) return;
|
||||||
|
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
if (lowerKey === "c" && canCopy) {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.toString().trim().length > 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
void onCopy();
|
||||||
|
} else if (lowerKey === "v") {
|
||||||
|
const selection = window.getSelection();
|
||||||
|
if (selection && selection.toString().trim().length > 0) return;
|
||||||
|
if (!navigator.clipboard?.readText) return;
|
||||||
|
event.preventDefault();
|
||||||
|
void onPaste();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Digits
|
||||||
|
if (key >= "0" && key <= "9") {
|
||||||
|
event.preventDefault();
|
||||||
|
inputDigit(key);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decimal
|
||||||
|
if (key === "." || key === ",") {
|
||||||
|
event.preventDefault();
|
||||||
|
inputDecimal();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Operators
|
||||||
|
const op = KEY_TO_OPERATOR[key];
|
||||||
|
if (op) {
|
||||||
|
event.preventDefault();
|
||||||
|
setNextOperator(op);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Evaluate
|
||||||
|
if (key === "Enter" || key === "=") {
|
||||||
|
event.preventDefault();
|
||||||
|
evaluate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backspace
|
||||||
|
if (key === "Backspace") {
|
||||||
|
event.preventDefault();
|
||||||
|
deleteLastDigit();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Escape resets calculator (dialog close is handled by onEscapeKeyDown)
|
||||||
|
if (key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Percent
|
||||||
|
if (key === "%") {
|
||||||
|
event.preventDefault();
|
||||||
|
applyPercent();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener("keydown", handleKeyDown);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener("keydown", handleKeyDown);
|
||||||
|
};
|
||||||
|
}, [
|
||||||
|
isOpen,
|
||||||
|
canCopy,
|
||||||
|
onCopy,
|
||||||
|
onPaste,
|
||||||
|
inputDigit,
|
||||||
|
inputDecimal,
|
||||||
|
setNextOperator,
|
||||||
|
evaluate,
|
||||||
|
deleteLastDigit,
|
||||||
|
reset,
|
||||||
|
applyPercent,
|
||||||
|
]);
|
||||||
|
}
|
||||||
379
lib/calculadora/use-calculator-state.ts
Normal file
379
lib/calculadora/use-calculator-state.ts
Normal file
@@ -0,0 +1,379 @@
|
|||||||
|
import type { VariantProps } from "class-variance-authority";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import type { buttonVariants } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
formatLocaleValue,
|
||||||
|
formatNumber,
|
||||||
|
normalizeClipboardNumber,
|
||||||
|
OPERATOR_SYMBOLS,
|
||||||
|
type Operator,
|
||||||
|
performOperation,
|
||||||
|
} from "@/lib/utils/calculator";
|
||||||
|
|
||||||
|
export type CalculatorButtonConfig = {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
variant?: VariantProps<typeof buttonVariants>["variant"];
|
||||||
|
colSpan?: number;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useCalculatorState() {
|
||||||
|
const [display, setDisplay] = useState("0");
|
||||||
|
const [accumulator, setAccumulator] = useState<number | null>(null);
|
||||||
|
const [operator, setOperator] = useState<Operator | null>(null);
|
||||||
|
const [overwrite, setOverwrite] = useState(false);
|
||||||
|
const [history, setHistory] = useState<string | null>(null);
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
const resetCopiedTimeoutRef = useRef<number | undefined>(undefined);
|
||||||
|
|
||||||
|
const currentValue = Number(display);
|
||||||
|
|
||||||
|
const resultText = (() => {
|
||||||
|
if (display === "Erro") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalized = formatNumber(currentValue);
|
||||||
|
if (normalized === "Erro") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatLocaleValue(normalized);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const reset = () => {
|
||||||
|
setDisplay("0");
|
||||||
|
setAccumulator(null);
|
||||||
|
setOperator(null);
|
||||||
|
setOverwrite(false);
|
||||||
|
setHistory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputDigit = (digit: string) => {
|
||||||
|
// Check conditions before state updates
|
||||||
|
const shouldReset = overwrite || display === "Erro";
|
||||||
|
|
||||||
|
setDisplay((prev) => {
|
||||||
|
if (shouldReset) {
|
||||||
|
return digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev === "0") {
|
||||||
|
return digit;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitar a 10 dígitos (excluindo sinal negativo e ponto decimal)
|
||||||
|
const digitCount = prev.replace(/[-.]/g, "").length;
|
||||||
|
if (digitCount >= 10) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prev}${digit}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update related states after display update
|
||||||
|
if (shouldReset) {
|
||||||
|
setOverwrite(false);
|
||||||
|
setHistory(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const inputDecimal = () => {
|
||||||
|
// Check conditions before state updates
|
||||||
|
const shouldReset = overwrite || display === "Erro";
|
||||||
|
|
||||||
|
setDisplay((prev) => {
|
||||||
|
if (shouldReset) {
|
||||||
|
return "0.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev.includes(".")) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitar a 10 dígitos antes de adicionar o ponto decimal
|
||||||
|
const digitCount = prev.replace(/[-]/g, "").length;
|
||||||
|
if (digitCount >= 10) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${prev}.`;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update related states after display update
|
||||||
|
if (shouldReset) {
|
||||||
|
setOverwrite(false);
|
||||||
|
setHistory(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setNextOperator = (nextOperator: Operator) => {
|
||||||
|
if (display === "Erro") {
|
||||||
|
reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = currentValue;
|
||||||
|
|
||||||
|
if (accumulator === null || operator === null || overwrite) {
|
||||||
|
setAccumulator(value);
|
||||||
|
} else {
|
||||||
|
const result = performOperation(accumulator, value, operator);
|
||||||
|
const formatted = formatNumber(result);
|
||||||
|
setAccumulator(Number.isFinite(result) ? result : null);
|
||||||
|
setDisplay(formatted);
|
||||||
|
if (!Number.isFinite(result)) {
|
||||||
|
setOperator(null);
|
||||||
|
setOverwrite(true);
|
||||||
|
setHistory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setOperator(nextOperator);
|
||||||
|
setOverwrite(true);
|
||||||
|
setHistory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const evaluate = () => {
|
||||||
|
if (operator === null || accumulator === null || display === "Erro") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const value = currentValue;
|
||||||
|
const left = formatNumber(accumulator);
|
||||||
|
const right = formatNumber(value);
|
||||||
|
const symbol = OPERATOR_SYMBOLS[operator];
|
||||||
|
const operation = `${formatLocaleValue(left)} ${symbol} ${formatLocaleValue(
|
||||||
|
right,
|
||||||
|
)}`;
|
||||||
|
const result = performOperation(accumulator, value, operator);
|
||||||
|
const formatted = formatNumber(result);
|
||||||
|
|
||||||
|
setDisplay(formatted);
|
||||||
|
setAccumulator(Number.isFinite(result) ? result : null);
|
||||||
|
setOperator(null);
|
||||||
|
setOverwrite(true);
|
||||||
|
setHistory(operation);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleSign = () => {
|
||||||
|
setDisplay((prev) => {
|
||||||
|
if (prev === "Erro") {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
if (prev.startsWith("-")) {
|
||||||
|
return prev.slice(1);
|
||||||
|
}
|
||||||
|
return prev === "0" ? prev : `-${prev}`;
|
||||||
|
});
|
||||||
|
if (overwrite) {
|
||||||
|
setOverwrite(false);
|
||||||
|
setHistory(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteLastDigit = () => {
|
||||||
|
setHistory(null);
|
||||||
|
|
||||||
|
// Check conditions before state updates
|
||||||
|
const isError = display === "Erro";
|
||||||
|
|
||||||
|
setDisplay((prev) => {
|
||||||
|
if (prev === "Erro") {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (overwrite) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prev.length <= 1 || (prev.length === 2 && prev.startsWith("-"))) {
|
||||||
|
return "0";
|
||||||
|
}
|
||||||
|
|
||||||
|
return prev.slice(0, -1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update related states after display update
|
||||||
|
if (isError) {
|
||||||
|
setAccumulator(null);
|
||||||
|
setOperator(null);
|
||||||
|
setOverwrite(false);
|
||||||
|
} else if (overwrite) {
|
||||||
|
setOverwrite(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const applyPercent = () => {
|
||||||
|
setDisplay((prev) => {
|
||||||
|
if (prev === "Erro") {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const value = Number(prev);
|
||||||
|
return formatNumber(value / 100);
|
||||||
|
});
|
||||||
|
setOverwrite(true);
|
||||||
|
setHistory(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const expression = (() => {
|
||||||
|
if (display === "Erro") {
|
||||||
|
return "Erro";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (operator && accumulator !== null) {
|
||||||
|
const symbol = OPERATOR_SYMBOLS[operator];
|
||||||
|
const left = formatLocaleValue(formatNumber(accumulator));
|
||||||
|
|
||||||
|
if (overwrite) {
|
||||||
|
return `${left} ${symbol}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${left} ${symbol} ${formatLocaleValue(display)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatLocaleValue(display);
|
||||||
|
})();
|
||||||
|
|
||||||
|
const makeOperatorHandler = (nextOperator: Operator) => () =>
|
||||||
|
setNextOperator(nextOperator);
|
||||||
|
|
||||||
|
const buttons: CalculatorButtonConfig[][] = [
|
||||||
|
[
|
||||||
|
{ label: "C", onClick: reset, variant: "destructive" },
|
||||||
|
{ label: "⌫", onClick: deleteLastDigit, variant: "secondary" },
|
||||||
|
{ label: "%", onClick: applyPercent, variant: "secondary" },
|
||||||
|
{
|
||||||
|
label: "÷",
|
||||||
|
onClick: makeOperatorHandler("divide"),
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "7", onClick: () => inputDigit("7") },
|
||||||
|
{ label: "8", onClick: () => inputDigit("8") },
|
||||||
|
{ label: "9", onClick: () => inputDigit("9") },
|
||||||
|
{
|
||||||
|
label: "×",
|
||||||
|
onClick: makeOperatorHandler("multiply"),
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "4", onClick: () => inputDigit("4") },
|
||||||
|
{ label: "5", onClick: () => inputDigit("5") },
|
||||||
|
{ label: "6", onClick: () => inputDigit("6") },
|
||||||
|
{
|
||||||
|
label: "-",
|
||||||
|
onClick: makeOperatorHandler("subtract"),
|
||||||
|
variant: "outline",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "1", onClick: () => inputDigit("1") },
|
||||||
|
{ label: "2", onClick: () => inputDigit("2") },
|
||||||
|
{ label: "3", onClick: () => inputDigit("3") },
|
||||||
|
{ label: "+", onClick: makeOperatorHandler("add"), variant: "outline" },
|
||||||
|
],
|
||||||
|
[
|
||||||
|
{ label: "±", onClick: toggleSign, variant: "secondary" },
|
||||||
|
{ label: "0", onClick: () => inputDigit("0") },
|
||||||
|
{ label: ",", onClick: inputDecimal },
|
||||||
|
{ label: "=", onClick: evaluate, variant: "default" },
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (!resultText) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(resultText);
|
||||||
|
|
||||||
|
setCopied(true);
|
||||||
|
if (resetCopiedTimeoutRef.current !== undefined) {
|
||||||
|
window.clearTimeout(resetCopiedTimeoutRef.current);
|
||||||
|
}
|
||||||
|
resetCopiedTimeoutRef.current = window.setTimeout(() => {
|
||||||
|
setCopied(false);
|
||||||
|
}, 2000);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(
|
||||||
|
"Não foi possível copiar o resultado da calculadora.",
|
||||||
|
error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const pasteFromClipboard = async () => {
|
||||||
|
if (!navigator.clipboard?.readText) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const rawValue = await navigator.clipboard.readText();
|
||||||
|
const normalized = normalizeClipboardNumber(rawValue);
|
||||||
|
|
||||||
|
if (resetCopiedTimeoutRef.current !== undefined) {
|
||||||
|
window.clearTimeout(resetCopiedTimeoutRef.current);
|
||||||
|
resetCopiedTimeoutRef.current = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCopied(false);
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
setDisplay("Erro");
|
||||||
|
setAccumulator(null);
|
||||||
|
setOperator(null);
|
||||||
|
setOverwrite(true);
|
||||||
|
setHistory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limitar a 10 dígitos
|
||||||
|
const digitCount = normalized.replace(/[-.]/g, "").length;
|
||||||
|
if (digitCount > 10) {
|
||||||
|
setDisplay("Erro");
|
||||||
|
setAccumulator(null);
|
||||||
|
setOperator(null);
|
||||||
|
setOverwrite(true);
|
||||||
|
setHistory(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setDisplay(normalized);
|
||||||
|
setAccumulator(null);
|
||||||
|
setOperator(null);
|
||||||
|
setOverwrite(false);
|
||||||
|
setHistory(null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Não foi possível colar o valor na calculadora.", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (resetCopiedTimeoutRef.current !== undefined) {
|
||||||
|
window.clearTimeout(resetCopiedTimeoutRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
display,
|
||||||
|
operator,
|
||||||
|
expression,
|
||||||
|
history,
|
||||||
|
resultText,
|
||||||
|
copied,
|
||||||
|
buttons,
|
||||||
|
inputDigit,
|
||||||
|
inputDecimal,
|
||||||
|
setNextOperator,
|
||||||
|
evaluate,
|
||||||
|
deleteLastDigit,
|
||||||
|
reset,
|
||||||
|
applyPercent,
|
||||||
|
copyToClipboard,
|
||||||
|
pasteFromClipboard,
|
||||||
|
};
|
||||||
|
}
|
||||||
112
lib/calculadora/use-draggable-dialog.ts
Normal file
112
lib/calculadora/use-draggable-dialog.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useCallback, useRef } from "react";
|
||||||
|
|
||||||
|
type Position = { x: number; y: number };
|
||||||
|
|
||||||
|
const MIN_VISIBLE_PX = 20;
|
||||||
|
|
||||||
|
function clampPosition(
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
elementWidth: number,
|
||||||
|
elementHeight: number,
|
||||||
|
): Position {
|
||||||
|
// Dialog starts centered (left/top 50% + translate(-50%, -50%)).
|
||||||
|
// Clamp offsets so at least MIN_VISIBLE_PX remains visible on each axis.
|
||||||
|
const halfViewportWidth = window.innerWidth / 2;
|
||||||
|
const halfViewportHeight = window.innerHeight / 2;
|
||||||
|
const halfElementWidth = elementWidth / 2;
|
||||||
|
const halfElementHeight = elementHeight / 2;
|
||||||
|
|
||||||
|
const minX = MIN_VISIBLE_PX - (halfViewportWidth + halfElementWidth);
|
||||||
|
const maxX = halfViewportWidth + halfElementWidth - MIN_VISIBLE_PX;
|
||||||
|
const minY = MIN_VISIBLE_PX - (halfViewportHeight + halfElementHeight);
|
||||||
|
const maxY = halfViewportHeight + halfElementHeight - MIN_VISIBLE_PX;
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: Math.min(Math.max(x, minX), maxX),
|
||||||
|
y: Math.min(Math.max(y, minY), maxY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPosition(el: HTMLElement, x: number, y: number) {
|
||||||
|
if (x === 0 && y === 0) {
|
||||||
|
el.style.translate = "";
|
||||||
|
el.style.transform = "";
|
||||||
|
} else {
|
||||||
|
// Keep the dialog's centered baseline (-50%, -50%) and only add drag offset.
|
||||||
|
el.style.translate = `calc(-50% + ${x}px) calc(-50% + ${y}px)`;
|
||||||
|
el.style.transform = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useDraggableDialog() {
|
||||||
|
const offset = useRef<Position>({ x: 0, y: 0 });
|
||||||
|
const dragStart = useRef<Position | null>(null);
|
||||||
|
const initialOffset = useRef<Position>({ x: 0, y: 0 });
|
||||||
|
const contentRef = useRef<HTMLElement | null>(null);
|
||||||
|
|
||||||
|
const onPointerDown = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (e.button !== 0) return;
|
||||||
|
|
||||||
|
dragStart.current = { x: e.clientX, y: e.clientY };
|
||||||
|
initialOffset.current = { x: offset.current.x, y: offset.current.y };
|
||||||
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerMove = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||||
|
if (!dragStart.current || !contentRef.current) return;
|
||||||
|
|
||||||
|
const dx = e.clientX - dragStart.current.x;
|
||||||
|
const dy = e.clientY - dragStart.current.y;
|
||||||
|
|
||||||
|
const rawX = initialOffset.current.x + dx;
|
||||||
|
const rawY = initialOffset.current.y + dy;
|
||||||
|
|
||||||
|
const el = contentRef.current;
|
||||||
|
const clamped = clampPosition(rawX, rawY, el.offsetWidth, el.offsetHeight);
|
||||||
|
|
||||||
|
offset.current = clamped;
|
||||||
|
applyPosition(el, clamped.x, clamped.y);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerUp = useCallback((e: React.PointerEvent<HTMLElement>) => {
|
||||||
|
dragStart.current = null;
|
||||||
|
if ((e.currentTarget as HTMLElement).hasPointerCapture(e.pointerId)) {
|
||||||
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onPointerCancel = useCallback(() => {
|
||||||
|
dragStart.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onLostPointerCapture = useCallback(() => {
|
||||||
|
dragStart.current = null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const resetPosition = useCallback(() => {
|
||||||
|
offset.current = { x: 0, y: 0 };
|
||||||
|
if (contentRef.current) {
|
||||||
|
applyPosition(contentRef.current, 0, 0);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const dragHandleProps = {
|
||||||
|
onPointerDown,
|
||||||
|
onPointerMove,
|
||||||
|
onPointerUp,
|
||||||
|
onPointerCancel,
|
||||||
|
onLostPointerCapture,
|
||||||
|
style: { touchAction: "none" as const, cursor: "grab" },
|
||||||
|
};
|
||||||
|
|
||||||
|
const contentRefCallback = useCallback((node: HTMLElement | null) => {
|
||||||
|
contentRef.current = node;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dragHandleProps,
|
||||||
|
contentRefCallback,
|
||||||
|
resetPosition,
|
||||||
|
};
|
||||||
|
}
|
||||||
45
lib/cartoes/brand-assets.ts
Normal file
45
lib/cartoes/brand-assets.ts
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
const CARD_BRAND_ASSET_BY_KEY = {
|
||||||
|
visa: "/bandeiras/visa.svg",
|
||||||
|
mastercard: "/bandeiras/mastercard.svg",
|
||||||
|
amex: "/bandeiras/amex.svg",
|
||||||
|
american: "/bandeiras/amex.svg",
|
||||||
|
elo: "/bandeiras/elo.svg",
|
||||||
|
hipercard: "/bandeiras/hipercard.svg",
|
||||||
|
hiper: "/bandeiras/hipercard.svg",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const CARD_BRAND_LOGO_BY_KEY = {
|
||||||
|
visa: "/logos/visa.png",
|
||||||
|
mastercard: "/logos/mastercard.png",
|
||||||
|
amex: "/logos/amex.png",
|
||||||
|
american: "/logos/amex.png",
|
||||||
|
elo: "/logos/elo.png",
|
||||||
|
hipercard: "/logos/hipercard.png",
|
||||||
|
hiper: "/logos/hipercard.png",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
const findMatchingCardBrandKey = (brand?: string | null) => {
|
||||||
|
if (!brand) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedBrand = brand.trim().toLowerCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
(
|
||||||
|
Object.keys(CARD_BRAND_ASSET_BY_KEY) as Array<
|
||||||
|
keyof typeof CARD_BRAND_ASSET_BY_KEY
|
||||||
|
>
|
||||||
|
).find((key) => normalizedBrand.includes(key)) ?? null
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveCardBrandAsset = (brand?: string | null) => {
|
||||||
|
const key = findMatchingCardBrandKey(brand);
|
||||||
|
return key ? CARD_BRAND_ASSET_BY_KEY[key] : null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveCardBrandLogoSrc = (brand?: string | null) => {
|
||||||
|
const key = findMatchingCardBrandKey(brand);
|
||||||
|
return key ? CARD_BRAND_LOGO_BY_KEY[key] : null;
|
||||||
|
};
|
||||||
7
lib/cartoes/constants.ts
Normal file
7
lib/cartoes/constants.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const DEFAULT_CARD_BRANDS = ["Visa", "Mastercard", "Elo"] as const;
|
||||||
|
|
||||||
|
export const DEFAULT_CARD_STATUS = ["Ativo", "Inativo"] as const;
|
||||||
|
|
||||||
|
export const DAYS_IN_MONTH = Array.from({ length: 31 }, (_, index) =>
|
||||||
|
String(index + 1).padStart(2, "0"),
|
||||||
|
);
|
||||||
3
lib/hooks/index.ts
Normal file
3
lib/hooks/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export { useControlledState } from "./use-controlled-state";
|
||||||
|
export { useFormState } from "./use-form-state";
|
||||||
|
export { useIsMobile, useMobile } from "./use-mobile";
|
||||||
51
lib/hooks/use-controlled-state.ts
Normal file
51
lib/hooks/use-controlled-state.ts
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing controlled/uncontrolled state pattern
|
||||||
|
* Allows a component to work both in controlled and uncontrolled mode
|
||||||
|
*
|
||||||
|
* @param controlledValue - The controlled value (undefined for uncontrolled mode)
|
||||||
|
* @param defaultValue - Default value for uncontrolled mode
|
||||||
|
* @param onChange - Callback when value changes
|
||||||
|
* @returns Tuple of [currentValue, setValue]
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* function MyComponent({ value, onChange }) {
|
||||||
|
* const [internalValue, setValue] = useControlledState(value, false, onChange);
|
||||||
|
* // Works both as controlled and uncontrolled
|
||||||
|
* }
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useControlledState<T>(
|
||||||
|
controlledValue: T | undefined,
|
||||||
|
defaultValue: T,
|
||||||
|
onChange?: (value: T) => void,
|
||||||
|
): [T, (value: T) => void] {
|
||||||
|
const [internalValue, setInternalValue] = useState<T>(defaultValue);
|
||||||
|
|
||||||
|
// Sync internal value when controlled value changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (controlledValue !== undefined) {
|
||||||
|
setInternalValue(controlledValue);
|
||||||
|
}
|
||||||
|
}, [controlledValue]);
|
||||||
|
|
||||||
|
// Use controlled value if provided, otherwise use internal value
|
||||||
|
const value = controlledValue !== undefined ? controlledValue : internalValue;
|
||||||
|
|
||||||
|
const setValue = useCallback(
|
||||||
|
(newValue: T) => {
|
||||||
|
// Update internal state if uncontrolled
|
||||||
|
if (controlledValue === undefined) {
|
||||||
|
setInternalValue(newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always call onChange if provided
|
||||||
|
onChange?.(newValue);
|
||||||
|
},
|
||||||
|
[controlledValue, onChange],
|
||||||
|
);
|
||||||
|
|
||||||
|
return [value, setValue];
|
||||||
|
}
|
||||||
60
lib/hooks/use-form-state.ts
Normal file
60
lib/hooks/use-form-state.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for managing form state with type-safe field updates
|
||||||
|
*
|
||||||
|
* @param initialValues - Initial form values
|
||||||
|
* @returns Object with formState, updateField, updateFields, replaceForm, resetForm
|
||||||
|
*
|
||||||
|
* @example
|
||||||
|
* ```tsx
|
||||||
|
* const { formState, updateField, resetForm } = useFormState({
|
||||||
|
* name: '',
|
||||||
|
* email: ''
|
||||||
|
* });
|
||||||
|
*
|
||||||
|
* updateField('name', 'John');
|
||||||
|
* ```
|
||||||
|
*/
|
||||||
|
export function useFormState<T extends object>(initialValues: T) {
|
||||||
|
const latestInitialValuesRef = useRef(initialValues);
|
||||||
|
latestInitialValuesRef.current = initialValues;
|
||||||
|
|
||||||
|
const [formState, setFormState] = useState<T>(initialValues);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates a single field in the form state
|
||||||
|
*/
|
||||||
|
const updateField = useCallback(
|
||||||
|
<K extends keyof T>(field: K, value: T[K]) => {
|
||||||
|
setFormState((prev) => ({ ...prev, [field]: value }));
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets form to initial values
|
||||||
|
*/
|
||||||
|
const resetForm = useCallback((nextValues?: T) => {
|
||||||
|
setFormState(nextValues ?? latestInitialValuesRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates multiple fields at once
|
||||||
|
*/
|
||||||
|
const updateFields = useCallback((updates: Partial<T>) => {
|
||||||
|
setFormState((prev) => ({ ...prev, ...updates }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const replaceForm = useCallback((nextValues: T) => {
|
||||||
|
setFormState(nextValues);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
formState,
|
||||||
|
updateField,
|
||||||
|
updateFields,
|
||||||
|
replaceForm,
|
||||||
|
resetForm,
|
||||||
|
};
|
||||||
|
}
|
||||||
28
lib/hooks/use-mobile.ts
Normal file
28
lib/hooks/use-mobile.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
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 const useMobile = useIsMobile;
|
||||||
@@ -38,3 +38,34 @@ export const deriveNameFromLogo = (logo?: string | null) => {
|
|||||||
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
.map((part) => part.charAt(0).toUpperCase() + part.slice(1).toLowerCase())
|
||||||
.join(" ");
|
.join(" ");
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const LOGO_SRC_PATTERN = /^(https?:\/\/|data:)/;
|
||||||
|
|
||||||
|
type ResolveLogoSrcOptions = {
|
||||||
|
basePath?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resolveLogoSrc = (
|
||||||
|
logo?: string | null,
|
||||||
|
options?: ResolveLogoSrcOptions,
|
||||||
|
) => {
|
||||||
|
if (!logo) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOGO_SRC_PATTERN.test(logo)) {
|
||||||
|
return logo;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logo.startsWith("/")) {
|
||||||
|
return logo;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = normalizeLogo(logo);
|
||||||
|
if (!fileName) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const basePath = options?.basePath?.replace(/\/$/, "") || "/logos";
|
||||||
|
return `${basePath}/${fileName}`;
|
||||||
|
};
|
||||||
|
|||||||
3
lib/schemas/index.ts
Normal file
3
lib/schemas/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./common";
|
||||||
|
export * from "./inbox";
|
||||||
|
export * from "./insights";
|
||||||
13
lib/types/actions.ts
Normal file
13
lib/types/actions.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* Standard action result type
|
||||||
|
*/
|
||||||
|
export type ActionResult<TData = void> =
|
||||||
|
| { success: true; message: string; data?: TData }
|
||||||
|
| { success: false; error: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Error result helper
|
||||||
|
*/
|
||||||
|
export function errorResult(error: string): ActionResult {
|
||||||
|
return { success: false, error };
|
||||||
|
}
|
||||||
62
lib/types/calendario.ts
Normal file
62
lib/types/calendario.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import type {
|
||||||
|
LancamentoItem,
|
||||||
|
SelectOption,
|
||||||
|
} from "@/components/lancamentos/types";
|
||||||
|
|
||||||
|
export type CalendarEvent =
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "lancamento";
|
||||||
|
date: string;
|
||||||
|
lancamento: LancamentoItem;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "boleto";
|
||||||
|
date: string;
|
||||||
|
lancamento: LancamentoItem;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
id: string;
|
||||||
|
type: "cartao";
|
||||||
|
date: string;
|
||||||
|
card: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dueDay: string;
|
||||||
|
closingDay: string;
|
||||||
|
brand: string | null;
|
||||||
|
status: string;
|
||||||
|
logo: string | null;
|
||||||
|
totalDue: number | null;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarPeriod = {
|
||||||
|
period: string;
|
||||||
|
monthName: string;
|
||||||
|
year: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarDay = {
|
||||||
|
date: string;
|
||||||
|
label: string;
|
||||||
|
isCurrentMonth: boolean;
|
||||||
|
isToday: boolean;
|
||||||
|
events: CalendarEvent[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarFormOptions = {
|
||||||
|
pagadorOptions: SelectOption[];
|
||||||
|
splitPagadorOptions: SelectOption[];
|
||||||
|
defaultPagadorId: string | null;
|
||||||
|
contaOptions: SelectOption[];
|
||||||
|
cartaoOptions: SelectOption[];
|
||||||
|
categoriaOptions: SelectOption[];
|
||||||
|
estabelecimentos: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CalendarData = {
|
||||||
|
events: CalendarEvent[];
|
||||||
|
formOptions: CalendarFormOptions;
|
||||||
|
};
|
||||||
3
lib/types/index.ts
Normal file
3
lib/types/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from "./actions";
|
||||||
|
export * from "./calendario";
|
||||||
|
export * from "./relatorios";
|
||||||
52
lib/types/relatorios.ts
Normal file
52
lib/types/relatorios.ts
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
/**
|
||||||
|
* Types for Category Report feature
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Monthly data for a specific category in a specific period
|
||||||
|
*/
|
||||||
|
export type MonthlyData = {
|
||||||
|
period: string; // Format: "YYYY-MM"
|
||||||
|
amount: number; // Total amount for this category in this period
|
||||||
|
previousAmount: number; // Amount from previous period (for comparison)
|
||||||
|
percentageChange: number | null; // Percentage change from previous period
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single category item in the report
|
||||||
|
*/
|
||||||
|
export type CategoryReportItem = {
|
||||||
|
categoryId: string;
|
||||||
|
name: string;
|
||||||
|
icon: string | null;
|
||||||
|
type: "despesa" | "receita";
|
||||||
|
monthlyData: Map<string, MonthlyData>; // Key: period (YYYY-MM)
|
||||||
|
total: number; // Total across all periods
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Complete category report data structure
|
||||||
|
*/
|
||||||
|
export type CategoryReportData = {
|
||||||
|
categories: CategoryReportItem[]; // All categories with their data
|
||||||
|
periods: string[]; // All periods in the report (sorted chronologically)
|
||||||
|
totals: Map<string, number>; // Total per period across all categories
|
||||||
|
grandTotal: number; // Total of all categories and all periods
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters for category report query
|
||||||
|
*/
|
||||||
|
export type CategoryReportFilters = {
|
||||||
|
startPeriod: string; // Format: "YYYY-MM"
|
||||||
|
endPeriod: string; // Format: "YYYY-MM"
|
||||||
|
categoryIds?: string[]; // Optional: filter by specific categories
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validation result for date range
|
||||||
|
*/
|
||||||
|
export type DateRangeValidation = {
|
||||||
|
isValid: boolean;
|
||||||
|
error?: string;
|
||||||
|
};
|
||||||
@@ -2,6 +2,48 @@
|
|||||||
* Utility functions for currency/decimal formatting and parsing
|
* Utility functions for currency/decimal formatting and parsing
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
type CurrencyFormatOptions = {
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
minimumFractionDigits?: number;
|
||||||
|
notation?: Intl.NumberFormatOptions["notation"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const currencyFormatter = new Intl.NumberFormat("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
minimumFractionDigits: 2,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const currencyFormatterNoCents = new Intl.NumberFormat("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const formatCurrency = (
|
||||||
|
value: number,
|
||||||
|
options: CurrencyFormatOptions = {},
|
||||||
|
) =>
|
||||||
|
new Intl.NumberFormat("pt-BR", {
|
||||||
|
style: "currency",
|
||||||
|
currency: "BRL",
|
||||||
|
minimumFractionDigits: options.minimumFractionDigits ?? 2,
|
||||||
|
maximumFractionDigits: options.maximumFractionDigits ?? 2,
|
||||||
|
...(options.notation ? { notation: options.notation } : {}),
|
||||||
|
}).format(value);
|
||||||
|
|
||||||
|
export const formatCurrencyCompact = (
|
||||||
|
value: number,
|
||||||
|
options: CurrencyFormatOptions = {},
|
||||||
|
) =>
|
||||||
|
formatCurrency(value, {
|
||||||
|
minimumFractionDigits: options.minimumFractionDigits ?? 0,
|
||||||
|
maximumFractionDigits: options.maximumFractionDigits ?? 0,
|
||||||
|
notation: options.notation ?? "compact",
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a decimal number for database storage (2 decimal places)
|
* Formats a decimal number for database storage (2 decimal places)
|
||||||
* @param value - The number to format
|
* @param value - The number to format
|
||||||
|
|||||||
@@ -37,6 +37,83 @@ const MONTH_NAMES = [
|
|||||||
"dezembro",
|
"dezembro",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
export const OPENMONETIS_TIME_ZONE = "America/Sao_Paulo";
|
||||||
|
|
||||||
|
type DateOnlyParts = {
|
||||||
|
year: number;
|
||||||
|
month: number;
|
||||||
|
day: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function capitalize(value: string): string {
|
||||||
|
return value.length > 0
|
||||||
|
? value[0]?.toUpperCase().concat(value.slice(1))
|
||||||
|
: value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDateOnlyString({ year, month, day }: DateOnlyParts): string {
|
||||||
|
return `${year}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateOnlyParts(value: string): DateOnlyParts | null {
|
||||||
|
const match = value.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||||
|
if (!match) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, yearStr, monthStr, dayStr] = match;
|
||||||
|
const year = Number.parseInt(yearStr ?? "", 10);
|
||||||
|
const month = Number.parseInt(monthStr ?? "", 10);
|
||||||
|
const day = Number.parseInt(dayStr ?? "", 10);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isNaN(year) ||
|
||||||
|
Number.isNaN(month) ||
|
||||||
|
Number.isNaN(day) ||
|
||||||
|
month < 1 ||
|
||||||
|
month > 12 ||
|
||||||
|
day < 1 ||
|
||||||
|
day > 31
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const utcDate = new Date(Date.UTC(year, month - 1, day));
|
||||||
|
if (
|
||||||
|
utcDate.getUTCFullYear() !== year ||
|
||||||
|
utcDate.getUTCMonth() !== month - 1 ||
|
||||||
|
utcDate.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return { year, month, day };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTimeZoneParts(
|
||||||
|
date: Date,
|
||||||
|
timeZone: string,
|
||||||
|
): { year: number; month: number; day: number; hour: number } {
|
||||||
|
const formatter = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
hour: "2-digit",
|
||||||
|
hour12: false,
|
||||||
|
});
|
||||||
|
const parts = formatter.formatToParts(date);
|
||||||
|
const getPart = (type: Intl.DateTimeFormatPartTypes) =>
|
||||||
|
parts.find((part) => part.type === type)?.value ?? "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
year: Number.parseInt(getPart("year"), 10),
|
||||||
|
month: Number.parseInt(getPart("month"), 10),
|
||||||
|
day: Number.parseInt(getPart("day"), 10),
|
||||||
|
hour: Number.parseInt(getPart("hour"), 10),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DATE CREATION & MANIPULATION
|
// DATE CREATION & MANIPULATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -53,48 +130,146 @@ const MONTH_NAMES = [
|
|||||||
* @returns Date object in local timezone
|
* @returns Date object in local timezone
|
||||||
*/
|
*/
|
||||||
export function parseLocalDateString(dateString: string): Date {
|
export function parseLocalDateString(dateString: string): Date {
|
||||||
const [year, month, day] = dateString.split("-");
|
const parts = parseDateOnlyParts(dateString);
|
||||||
return new Date(
|
if (!parts) {
|
||||||
Number.parseInt(year ?? "0", 10),
|
return new Date(Number.NaN);
|
||||||
Number.parseInt(month ?? "1", 10) - 1,
|
}
|
||||||
Number.parseInt(day ?? "1", 10),
|
|
||||||
);
|
return new Date(parts.year, parts.month - 1, parts.day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely parses a date string (YYYY-MM-DD) as UTC midnight
|
||||||
|
*/
|
||||||
|
export function parseUtcDateString(dateString: string): Date | null {
|
||||||
|
const parts = parseDateOnlyParts(dateString);
|
||||||
|
if (!parts) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Date(Date.UTC(parts.year, parts.month - 1, parts.day));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a Date or date string to YYYY-MM-DD
|
||||||
|
*/
|
||||||
|
export function toDateOnlyString(
|
||||||
|
value: Date | string | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string") {
|
||||||
|
const directValue = value.slice(0, 10);
|
||||||
|
return parseDateOnlyParts(directValue) ? directValue : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number.isNaN(value.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDateOnlyString({
|
||||||
|
year: value.getUTCFullYear(),
|
||||||
|
month: value.getUTCMonth() + 1,
|
||||||
|
day: value.getUTCDate(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts a local Date object to YYYY-MM-DD without timezone normalization
|
||||||
|
*/
|
||||||
|
export function toLocalDateString(
|
||||||
|
value: Date | null | undefined,
|
||||||
|
): string | null {
|
||||||
|
if (!value || Number.isNaN(value.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildDateOnlyString({
|
||||||
|
year: value.getFullYear(),
|
||||||
|
month: value.getMonth() + 1,
|
||||||
|
day: value.getDate(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets today's date as YYYY-MM-DD string
|
* Gets today's date as YYYY-MM-DD string
|
||||||
* @returns Formatted date string
|
* @returns Formatted date string
|
||||||
*/
|
*/
|
||||||
export function getTodayDateString(): string {
|
export function getTodayDateString(date: Date = new Date()): string {
|
||||||
const now = new Date();
|
return toLocalDateString(date) ?? "";
|
||||||
const year = now.getFullYear();
|
}
|
||||||
const month = String(now.getMonth() + 1).padStart(2, "0");
|
|
||||||
const day = String(now.getDate()).padStart(2, "0");
|
|
||||||
|
|
||||||
return `${year}-${month}-${day}`;
|
/**
|
||||||
|
* Gets a date string in YYYY-MM-DD format for a specific timezone
|
||||||
|
*/
|
||||||
|
export function getDateStringInTimeZone(
|
||||||
|
timeZone: string,
|
||||||
|
date: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const parts = getTimeZoneParts(date, timeZone);
|
||||||
|
return buildDateOnlyString(parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets today's date using the app business timezone
|
||||||
|
*/
|
||||||
|
export function getBusinessDateString(date: Date = new Date()): string {
|
||||||
|
return getDateStringInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets today's date as Date object
|
* Gets today's date as Date object
|
||||||
* @returns Date object for today
|
* @returns Date object for today
|
||||||
*/
|
*/
|
||||||
export function getTodayDate(): Date {
|
export function getTodayDate(date: Date = new Date()): Date {
|
||||||
return parseLocalDateString(getTodayDateString());
|
return parseLocalDateString(getTodayDateString(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets today's date as Date object using the app business timezone
|
||||||
|
*/
|
||||||
|
export function getBusinessTodayDate(date: Date = new Date()): Date {
|
||||||
|
return parseLocalDateString(getBusinessDateString(date));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Gets today's info (date and period)
|
* Gets today's info (date and period)
|
||||||
* @returns Object with date and period
|
* @returns Object with date and period
|
||||||
*/
|
*/
|
||||||
export function getTodayInfo(): { date: Date; period: string } {
|
export function getTodayInfo(date: Date = new Date()): {
|
||||||
const now = new Date();
|
date: Date;
|
||||||
const year = now.getFullYear();
|
period: string;
|
||||||
const month = now.getMonth();
|
} {
|
||||||
const day = now.getDate();
|
const today = getTodayDateString(date);
|
||||||
|
const parts = parseDateOnlyParts(today);
|
||||||
|
if (!parts) {
|
||||||
|
return { date: new Date(Number.NaN), period: "" };
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
date: new Date(year, month, day),
|
date: new Date(parts.year, parts.month - 1, parts.day),
|
||||||
period: `${year}-${String(month + 1).padStart(2, "0")}`,
|
period: `${parts.year}-${String(parts.month).padStart(2, "0")}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets today's info using the app business timezone
|
||||||
|
*/
|
||||||
|
export function getBusinessTodayInfo(date: Date = new Date()): {
|
||||||
|
date: Date;
|
||||||
|
period: string;
|
||||||
|
} {
|
||||||
|
const today = getBusinessDateString(date);
|
||||||
|
const parts = parseDateOnlyParts(today);
|
||||||
|
if (!parts) {
|
||||||
|
return { date: new Date(Number.NaN), period: "" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
date: new Date(parts.year, parts.month - 1, parts.day),
|
||||||
|
period: `${parts.year}-${String(parts.month).padStart(2, "0")}`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,12 +301,20 @@ export function addMonthsToDate(value: Date, offset: number): Date {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a date string (YYYY-MM-DD) to short display format
|
* Formats a date value to short display format
|
||||||
* @example
|
* @example
|
||||||
* formatDate("2024-11-14") // "qui 14 nov"
|
* formatDate("2024-11-14") // "qui 14 nov"
|
||||||
*/
|
*/
|
||||||
export function formatDate(value: string): string {
|
export function formatDate(value: string | Date | null | undefined): string {
|
||||||
const parsed = parseLocalDateString(value);
|
const dateString = toDateOnlyString(value);
|
||||||
|
if (!dateString) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseLocalDateString(dateString);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return "—";
|
||||||
|
}
|
||||||
|
|
||||||
return new Intl.DateTimeFormat("pt-BR", {
|
return new Intl.DateTimeFormat("pt-BR", {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
@@ -143,6 +326,154 @@ export function formatDate(value: string): string {
|
|||||||
.replace(" de", "");
|
.replace(" de", "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats a date-only value (YYYY-MM-DD) using UTC to preserve the civil day
|
||||||
|
*/
|
||||||
|
export function formatDateOnly(
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
options: Intl.DateTimeFormatOptions = {},
|
||||||
|
): string | null {
|
||||||
|
const dateString = toDateOnlyString(value);
|
||||||
|
if (!dateString) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseUtcDateString(dateString);
|
||||||
|
if (!parsed) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
day: "2-digit",
|
||||||
|
month: "short",
|
||||||
|
year: "numeric",
|
||||||
|
timeZone: "UTC",
|
||||||
|
...options,
|
||||||
|
}).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTime(
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
options: Intl.DateTimeFormatOptions = {
|
||||||
|
dateStyle: "short",
|
||||||
|
timeStyle: "short",
|
||||||
|
},
|
||||||
|
): string | null {
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = value instanceof Date ? value : new Date(value);
|
||||||
|
if (Number.isNaN(parsed.getTime())) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat("pt-BR", options).format(parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateOnlyLabel(
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
prefix?: string,
|
||||||
|
options?: Intl.DateTimeFormatOptions,
|
||||||
|
): string | null {
|
||||||
|
const formatted = formatDateOnly(value, options);
|
||||||
|
if (!formatted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix ? `${prefix} ${formatted}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDateTimeLabel(
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
prefix?: string,
|
||||||
|
options?: Intl.DateTimeFormatOptions,
|
||||||
|
): string | null {
|
||||||
|
const formatted = formatDateTime(value, options);
|
||||||
|
if (!formatted) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return prefix ? `${prefix} ${formatted}` : formatted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compareDateOnly(
|
||||||
|
left: string | Date | null | undefined,
|
||||||
|
right: string | Date | null | undefined,
|
||||||
|
): number {
|
||||||
|
const leftValue = toDateOnlyString(left);
|
||||||
|
const rightValue = toDateOnlyString(right);
|
||||||
|
|
||||||
|
if (!leftValue || !rightValue || leftValue === rightValue) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return leftValue < rightValue ? -1 : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDateOnlyPast(
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
reference: string | Date | null | undefined = getBusinessDateString(),
|
||||||
|
): boolean {
|
||||||
|
return compareDateOnly(value, reference) < 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isDateOnlyWithinDays(
|
||||||
|
value: string | Date | null | undefined,
|
||||||
|
daysThreshold: number,
|
||||||
|
reference: string | Date | null | undefined = getBusinessDateString(),
|
||||||
|
): boolean {
|
||||||
|
const dateValue = toDateOnlyString(value);
|
||||||
|
const referenceValue = toDateOnlyString(reference);
|
||||||
|
if (
|
||||||
|
!dateValue ||
|
||||||
|
!referenceValue ||
|
||||||
|
compareDateOnly(dateValue, referenceValue) < 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetDate = parseUtcDateString(dateValue);
|
||||||
|
const referenceDate = parseUtcDateString(referenceValue);
|
||||||
|
if (!targetDate || !referenceDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitDate = new Date(referenceDate);
|
||||||
|
limitDate.setUTCDate(limitDate.getUTCDate() + daysThreshold);
|
||||||
|
return targetDate <= limitDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDateOnlyStringFromPeriodDay(
|
||||||
|
period: string,
|
||||||
|
dayValue: string | number,
|
||||||
|
): string | null {
|
||||||
|
const [yearPart, monthPart] = period.split("-");
|
||||||
|
const year = Number.parseInt(yearPart ?? "", 10);
|
||||||
|
const month = Number.parseInt(monthPart ?? "", 10);
|
||||||
|
const day = typeof dayValue === "number" ? dayValue : Number(dayValue);
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isNaN(year) ||
|
||||||
|
Number.isNaN(month) ||
|
||||||
|
Number.isNaN(day) ||
|
||||||
|
month < 1 ||
|
||||||
|
month > 12 ||
|
||||||
|
day < 1
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const daysInMonth = new Date(year, month, 0).getDate();
|
||||||
|
const clampedDay = Math.min(day, daysInMonth);
|
||||||
|
|
||||||
|
return buildDateOnlyString({
|
||||||
|
year,
|
||||||
|
month,
|
||||||
|
day: clampedDay,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats a date to friendly long format
|
* Formats a date to friendly long format
|
||||||
* @example
|
* @example
|
||||||
@@ -173,5 +504,39 @@ export function getGreeting(date: Date = new Date()): string {
|
|||||||
return "Boa noite";
|
return "Boa noite";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGreetingInTimeZone(
|
||||||
|
timeZone: string,
|
||||||
|
date: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
const { hour } = getTimeZoneParts(date, timeZone);
|
||||||
|
if (hour >= 5 && hour < 12) return "Bom dia";
|
||||||
|
if (hour >= 12 && hour < 18) return "Boa tarde";
|
||||||
|
return "Boa noite";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBusinessGreeting(date: Date = new Date()): string {
|
||||||
|
return getGreetingInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCurrentDateInTimeZone(
|
||||||
|
timeZone: string,
|
||||||
|
date: Date = new Date(),
|
||||||
|
): string {
|
||||||
|
return capitalize(
|
||||||
|
new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
weekday: "long",
|
||||||
|
day: "numeric",
|
||||||
|
month: "long",
|
||||||
|
year: "numeric",
|
||||||
|
hour12: false,
|
||||||
|
timeZone,
|
||||||
|
}).format(date),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBusinessCurrentDate(date: Date = new Date()): string {
|
||||||
|
return formatCurrentDateInTimeZone(OPENMONETIS_TIME_ZONE, date);
|
||||||
|
}
|
||||||
|
|
||||||
// Re-export MONTH_NAMES for convenience
|
// Re-export MONTH_NAMES for convenience
|
||||||
export { MONTH_NAMES };
|
export { MONTH_NAMES };
|
||||||
|
|||||||
66
lib/utils/financial-dates.ts
Normal file
66
lib/utils/financial-dates.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import {
|
||||||
|
buildDateOnlyStringFromPeriodDay,
|
||||||
|
formatDateOnlyLabel,
|
||||||
|
} from "@/lib/utils/date";
|
||||||
|
|
||||||
|
type FinancialStatusLabelInput = {
|
||||||
|
isSettled: boolean;
|
||||||
|
dueDate: string | null;
|
||||||
|
paidAt: string | null;
|
||||||
|
paidPrefix?: string;
|
||||||
|
duePrefix?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type FinancialDueDateInfo = {
|
||||||
|
label: string;
|
||||||
|
date: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatFinancialDateLabel(
|
||||||
|
value: string | null,
|
||||||
|
prefix?: string,
|
||||||
|
options?: Intl.DateTimeFormatOptions,
|
||||||
|
): string | null {
|
||||||
|
return formatDateOnlyLabel(value, prefix, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildFinancialStatusLabel({
|
||||||
|
isSettled,
|
||||||
|
dueDate,
|
||||||
|
paidAt,
|
||||||
|
paidPrefix = "Pago em",
|
||||||
|
duePrefix = "Vence em",
|
||||||
|
}: FinancialStatusLabelInput): string | null {
|
||||||
|
if (isSettled) {
|
||||||
|
return formatFinancialDateLabel(paidAt, paidPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
return formatFinancialDateLabel(dueDate, duePrefix);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDueDateInfoFromPeriodDay(
|
||||||
|
period: string,
|
||||||
|
dueDay: string,
|
||||||
|
options?: {
|
||||||
|
prefix?: string;
|
||||||
|
fallbackPrefix?: string;
|
||||||
|
},
|
||||||
|
): FinancialDueDateInfo {
|
||||||
|
const prefix = options?.prefix ?? "Vence em";
|
||||||
|
const fallbackPrefix = options?.fallbackPrefix ?? "Vence dia";
|
||||||
|
const dueDate = buildDateOnlyStringFromPeriodDay(period, dueDay);
|
||||||
|
|
||||||
|
if (!dueDate) {
|
||||||
|
return {
|
||||||
|
label: `${fallbackPrefix} ${dueDay}`,
|
||||||
|
date: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
label:
|
||||||
|
formatFinancialDateLabel(dueDate, prefix) ??
|
||||||
|
`${fallbackPrefix} ${dueDay}`,
|
||||||
|
date: dueDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
43
lib/utils/percentage.ts
Normal file
43
lib/utils/percentage.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
type FormatPercentageOptions = {
|
||||||
|
minimumFractionDigits?: number;
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
absolute?: boolean;
|
||||||
|
signDisplay?: Intl.NumberFormatOptions["signDisplay"];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function formatPercentage(
|
||||||
|
value: number,
|
||||||
|
options?: FormatPercentageOptions,
|
||||||
|
): string {
|
||||||
|
const normalizedValue = options?.absolute ? Math.abs(value) : value;
|
||||||
|
|
||||||
|
return `${new Intl.NumberFormat("pt-BR", {
|
||||||
|
minimumFractionDigits: options?.minimumFractionDigits ?? 0,
|
||||||
|
maximumFractionDigits: options?.maximumFractionDigits ?? 1,
|
||||||
|
...(options?.signDisplay ? { signDisplay: options.signDisplay } : {}),
|
||||||
|
}).format(normalizedValue)}%`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatPercentageChange(value: number | null): string {
|
||||||
|
if (value === null) {
|
||||||
|
return "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
const absoluteValue = Math.abs(value);
|
||||||
|
const formatterOptions =
|
||||||
|
absoluteValue < 10
|
||||||
|
? {
|
||||||
|
minimumFractionDigits: 1,
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
return formatPercentage(value, {
|
||||||
|
...formatterOptions,
|
||||||
|
absolute: true,
|
||||||
|
signDisplay: value === 0 ? "auto" : "always",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -70,9 +70,8 @@ export function formatPeriod(year: number, month: number): string {
|
|||||||
* @example
|
* @example
|
||||||
* getCurrentPeriod() // "2025-11"
|
* getCurrentPeriod() // "2025-11"
|
||||||
*/
|
*/
|
||||||
export function getCurrentPeriod(): string {
|
export function getCurrentPeriod(date: Date = new Date()): string {
|
||||||
const now = new Date();
|
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||||
return formatPeriod(now.getFullYear(), now.getMonth() + 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,11 +174,31 @@ export function buildPeriodRange(start: string, end: string): string[] {
|
|||||||
return items;
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Builds a trailing period window ending at the reference period
|
||||||
|
* @example
|
||||||
|
* buildPeriodWindow("2025-11", 3) // ["2025-09", "2025-10", "2025-11"]
|
||||||
|
*/
|
||||||
|
export function buildPeriodWindow(
|
||||||
|
referencePeriod: string,
|
||||||
|
totalMonths: number,
|
||||||
|
): string[] {
|
||||||
|
if (totalMonths <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from({ length: totalMonths }, (_, index) =>
|
||||||
|
addMonthsToPeriod(referencePeriod, index - (totalMonths - 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// URL PARAM HANDLING (mes-ano format for Portuguese URLs)
|
// URL PARAM HANDLING (mes-ano format for Portuguese URLs)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const MONTH_MAP = new Map(MONTH_NAMES.map((name, index) => [name, index]));
|
const MONTH_MAP = new Map<string, number>(
|
||||||
|
MONTH_NAMES.map((name, index) => [name, index]),
|
||||||
|
);
|
||||||
|
|
||||||
const normalize = (value: string | null | undefined) =>
|
const normalize = (value: string | null | undefined) =>
|
||||||
(value ?? "").trim().toLowerCase();
|
(value ?? "").trim().toLowerCase();
|
||||||
@@ -271,6 +290,32 @@ function capitalize(value: string): string {
|
|||||||
: value;
|
: value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts period string (YYYY-MM) to Date object for the first day of month
|
||||||
|
*/
|
||||||
|
export function periodToDate(period: string): Date {
|
||||||
|
const { year, month } = parsePeriod(period);
|
||||||
|
return new Date(year, month - 1, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts Date object to period string (YYYY-MM)
|
||||||
|
*/
|
||||||
|
export function dateToPeriod(date: Date): string {
|
||||||
|
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats period as "Mes Ano"
|
||||||
|
* @example
|
||||||
|
* formatMonthYearLabel("2025-11") // "Novembro 2025"
|
||||||
|
*/
|
||||||
|
export function formatMonthYearLabel(period: string): string {
|
||||||
|
const { year, month } = parsePeriod(period);
|
||||||
|
const monthName = MONTH_NAMES[month - 1] ?? "";
|
||||||
|
return `${capitalize(monthName)} ${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Formats period for display in Portuguese
|
* Formats period for display in Portuguese
|
||||||
* @example
|
* @example
|
||||||
@@ -291,6 +336,46 @@ export function formatMonthLabel(period: string): string {
|
|||||||
return displayPeriod(period);
|
return displayPeriod(period);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats period for short display with full year
|
||||||
|
* @example
|
||||||
|
* formatShortPeriodLabel("2025-11") // "Nov/2025"
|
||||||
|
*/
|
||||||
|
export function formatShortPeriodLabel(period: string): string {
|
||||||
|
const rawLabel = new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
month: "short",
|
||||||
|
}).format(periodToDate(period));
|
||||||
|
const label = capitalize(rawLabel.replace(".", ""));
|
||||||
|
const { year } = parsePeriod(period);
|
||||||
|
return `${label}/${year}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats period for compact display
|
||||||
|
* @example
|
||||||
|
* formatCompactPeriodLabel("2025-11") // "Nov/25"
|
||||||
|
*/
|
||||||
|
export function formatCompactPeriodLabel(period: string): string {
|
||||||
|
const { year } = parsePeriod(period);
|
||||||
|
const rawLabel = new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
month: "short",
|
||||||
|
}).format(periodToDate(period));
|
||||||
|
const label = capitalize(rawLabel.replace(".", ""));
|
||||||
|
return `${label}/${String(year).slice(-2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats period as short month only
|
||||||
|
* @example
|
||||||
|
* formatPeriodMonthShort("2025-11") // "Nov"
|
||||||
|
*/
|
||||||
|
export function formatPeriodMonthShort(period: string): string {
|
||||||
|
const rawLabel = new Intl.DateTimeFormat("pt-BR", {
|
||||||
|
month: "short",
|
||||||
|
}).format(periodToDate(period));
|
||||||
|
return capitalize(rawLabel.replace(".", ""));
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// DATE DERIVATION
|
// DATE DERIVATION
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -320,5 +405,5 @@ export function derivePeriodFromDate(value?: string | null): string {
|
|||||||
return getCurrentPeriod();
|
return getCurrentPeriod();
|
||||||
}
|
}
|
||||||
|
|
||||||
return formatPeriod(date.getFullYear(), date.getMonth() + 1);
|
return dateToPeriod(date);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user